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.
This commit is contained in:
2026-07-06 15:17:32 -04:00
parent 3e290164c5
commit 03e9368a91
18 changed files with 505 additions and 77 deletions
+4
View File
@@ -7,6 +7,7 @@ export interface SambaShare {
guest_ok: boolean;
valid_users: string[];
valid_groups: string[];
invalid_users: string[];
}
export interface NFSClient {
@@ -213,6 +214,9 @@ export const api = {
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
// import
importUsers: () => request<{ imported: number; message?: string }>("POST", "/import/users"),
// files
fileCapabilities: () => request<FileCapabilities>("GET", "/files/capabilities"),
fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"),
+115 -21
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react";
import { api, SambaShare } from "../api";
import { api, SambaShare, User } from "../api";
import { useDirty } from "../DirtyContext";
import Modal from "../components/Modal";
import PathField from "../components/PathField";
@@ -12,12 +12,73 @@ const empty: Partial<SambaShare> = {
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() {
@@ -25,10 +86,24 @@ export default function Samba() {
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;
@@ -58,7 +133,7 @@ export default function Samba() {
<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={() => setEditing({ ...empty })}>
<button className="btn-primary" onClick={() => openEditor(null)}>
Nuevo share
</button>
</div>
@@ -82,9 +157,25 @@ export default function Samba() {
<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 text-slate-400">{s.valid_users.join(", ") || "—"}</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={() => setEditing({ ...s })}>
<button className="btn-ghost mr-2" onClick={() => openEditor(s)}>
Editar
</button>
<button className="btn-danger" onClick={() => remove(s.id)}>
@@ -122,7 +213,7 @@ export default function Samba() {
<label className="label">Path (absoluto)</label>
<PathField
value={editing.path ?? ""}
onChange={p => setEditing({ ...editing, path: p })}
onChange={(p) => setEditing({ ...editing, path: p })}
/>
</div>
<div>
@@ -133,22 +224,25 @@ export default function Samba() {
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
/>
</div>
<div>
<label className="label">Usuarios válidos (separados por coma)</label>
<input
className="input"
value={(editing.valid_users ?? []).join(", ")}
onChange={(e) =>
setEditing({
...editing,
valid_users: e.target.value
.split(",")
.map((v) => v.trim())
.filter(Boolean),
})
}
/>
</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
+17 -3
View File
@@ -57,9 +57,23 @@ export default function Users() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Usuarios del sistema</h1>
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
Nuevo usuario
</button>
<div className="flex gap-2">
<button className="btn-ghost" onClick={async () => {
try {
const res = await api.importUsers();
await load();
await refresh();
alert(`Importados ${res.imported} usuarios del sistema. Revisa y aplica los cambios.`);
} catch (err) {
alert(err instanceof Error ? err.message : "Error al importar");
}
}}>
Re-importar del sistema
</button>
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
Nuevo usuario
</button>
</div>
</div>
<div className="card overflow-x-auto p-0">