8003db49b3
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.
252 lines
7.4 KiB
TypeScript
252 lines
7.4 KiB
TypeScript
export interface SambaShare {
|
|
id: number;
|
|
name: string;
|
|
path: string;
|
|
comment: string;
|
|
read_only: boolean;
|
|
guest_ok: boolean;
|
|
valid_users: string[];
|
|
valid_groups: string[];
|
|
invalid_users: string[];
|
|
}
|
|
|
|
export interface NFSClient {
|
|
host: string;
|
|
read_only: boolean;
|
|
async: boolean;
|
|
root_squash: boolean;
|
|
subtree_check: boolean;
|
|
advanced: NFSAdvanced;
|
|
}
|
|
|
|
export interface NFSAdvanced {
|
|
all_squash: boolean;
|
|
secure: boolean;
|
|
wdelay: boolean;
|
|
hide: boolean;
|
|
crossmnt: boolean;
|
|
[key: string]: boolean;
|
|
}
|
|
|
|
export interface NFSExport {
|
|
id: number;
|
|
path: string;
|
|
clients: NFSClient[];
|
|
read_only: boolean;
|
|
async: boolean;
|
|
root_squash: boolean;
|
|
subtree_check: boolean;
|
|
fsid: number;
|
|
advanced: string;
|
|
}
|
|
|
|
export interface User {
|
|
id: number;
|
|
username: string;
|
|
groups: string[];
|
|
smb_enabled: boolean;
|
|
disabled: boolean;
|
|
}
|
|
|
|
export interface DirtyModule {
|
|
module: string;
|
|
marked_at: string;
|
|
}
|
|
|
|
export interface ApplyLogEntry {
|
|
id: number;
|
|
module: string;
|
|
message: string;
|
|
success: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface DiskUsage {
|
|
path: string;
|
|
total_bytes: number;
|
|
free_bytes: number;
|
|
used_bytes: number;
|
|
used_percent: number;
|
|
sources: ("manual" | "samba" | "nfs")[];
|
|
used_by?: string[];
|
|
available: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export interface WatchedMount {
|
|
id: number;
|
|
path: string;
|
|
}
|
|
|
|
export interface ServiceStatus {
|
|
name: string;
|
|
active: boolean;
|
|
state: string;
|
|
}
|
|
|
|
export interface SystemStatus {
|
|
disks: DiskUsage[];
|
|
services: ServiceStatus[];
|
|
}
|
|
|
|
export interface VersionInfo {
|
|
version: string;
|
|
commit: string;
|
|
}
|
|
|
|
export interface FileEntry {
|
|
name: string;
|
|
path: string;
|
|
is_dir: boolean;
|
|
size: number;
|
|
mode: string;
|
|
mode_num: number;
|
|
mod_time: number;
|
|
}
|
|
|
|
export interface FileInfo {
|
|
path: string;
|
|
name: string;
|
|
is_dir: boolean;
|
|
size: number;
|
|
mode: string;
|
|
mode_num: number;
|
|
mod_time: number;
|
|
uid: number;
|
|
gid: number;
|
|
total_bytes?: number;
|
|
free_bytes?: number;
|
|
}
|
|
|
|
export interface DirList {
|
|
entries: FileEntry[];
|
|
path: string;
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
has_more: boolean;
|
|
}
|
|
|
|
export interface SearchHit {
|
|
name: string;
|
|
path: string;
|
|
is_dir: boolean;
|
|
size: number;
|
|
}
|
|
|
|
export interface FileCapabilities {
|
|
chmod: boolean;
|
|
chown: boolean;
|
|
}
|
|
|
|
export interface FilePreview {
|
|
type: "text" | "image" | "binary";
|
|
content?: string;
|
|
mime: string;
|
|
size: number;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const isFormData = body instanceof FormData;
|
|
const res = await fetch(`/api${path}`, {
|
|
method,
|
|
headers: isFormData ? undefined : body ? { "Content-Type": "application/json" } : undefined,
|
|
body: isFormData ? body : body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
const text = await res.text();
|
|
if (!text) {
|
|
throw new ApiError(res.status, "empty response");
|
|
}
|
|
const data = JSON.parse(text);
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, (data && data.error) || res.statusText);
|
|
}
|
|
return data as T;
|
|
}
|
|
|
|
export const api = {
|
|
// auth
|
|
status: () => request<{ authenticated: boolean; username: string }>("GET", "/auth/status"),
|
|
login: (username: string, password: string) =>
|
|
request<{ username: string }>("POST", "/auth/login", { username, password }),
|
|
logout: () => request<{ ok: boolean }>("POST", "/auth/logout"),
|
|
changePassword: (oldPassword: string, newPassword: string) =>
|
|
request<{ ok: boolean }>("PUT", "/auth/password", { old_password: oldPassword, new_password: newPassword }),
|
|
|
|
// dirty / apply
|
|
dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"),
|
|
apply: () => request<{ results: { module: string; applied: boolean; error?: string }[] }>("POST", "/apply"),
|
|
applyLog: () => request<{ entries: ApplyLogEntry[] }>("GET", "/apply/log"),
|
|
systemStatus: () => request<SystemStatus>("GET", "/system/status"),
|
|
version: () => request<VersionInfo>("GET", "/version"),
|
|
|
|
// samba
|
|
listShares: () => request<{ shares: SambaShare[] | null }>("GET", "/samba/shares/"),
|
|
createShare: (s: Partial<SambaShare>) => request<SambaShare>("POST", "/samba/shares/", s),
|
|
updateShare: (id: number, s: Partial<SambaShare>) => request<SambaShare>("PUT", `/samba/shares/${id}/`, s),
|
|
deleteShare: (id: number) => request<void>("DELETE", `/samba/shares/${id}/`),
|
|
|
|
// nfs
|
|
listExports: () => request<{ exports: NFSExport[] | null }>("GET", "/nfs/exports/"),
|
|
createExport: (e: Partial<NFSExport>) => request<NFSExport>("POST", "/nfs/exports/", e),
|
|
updateExport: (id: number, e: Partial<NFSExport>) => request<NFSExport>("PUT", `/nfs/exports/${id}/`, e),
|
|
deleteExport: (id: number) => request<void>("DELETE", `/nfs/exports/${id}/`),
|
|
|
|
// users
|
|
listUsers: () => request<{ users: User[] | null }>("GET", "/users/"),
|
|
createUser: (u: Partial<User> & { password?: string }) => request<User>("POST", "/users/", u),
|
|
updateUser: (id: number, u: Partial<User> & { password?: string }) => request<User>("PUT", `/users/${id}/`, u),
|
|
deleteUser: (id: number) => request<void>("DELETE", `/users/${id}/`),
|
|
|
|
// watched mounts
|
|
listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"),
|
|
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"),
|
|
listFiles: (path: string, page = 1, limit = 200) =>
|
|
request<DirList>("GET", `/files/?path=${encodeURIComponent(path)}&page=${page}&limit=${limit}`),
|
|
fileInfo: (path: string) => request<FileInfo>("GET", `/files/info?path=${encodeURIComponent(path)}`),
|
|
mkdirFile: (path: string) => request<void>("POST", "/files/mkdir", { path }),
|
|
renameFile: (path: string, newName: string) =>
|
|
request<void>("POST", "/files/rename", { path, newName }),
|
|
chmodFile: (path: string, mode: string) =>
|
|
request<void>("POST", "/files/chmod", { path, mode }),
|
|
chownFile: (path: string, uid: number, gid: number) =>
|
|
request<void>("POST", "/files/chown", { path, uid, gid }),
|
|
deleteFile: (path: string) => request<void>("DELETE", `/files/?path=${encodeURIComponent(path)}`),
|
|
uploadFile: (dir: string, file: File) => {
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
return request<{ path: string }>("POST", `/files/upload?path=${encodeURIComponent(dir)}`, fd);
|
|
},
|
|
filePreview: (path: string) =>
|
|
request<FilePreview>("GET", `/files/preview?path=${encodeURIComponent(path)}`),
|
|
searchFiles: (path: string, q: string, limit = 100) =>
|
|
request<{ results: SearchHit[] }>(`GET`, `/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(q)}&limit=${limit}`),
|
|
fileDownloadUrl: (path: string) => `/api/files/download?path=${encodeURIComponent(path)}`,
|
|
};
|
|
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
}
|