import { APP_VERSION, CATEGORIES, TOOLS } from "@snapotter/shared"; import { Check, Copy, Eye, EyeOff, FileText, Info, Key, Loader2, Lock, LogOut, Monitor, MoreVertical, Pencil, Plus, RotateCcw, Search, Settings, Shield, Sparkles, Trash2, UserPlus, Users, UsersRound, Wrench, X, } from "lucide-react"; import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { cn, copyToClipboard } from "@/lib/utils"; import { useAnalyticsStore } from "@/stores/analytics-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; import { OtterLogo } from "../common/otter-logo"; import { AiFeaturesSection } from "./ai-features-section"; interface SettingsDialogProps { open: boolean; onClose: () => void; } type Section = | "general" | "system" | "security" | "people" | "teams" | "roles" | "audit-log" | "api-keys" | "ai-features" | "tools" | "analytics" | "about"; interface NavItem { id: Section; label: string; icon: React.ComponentType<{ className?: string }>; requiredPermission?: string; } const NAV_ITEMS: NavItem[] = [ { id: "general", label: "General", icon: Settings }, { id: "system", label: "System Settings", icon: Monitor, requiredPermission: "settings:write" }, { id: "security", label: "Security", icon: Shield }, { id: "people", label: "People", icon: Users, requiredPermission: "users:manage" }, { id: "teams", label: "Teams", icon: UsersRound, requiredPermission: "teams:manage" }, { id: "roles", label: "Roles", icon: Shield, requiredPermission: "users:manage" }, { id: "audit-log", label: "Audit Log", icon: FileText, requiredPermission: "audit:read" }, { id: "api-keys", label: "API Keys", icon: Key }, { id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" }, { id: "tools", label: "Tools", icon: Wrench }, { id: "analytics", label: "Product Analytics", icon: Eye }, { id: "about", label: "About", icon: Info }, ]; export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const [section, setSection] = useState
("general"); const { hasPermission } = useAuth(); const visibleNavItems = NAV_ITEMS.filter( (item) => !item.requiredPermission || hasPermission(item.requiredPermission), ); // Close on Escape useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [open, onClose]); if (!open) return null; return (
{/* Backdrop */} ); } /* ────────────────────── Types ────────────────────── */ interface SessionUser { id: number; username: string; role: string; } interface ApiKeyEntry { id: number; name: string; prefix: string; createdAt: string; permissions: string[] | null; expiresAt: string | null; } interface RoleEntry { id: string; name: string; description: string; permissions: string[]; isBuiltin: boolean; userCount: number; } interface UserEntry { id: string; username: string; role: string; team: string; createdAt: string; } interface TeamEntry { id: number; name: string; memberCount: number; createdAt: string; } /* ────────────────────── General ────────────────────── */ function GeneralSection() { const { authEnabled } = useAuth(); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [defaultToolView, setDefaultToolView] = useState("sidebar"); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); useEffect(() => { Promise.all([ apiGet<{ user: SessionUser }>("/auth/session") .then((data) => setUser(data.user)) .catch(() => { setUser({ id: 0, username: localStorage.getItem("snapotter-username") || "", role: "unknown", }); }), apiGet<{ settings: Record }>("/v1/settings") .then((data) => { if (data.settings.defaultToolView) { setDefaultToolView(data.settings.defaultToolView); } }) .catch(() => {}), ]).finally(() => setLoading(false)); }, []); const handleLogout = () => { clearToken(); localStorage.removeItem("snapotter-username"); window.location.href = "/login"; }; const handleSave = useCallback(async () => { setSaving(true); setSaveMsg(null); try { await apiPut("/v1/settings", { defaultToolView }); setSaveMsg("Settings saved."); useSettingsStore.setState({ defaultToolView: defaultToolView as "sidebar" | "fullscreen", }); } catch { setSaveMsg("Failed to save settings."); } finally { setSaving(false); setTimeout(() => setSaveMsg(null), 3000); } }, [defaultToolView]); const username = user?.username || "admin"; const role = user?.role || "unknown"; return (

General

User preferences and display settings.

{/* User info */}
{loading ? ( ) : ( username.charAt(0).toUpperCase() )}

{loading ? "Loading..." : username}

{role}

{authEnabled && ( )}
{/* Default view */} {/* Version */} {APP_VERSION}
{saveMsg && ( {saveMsg} )}
); } /* ────────────────────── System ────────────────────── */ function SystemSection() { const [settings, setSettings] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); useEffect(() => { apiGet<{ settings: Record }>("/v1/settings") .then((data) => setSettings(data.settings)) .catch(() => { // Fallback defaults if endpoint not ready setSettings({ appName: "SnapOtter", fileUploadLimitMb: "100", defaultTheme: "system", defaultLocale: "en", loginAttemptLimit: "5", }); }) .finally(() => setLoading(false)); }, []); const updateSetting = useCallback((key: string, value: string) => { setSettings((prev) => ({ ...prev, [key]: value })); }, []); const handleSave = useCallback(async () => { setSaving(true); setSaveMsg(null); try { await apiPut("/v1/settings", settings); if (settings.defaultTheme) { const theme = settings.defaultTheme as "light" | "dark" | "system"; useThemeStore.getState().setTheme(theme); } setSaveMsg("Settings saved."); } catch { setSaveMsg("Failed to save settings."); } finally { setSaving(false); setTimeout(() => setSaveMsg(null), 3000); } }, [settings]); const handleLogoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); try { const res = await fetch("/api/v1/settings/logo", { method: "POST", headers: formatHeaders(), body: formData, }); if (!res.ok) { const body = await res.json().catch(() => null); setSaveMsg(body?.error || "Failed to upload logo."); return; } setSettings((prev) => ({ ...prev, customLogo: "true" })); } catch { setSaveMsg("Failed to upload logo."); } }; const handleLogoDelete = async () => { try { await apiDelete("/v1/settings/logo"); setSettings((prev) => ({ ...prev, customLogo: "false" })); } catch { setSaveMsg("Failed to delete logo."); } }; if (loading) { return (
); } return (

System Settings

Server-side configuration and limits.

updateSetting("appName", e.target.value)} className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-48" />
{settings.customLogo === "true" && ( Logo )} {settings.customLogo === "true" && ( )}
updateSetting("fileUploadLimitMb", e.target.value)} className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" min={1} /> updateSetting("loginAttemptLimit", e.target.value)} className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" min={1} max={100} />

File Management

updateSetting("tempFileMaxAgeHours", e.target.value)} className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" min={1} />
{saveMsg && ( {saveMsg} )}
); } /* ────────────────────── Security ────────────────────── */ function SecuritySection() { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [showCurrent, setShowCurrent] = useState(false); const [showNew, setShowNew] = useState(false); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const handleChangePassword = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (newPassword !== confirmPassword) { setMessage({ type: "error", text: "Passwords do not match" }); return; } if (newPassword.length < 4) { setMessage({ type: "error", text: "Password must be at least 4 characters" }); return; } setSubmitting(true); setMessage(null); try { await apiPost("/auth/change-password", { currentPassword, newPassword }); setMessage({ type: "success", text: "Password changed successfully" }); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to change password"; setMessage({ type: "error", text: msg.includes("401") ? "Current password is incorrect" : msg, }); } finally { setSubmitting(false); } }, [currentPassword, newPassword, confirmPassword], ); return (

Security

Password and authentication settings.

Change Password

setCurrentPassword(e.target.value)} placeholder="Current Password" className="w-full px-3 py-2 pr-10 rounded-lg border border-border bg-background text-sm text-foreground" required />
setNewPassword(e.target.value)} placeholder="New Password" className="w-full px-3 py-2 pr-10 rounded-lg border border-border bg-background text-sm text-foreground" required />
setConfirmPassword(e.target.value)} placeholder="Confirm New Password" className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" required /> {message && (

{message.text}

)}

Login attempt limits can be configured in System Settings.

); } /* ────────────────────── People ────────────────────── */ function PeopleSection() { const [users, setUsers] = useState([]); const [maxUsers, setMaxUsers] = useState(5); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [showAddForm, setShowAddForm] = useState(false); const [newUsername, setNewUsername] = useState(""); const [newPassword, setNewPassword] = useState(""); const [newRole, setNewRole] = useState("user"); const [newTeam, setNewTeam] = useState("Default"); const [addError, setAddError] = useState(null); const [adding, setAdding] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); const [editingUser, setEditingUser] = useState(null); const [editRole, setEditRole] = useState(""); const [editTeam, setEditTeam] = useState(""); const [resetPasswordUser, setResetPasswordUser] = useState(null); const [resetPassword, setResetPassword] = useState(""); const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>( null, ); const [teams, setTeams] = useState([]); const [availableRoles, setAvailableRoles] = useState([]); const loadTeams = useCallback(async () => { try { const data = await apiGet<{ teams: TeamEntry[] }>("/v1/teams"); setTeams(data.teams); } catch { setTeams([]); } }, []); const loadUsers = useCallback(async () => { try { const data = await apiGet<{ users: UserEntry[]; maxUsers: number }>("/auth/users"); setUsers(data.users); setMaxUsers(data.maxUsers); } catch { setUsers([]); } finally { setLoading(false); } }, []); useEffect(() => { loadUsers(); loadTeams(); apiGet<{ roles: RoleEntry[] }>("/v1/roles") .then((data) => setAvailableRoles(data.roles)) .catch(() => setAvailableRoles([])); }, [loadUsers, loadTeams]); // Close dropdown when clicking outside useEffect(() => { if (!openMenuId) return; const handler = () => setOpenMenuId(null); window.addEventListener("click", handler); return () => window.removeEventListener("click", handler); }, [openMenuId]); const filteredUsers = users.filter((u) => u.username.toLowerCase().includes(search.toLowerCase()), ); const atLimit = maxUsers > 0 && users.length >= maxUsers; const handleAddUser = useCallback( async (e: React.FormEvent) => { e.preventDefault(); setAddError(null); setAdding(true); try { await apiPost("/auth/register", { username: newUsername, password: newPassword, role: newRole, team: newTeam, }); setNewUsername(""); setNewPassword(""); setNewRole("user"); setNewTeam("Default"); setShowAddForm(false); setActionMsg({ type: "success", text: "User created successfully" }); await loadUsers(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to create user"; setAddError(msg.includes("403") ? `User limit reached (${maxUsers} max)` : msg); } finally { setAdding(false); setTimeout(() => setActionMsg(null), 3000); } }, [newUsername, newPassword, newRole, newTeam, maxUsers, loadUsers], ); const handleDeleteUser = useCallback( async (id: string, username: string) => { if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; try { await apiDelete(`/auth/users/${id}`); setActionMsg({ type: "success", text: `User "${username}" deleted` }); await loadUsers(); } catch { setActionMsg({ type: "error", text: "Failed to delete user" }); } setOpenMenuId(null); setTimeout(() => setActionMsg(null), 3000); }, [loadUsers], ); const handleUpdateUser = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!editingUser) return; try { await apiPut(`/auth/users/${editingUser.id}`, { role: editRole, team: editTeam, }); setEditingUser(null); setActionMsg({ type: "success", text: "User updated" }); await loadUsers(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to update user"; setActionMsg({ type: "error", text: msg.includes("400") ? "Cannot remove your own admin role" : msg, }); } setTimeout(() => setActionMsg(null), 3000); }, [editingUser, editRole, editTeam, loadUsers], ); const handleResetPassword = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!resetPasswordUser) return; try { await apiPost(`/auth/users/${resetPasswordUser.id}/reset-password`, { newPassword: resetPassword, }); setResetPasswordUser(null); setResetPassword(""); setActionMsg({ type: "success", text: "Password reset successfully" }); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to reset password"; setActionMsg({ type: "error", text: msg }); } setTimeout(() => setActionMsg(null), 3000); }, [resetPasswordUser, resetPassword], ); if (loading) { return (
); } return (
{/* Header */}

People

Manage workspace members and their permissions

{/* User count */}

{maxUsers > 0 ? `${users.length} / ${maxUsers} users` : `${users.length} ${users.length === 1 ? "user" : "users"}`}

{/* Action message */} {actionMsg && (
{actionMsg.text}
)} {/* Search + Add Members */}
setSearch(e.target.value)} placeholder="Search members..." className="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
{/* Add user form */} {showAddForm && (

New Member

setNewUsername(e.target.value)} placeholder="Username" required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> setNewPassword(e.target.value)} placeholder="Password" required minLength={8} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
{addError &&

{addError}

}
)} {/* Edit user modal */} {editingUser && (

Edit {editingUser.username}

)} {/* Reset password modal */} {resetPasswordUser && (

Reset password for {resetPasswordUser.username}

setResetPassword(e.target.value)} placeholder="New password (min 8 chars)" required minLength={8} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-60" />

This will invalidate all sessions and API keys for this user.

)} {/* Users table */}
{/* Table header */}
User Role Team
{/* Table rows */} {filteredUsers.length === 0 ? (
{search ? "No members match your search." : "No users found."}
) : ( filteredUsers.map((u) => (
{/* User cell */}
{u.username.charAt(0).toUpperCase()}
{u.username}
{/* Role badge */}
{u.role}
{/* Team */} {u.team} {/* Actions */}
{/* Dropdown menu */} {openMenuId === u.id && (
)}
)) )}
); } /* ────────────────────── API Keys ────────────────────── */ function ApiKeysSection() { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); const [newKey, setNewKey] = useState(null); const [copied, setCopied] = useState(false); const [generating, setGenerating] = useState(false); const [keyName, setKeyName] = useState(""); const [showScoping, setShowScoping] = useState(false); const [scopedPerms, setScopedPerms] = useState([]); const [expiresAt, setExpiresAt] = useState(""); const { permissions } = useAuth(); const loadKeys = useCallback(async () => { try { const data = await apiGet<{ apiKeys: ApiKeyEntry[] }>("/v1/api-keys"); setKeys(data.apiKeys); } catch { setKeys([]); } finally { setLoading(false); } }, []); useEffect(() => { loadKeys(); }, [loadKeys]); const generateKey = useCallback(async () => { setGenerating(true); setNewKey(null); try { const payload: Record = { name: keyName || "default" }; if (showScoping && scopedPerms.length > 0) { payload.permissions = scopedPerms; } if (expiresAt) { payload.expiresAt = new Date(expiresAt).toISOString(); } const data = await apiPost<{ key: string }>("/v1/api-keys", payload); setNewKey(data.key); setKeyName(""); setScopedPerms([]); setShowScoping(false); setExpiresAt(""); await loadKeys(); } catch { // Silently fail } finally { setGenerating(false); } }, [keyName, showScoping, scopedPerms, expiresAt, loadKeys]); const copyKey = useCallback(async (key: string) => { const ok = await copyToClipboard(key); if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); } }, []); const deleteKey = useCallback( async (id: number) => { if (!confirm("Delete this API key? Any integrations using it will stop working.")) return; try { await apiDelete(`/v1/api-keys/${id}`); await loadKeys(); } catch { // Silently fail } }, [loadKeys], ); if (loading) { return (
); } return (

API Keys

Manage API keys for programmatic access to SnapOtter.

{/* Generate new key */}
setKeyName(e.target.value)} placeholder="Key name (optional)" className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48" />
{/* Permission scoping */}
{showScoping && (
{permissions.map((perm) => ( ))}
)}
{/* Expiration date */}
{expiresAt && ( )}
{/* Newly generated key display */} {newKey && (
{newKey}

Store this key securely. It will not be shown again.

)} {/* Existing keys list */} {keys.length > 0 && (

Existing Keys

{keys.map((k) => (

{k.name}

{k.prefix}... · Created {new Date(k.createdAt).toLocaleDateString()}

{k.permissions && (

Scoped: {k.permissions.join(", ")}

)} {k.expiresAt && ( Expires {new Date(k.expiresAt).toLocaleDateString()} )}
))}
)} {keys.length === 0 && !newKey && (

No API keys yet. Generate one to get started.

)}
); } /* ────────────────────── Teams ────────────────────── */ function TeamsSection() { const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); const [newTeamName, setNewTeamName] = useState(""); const [creating, setCreating] = useState(false); const [editingTeamId, setEditingTeamId] = useState(null); const [editingTeamName, setEditingTeamName] = useState(""); const [openMenuId, setOpenMenuId] = useState(null); const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>( null, ); const loadTeams = useCallback(async () => { try { const data = await apiGet<{ teams: TeamEntry[] }>("/v1/teams"); setTeams(data.teams); } catch { setTeams([]); } finally { setLoading(false); } }, []); useEffect(() => { loadTeams(); }, [loadTeams]); // Close dropdown when clicking outside useEffect(() => { if (!openMenuId) return; const handler = () => setOpenMenuId(null); window.addEventListener("click", handler); return () => window.removeEventListener("click", handler); }, [openMenuId]); const handleCreate = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!newTeamName.trim()) return; setCreating(true); try { await apiPost("/v1/teams", { name: newTeamName.trim() }); setNewTeamName(""); setShowCreateForm(false); setActionMsg({ type: "success", text: "Team created successfully" }); await loadTeams(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to create team"; setActionMsg({ type: "error", text: msg.includes("409") ? "A team with that name already exists" : msg, }); } finally { setCreating(false); setTimeout(() => setActionMsg(null), 3000); } }, [newTeamName, loadTeams], ); const handleRename = useCallback( async (id: number) => { if (!editingTeamName.trim()) return; try { await apiPut(`/v1/teams/${id}`, { name: editingTeamName.trim() }); setEditingTeamId(null); setEditingTeamName(""); setActionMsg({ type: "success", text: "Team renamed" }); await loadTeams(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to rename team"; setActionMsg({ type: "error", text: msg }); } setTimeout(() => setActionMsg(null), 3000); }, [editingTeamName, loadTeams], ); const handleDelete = useCallback( async (id: number, name: string) => { if (!confirm(`Delete team "${name}"? Members will be unassigned.`)) return; try { await apiDelete(`/v1/teams/${id}`); setActionMsg({ type: "success", text: `Team "${name}" deleted` }); await loadTeams(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to delete team"; setActionMsg({ type: "error", text: msg.includes("400") ? "Cannot delete the default team or a team with members" : msg, }); } setOpenMenuId(null); setTimeout(() => setActionMsg(null), 3000); }, [loadTeams], ); if (loading) { return (
); } return (

Teams

Organize members into teams for better management.

{actionMsg && (
{actionMsg.text}
)}
{showCreateForm && (

New Team

setNewTeamName(e.target.value)} placeholder="Team name" required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1" />
)}
Team Name Members
{teams.length === 0 ? (
No teams found.
) : ( teams.map((t) => (
{editingTeamId === t.id ? (
setEditingTeamName(e.target.value)} className="px-2 py-1 rounded border border-border bg-background text-sm text-foreground w-40" ref={(el) => el?.focus()} onKeyDown={(e) => { if (e.key === "Enter") handleRename(t.id); if (e.key === "Escape") setEditingTeamId(null); }} />
) : ( {t.name} )}
{t.memberCount}
{openMenuId === t.id && (
)}
)) )}
); } /* ────────────────────── Roles ────────────────────── */ const PERMISSION_GROUPS = [ { label: "Tools", permissions: ["tools:use"] }, { label: "Files", permissions: ["files:own", "files:all"] }, { label: "API Keys", permissions: ["apikeys:own", "apikeys:all"] }, { label: "Pipelines", permissions: ["pipelines:own", "pipelines:all"] }, { label: "Settings", permissions: ["settings:read", "settings:write"] }, { label: "Users", permissions: ["users:manage"] }, { label: "Teams", permissions: ["teams:manage"] }, { label: "Branding", permissions: ["branding:manage"] }, { label: "System", permissions: ["features:manage", "system:health", "audit:read"], }, ]; function RolesSection() { const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); const [newName, setNewName] = useState(""); const [newDescription, setNewDescription] = useState(""); const [newPermissions, setNewPermissions] = useState([]); const [editingRole, setEditingRole] = useState(null); const [editPermissions, setEditPermissions] = useState([]); const [editName, setEditName] = useState(""); const [editDescription, setEditDescription] = useState(""); const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>( null, ); const loadRoles = useCallback(async () => { try { const data = await apiGet<{ roles: RoleEntry[] }>("/v1/roles"); setRoles(data.roles); } catch { setRoles([]); } finally { setLoading(false); } }, []); useEffect(() => { loadRoles(); }, [loadRoles]); const handleCreate = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!newName.trim()) return; try { await apiPost("/v1/roles", { name: newName.trim().toLowerCase(), description: newDescription.trim(), permissions: newPermissions, }); setNewName(""); setNewDescription(""); setNewPermissions([]); setShowCreateForm(false); setActionMsg({ type: "success", text: "Role created successfully" }); await loadRoles(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to create role"; setActionMsg({ type: "error", text: msg.includes("409") ? "A role with that name already exists" : msg, }); } setTimeout(() => setActionMsg(null), 3000); }, [newName, newDescription, newPermissions, loadRoles], ); const handleUpdate = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!editingRole) return; try { await apiPut(`/v1/roles/${editingRole.id}`, { name: editName.trim().toLowerCase(), description: editDescription.trim(), permissions: editPermissions, }); setEditingRole(null); setActionMsg({ type: "success", text: "Role updated" }); await loadRoles(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to update role"; setActionMsg({ type: "error", text: msg }); } setTimeout(() => setActionMsg(null), 3000); }, [editingRole, editName, editDescription, editPermissions, loadRoles], ); const handleDelete = useCallback( async (role: RoleEntry) => { const msg = role.userCount > 0 ? `Delete role "${role.name}"? ${role.userCount} user${role.userCount !== 1 ? "s" : ""} will need to be reassigned.` : `Delete role "${role.name}"?`; if (!confirm(msg)) return; try { await apiDelete(`/v1/roles/${role.id}`); setActionMsg({ type: "success", text: `Role "${role.name}" deleted` }); await loadRoles(); } catch (err) { const errMsg = err instanceof Error ? err.message : "Failed to delete role"; setActionMsg({ type: "error", text: errMsg }); } setTimeout(() => setActionMsg(null), 3000); }, [loadRoles], ); const togglePermission = (perm: string, list: string[], setter: (v: string[]) => void) => { setter(list.includes(perm) ? list.filter((p) => p !== perm) : [...list, perm]); }; if (loading) { return (
); } return (

Roles

Manage roles and their permissions. Built-in roles cannot be modified.

{actionMsg && (
{actionMsg.text}
)}
{/* Create role form */} {showCreateForm && (

New Role

setNewName(e.target.value)} placeholder="Role name" required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> setNewDescription(e.target.value)} placeholder="Description (optional)" className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />

Permissions

{PERMISSION_GROUPS.map((group) => (

{group.label}

{group.permissions.map((perm) => ( ))}
))}
)} {/* Edit role form */} {editingRole && (

Edit Role: {editingRole.name}

setEditName(e.target.value)} placeholder="Role name" required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> setEditDescription(e.target.value)} placeholder="Description (optional)" className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />

Permissions

{PERMISSION_GROUPS.map((group) => (

{group.label}

{group.permissions.map((perm) => ( ))}
))}
)} {/* Role cards */}
{roles.length === 0 ? (

No roles found.

) : ( roles.map((role) => (
{role.name} {role.isBuiltin && ( Built-in )} {role.userCount} user{role.userCount !== 1 ? "s" : ""}
{!role.isBuiltin && (
)}
{role.description && (

{role.description}

)}
{role.permissions.map((perm) => ( {perm} ))}
)) )}
); } /* ────────────────────── Audit Log ────────────────────── */ const AUDIT_ACTIONS = [ "LOGIN_SUCCESS", "LOGIN_FAILED", "USER_CREATED", "USER_UPDATED", "USER_DELETED", "PASSWORD_CHANGED", "PASSWORD_RESET", "API_KEY_CREATED", "API_KEY_DELETED", "ROLE_CREATED", "ROLE_UPDATED", "ROLE_DELETED", "SETTINGS_UPDATED", ] as const; interface AuditEntry { id: string; actorUsername: string; action: string; targetType: string | null; targetId: string | null; details: Record | null; createdAt: string; } function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diff / 60_000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 7) return `${days}d ago`; return new Date(iso).toLocaleDateString(); } function AuditLogSection() { const [entries, setEntries] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [actionFilter, setActionFilter] = useState(""); const [expandedId, setExpandedId] = useState(null); const limit = 25; const fetchEntries = useCallback(async () => { setLoading(true); try { const params = new URLSearchParams({ page: String(page), limit: String(limit) }); if (actionFilter) params.set("action", actionFilter); const data = await apiGet<{ entries: AuditEntry[]; total: number }>( `/v1/audit-log?${params}`, ); setEntries(data.entries); setTotal(data.total); } catch { setEntries([]); setTotal(0); } finally { setLoading(false); } }, [page, actionFilter]); useEffect(() => { fetchEntries(); }, [fetchEntries]); const totalPages = Math.max(1, Math.ceil(total / limit)); const handleFilterChange = (value: string) => { setActionFilter(value); setPage(1); }; return (

Audit Log

{loading ? (
) : entries.length === 0 ? (

No audit log entries.

) : (
{entries.map((entry) => ( setExpandedId(expandedId === entry.id ? null : entry.id)} > {expandedId === entry.id && entry.details && ( )} ))}
Time User Action Target
{formatRelativeTime(entry.createdAt)} {entry.actorUsername} {entry.action} {entry.targetType ? `${entry.targetType}${entry.targetId ? ` #${entry.targetId}` : ""}` : "—"}
                          {JSON.stringify(entry.details, null, 2)}
                        
)} {/* Pagination */} {totalPages > 1 && (
Page {page} of {totalPages} ({total} entries)
)}
); } /* ────────────────────── Tools ────────────────────── */ function ToolsSection() { const [disabledTools, setDisabledTools] = useState([]); const [loading, setLoading] = useState(true); const [loadFailed, setLoadFailed] = useState(false); const [saving, setSaving] = useState(false); const [search, setSearch] = useState(""); const [showRestartBanner, setShowRestartBanner] = useState(false); useEffect(() => { apiGet<{ settings: Record }>("/v1/settings") .then((data) => { setDisabledTools( data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [], ); setLoadFailed(false); }) .catch(() => setLoadFailed(true)) .finally(() => setLoading(false)); }, []); const filteredTools = useMemo(() => { if (!search) return TOOLS; const q = search.toLowerCase(); return TOOLS.filter( (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q), ); }, [search]); const groupedTools = useMemo(() => { const groups = new Map(); for (const tool of filteredTools) { const list = groups.get(tool.category) || []; list.push(tool); groups.set(tool.category, list); } return groups; }, [filteredTools]); const toggleTool = useCallback((toolId: string) => { setDisabledTools((prev) => prev.includes(toolId) ? prev.filter((id) => id !== toolId) : [...prev, toolId], ); }, []); const handleSave = useCallback(async () => { setSaving(true); try { await apiPut("/v1/settings", { disabledTools: JSON.stringify(disabledTools) }); setShowRestartBanner(true); } catch { /* handle error */ } finally { setSaving(false); } }, [disabledTools]); if (loading) { return (
); } return (

Tools

Enable or disable individual tools. Disabled tools are hidden from all users.

{showRestartBanner && (
Restart required for changes to take effect.
)}
setSearch(e.target.value)} placeholder="Search tools..." className="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (

{category.name}

{groupedTools.get(category.id)?.map((tool) => { const isDisabled = disabledTools.includes(tool.id); return (

{tool.name}

{tool.description}

); })}
))}
{filteredTools.length === 0 && (

No tools match your search.

)} {loadFailed && (
Failed to load tool settings. Saving is disabled to prevent data loss.
)}
{disabledTools.length} tool{disabledTools.length !== 1 ? "s" : ""} disabled
); } /* ────────────────────── Analytics ────────────────────── */ function AnalyticsSection() { const { consent, config, configLoaded, fetchConfig, toggleAnalytics } = useAnalyticsStore(); useEffect(() => { fetchConfig(); }, [fetchConfig]); if (!configLoaded) return null; const disabled = !config?.enabled; const enabled = consent.analyticsEnabled === true; return (

Product Analytics

Share anonymous usage data to help improve SnapOtter.

Your images never leave your machine.

{disabled ? (

Product analytics has been disabled by the server administrator.

) : (
{enabled ? "Analytics enabled" : "Analytics disabled"}
)} Learn more
); } /* ────────────────────── About ────────────────────── */ function AboutSection() { return (

About

SnapOtter

A self-hosted, privacy-first image processing suite with 48 tools. Resize, compress, convert, watermark, and automate your image workflows without sending data to the cloud.

Version: {APP_VERSION}
); } /* ────────────────────── Shared ────────────────────── */ function SettingRow({ label, description, children, }: { label: string; description: string; children: React.ReactNode; }) { return (

{label}

{description}

{children}
); }