Files
baby-nas/web/src/pages/Samba.tsx
T
darroyo 03e9368a91 feat: add invalid_users directive for Samba shares
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.
2026-07-06 15:37:01 -04:00

276 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}