03e9368a91
Samba shares now support an 'invalid users' list (deny list), written as 'invalid users = u1,u2' in smb.conf. The UI shows a ChipPicker for valid_users and invalid_users, mutually exclusive, sourced from the system user list. feat: add ImportSystemUsers for fresh installations When NASCTL_IMPORT_ON_BOOT=true, nasctl now imports existing system users from /etc/passwd (UID 1000-60000) and /etc/group (supplemental groups), and detects which have Samba accounts via 'pdbedit -L'. Imported users are marked dirty so the admin can review before applying. New POST /api/import/users endpoint for manual re-import. This mirrors the existing import-on-boot flow for smb.conf and /etc/exports.
276 lines
9.2 KiB
TypeScript
276 lines
9.2 KiB
TypeScript
import { FormEvent, useEffect, useState } from "react";
|
||
import { api, SambaShare, User } from "../api";
|
||
import { useDirty } from "../DirtyContext";
|
||
import Modal from "../components/Modal";
|
||
import PathField from "../components/PathField";
|
||
|
||
const empty: Partial<SambaShare> = {
|
||
name: "",
|
||
path: "",
|
||
comment: "",
|
||
read_only: false,
|
||
guest_ok: false,
|
||
valid_users: [],
|
||
valid_groups: [],
|
||
invalid_users: [],
|
||
};
|
||
|
||
type ChipPickerProps = {
|
||
label: string;
|
||
options: User[];
|
||
selected: string[];
|
||
onChange: (selected: string[]) => void;
|
||
allowRemove?: boolean;
|
||
};
|
||
|
||
function ChipPicker({ label, options, selected, onChange }: ChipPickerProps) {
|
||
const available = options.filter((u) => !selected.includes(u.username));
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<label className="label">{label}</label>
|
||
{selected.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{selected.map((username) => {
|
||
const user = options.find((u) => u.username === username);
|
||
const isSMB = user?.smb_enabled;
|
||
return (
|
||
<span
|
||
key={username}
|
||
className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2.5 py-0.5 text-xs text-emerald-300"
|
||
>
|
||
{username}
|
||
{isSMB && <span title="SMB habilitado">*</span>}
|
||
<button
|
||
type="button"
|
||
className="ml-0.5 rounded-full hover:text-white"
|
||
onClick={() => onChange(selected.filter((u) => u !== username))}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{available.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{available.map((user) => (
|
||
<button
|
||
key={user.username}
|
||
type="button"
|
||
className="rounded-full bg-slate-700 px-2.5 py-0.5 text-xs text-slate-300 hover:bg-slate-600 hover:text-white"
|
||
onClick={() => onChange([...selected, user.username])}
|
||
>
|
||
+ {user.username}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{options.length === 0 && (
|
||
<p className="text-xs text-slate-500">No hay usuarios del sistema. Créalos primero.</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Samba() {
|
||
const [shares, setShares] = useState<SambaShare[]>([]);
|
||
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [systemUsers, setSystemUsers] = useState<User[]>([]);
|
||
const { refresh } = useDirty();
|
||
|
||
async function load() {
|
||
const res = await api.listShares();
|
||
setShares(res.shares ?? []);
|
||
}
|
||
|
||
async function loadSystemUsers() {
|
||
try {
|
||
const res = await api.listUsers();
|
||
setSystemUsers(res.users ?? []);
|
||
} catch {
|
||
setSystemUsers([]);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
function openEditor(share: Partial<SambaShare> | null) {
|
||
setEditing(share ? { ...share } : { ...empty });
|
||
loadSystemUsers();
|
||
}
|
||
|
||
async function save(e: FormEvent) {
|
||
e.preventDefault();
|
||
if (!editing) return;
|
||
setError(null);
|
||
try {
|
||
if (editing.id) {
|
||
await api.updateShare(editing.id, editing);
|
||
} else {
|
||
await api.createShare(editing);
|
||
}
|
||
setEditing(null);
|
||
await load();
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Error al guardar");
|
||
}
|
||
}
|
||
|
||
async function remove(id: number) {
|
||
if (!confirm("¿Eliminar este share?")) return;
|
||
await api.deleteShare(id);
|
||
await load();
|
||
await refresh();
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold text-white">Shares SMB / Samba</h1>
|
||
<button className="btn-primary" onClick={() => openEditor(null)}>
|
||
Nuevo share
|
||
</button>
|
||
</div>
|
||
|
||
<div className="card overflow-x-auto p-0">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="border-b border-slate-800 text-slate-400">
|
||
<tr>
|
||
<th className="px-4 py-3">Nombre</th>
|
||
<th className="px-4 py-3">Path</th>
|
||
<th className="px-4 py-3">Modo</th>
|
||
<th className="px-4 py-3">Invitado</th>
|
||
<th className="px-4 py-3">Usuarios</th>
|
||
<th className="px-4 py-3"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{shares.map((s) => (
|
||
<tr key={s.id} className="border-b border-slate-800/60">
|
||
<td className="px-4 py-3 font-medium text-slate-100">{s.name}</td>
|
||
<td className="px-4 py-3 text-slate-300">{s.path}</td>
|
||
<td className="px-4 py-3">{s.read_only ? "Solo lectura" : "Lectura/Escritura"}</td>
|
||
<td className="px-4 py-3">{s.guest_ok ? "Sí" : "No"}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-1">
|
||
{s.valid_users.map((u) => (
|
||
<span key={u} className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300">
|
||
{u}
|
||
</span>
|
||
))}
|
||
{s.invalid_users.map((u) => (
|
||
<span key={u} className="rounded-full bg-red-500/20 px-2 py-0.5 text-xs text-red-300">
|
||
!{u}
|
||
</span>
|
||
))}
|
||
{s.valid_users.length === 0 && s.invalid_users.length === 0 && (
|
||
<span className="text-slate-500">—</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<button className="btn-ghost mr-2" onClick={() => openEditor(s)}>
|
||
Editar
|
||
</button>
|
||
<button className="btn-danger" onClick={() => remove(s.id)}>
|
||
Eliminar
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{shares.length === 0 && (
|
||
<tr>
|
||
<td colSpan={6} className="px-4 py-6 text-center text-slate-500">
|
||
No hay shares configurados.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{editing && (
|
||
<Modal title={editing.id ? "Editar share" : "Nuevo share"} onClose={() => setEditing(null)}>
|
||
<form onSubmit={save} className="space-y-4">
|
||
{error && (
|
||
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||
)}
|
||
<div>
|
||
<label className="label">Nombre</label>
|
||
<input
|
||
className="input"
|
||
value={editing.name ?? ""}
|
||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="label">Path (absoluto)</label>
|
||
<PathField
|
||
value={editing.path ?? ""}
|
||
onChange={(p) => setEditing({ ...editing, path: p })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="label">Comentario</label>
|
||
<input
|
||
className="input"
|
||
value={editing.comment ?? ""}
|
||
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
<ChipPicker
|
||
label="Usuarios válidos (acceso permitido)"
|
||
options={systemUsers}
|
||
selected={editing.valid_users ?? []}
|
||
onChange={(selected) =>
|
||
setEditing({ ...editing, valid_users: selected })
|
||
}
|
||
/>
|
||
|
||
<ChipPicker
|
||
label="Usuarios denegados (acceso explícitamente bloqueado)"
|
||
options={systemUsers}
|
||
selected={editing.invalid_users ?? []}
|
||
onChange={(selected) =>
|
||
setEditing({ ...editing, invalid_users: selected })
|
||
}
|
||
/>
|
||
|
||
<div className="flex gap-6">
|
||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={editing.read_only ?? false}
|
||
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
|
||
/>
|
||
Solo lectura
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={editing.guest_ok ?? false}
|
||
onChange={(e) => setEditing({ ...editing, guest_ok: e.target.checked })}
|
||
/>
|
||
Permitir invitado
|
||
</label>
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
||
Cancelar
|
||
</button>
|
||
<button className="btn-primary">Guardar</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|