import { APP_VERSION, CATEGORIES, SUPPORTED_LOCALES, 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 { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; import { useMobile } from "@/hooks/use-mobile"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { format, plural } from "@/lib/format"; import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n"; 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; authRequired?: boolean; } function useNavItems() { const { t } = useTranslation(); return useMemo( () => [ { id: "general", label: t.settings.nav.general, icon: Settings }, { id: "system", label: t.settings.nav.systemSettings, icon: Monitor, requiredPermission: "settings:write", }, { id: "security", label: t.settings.nav.security, icon: Shield, authRequired: true }, { id: "people", label: t.settings.nav.people, icon: Users, requiredPermission: "users:manage", authRequired: true, }, { id: "teams", label: t.settings.nav.teams, icon: UsersRound, requiredPermission: "teams:manage", authRequired: true, }, { id: "roles", label: t.settings.nav.roles, icon: Shield, requiredPermission: "users:manage", authRequired: true, }, { id: "audit-log", label: t.settings.nav.auditLog, icon: FileText, requiredPermission: "audit:read", }, { id: "api-keys", label: t.settings.nav.apiKeys, icon: Key }, { id: "ai-features", label: t.settings.nav.aiFeatures, icon: Sparkles, requiredPermission: "settings:write", }, { id: "tools", label: t.settings.nav.tools, icon: Wrench }, { id: "analytics", label: t.settings.nav.productAnalytics, icon: Eye }, { id: "about", label: t.settings.nav.about, icon: Info }, ], [t], ); } export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const [section, setSection] = useState
("general"); const { hasPermission, authEnabled } = useAuth(); const { t } = useTranslation(); const isMobile = useMobile(); const NAV_ITEMS = useNavItems(); const visibleNavItems = NAV_ITEMS.filter( (item) => (!item.requiredPermission || hasPermission(item.requiredPermission)) && (!item.authRequired || authEnabled), ); // 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; if (isMobile) { return (
{/* Mobile header */}

{t.settings.heading}

{/* Mobile pill strip nav */}
{visibleNavItems.map((item) => ( ))}
{/* Mobile content */}
{section === "general" && } {section === "system" && } {section === "security" && } {section === "people" && } {section === "teams" && } {section === "roles" && } {section === "audit-log" && } {section === "api-keys" && } {section === "ai-features" && } {section === "tools" && } {section === "analytics" && } {section === "about" && }
); } 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; authProvider?: string; email?: string; hasLocalPassword?: boolean; hasOidcLink?: boolean; createdAt: string; } interface TeamEntry { id: string; name: string; memberCount: number; createdAt: string; } /* ────────────────────── General ────────────────────── */ function GeneralSection() { const { t, locale, setLocale, supportedLocales } = useTranslation(); 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 = async () => { try { const res = await fetch("/api/auth/logout", { method: "POST", headers: formatHeaders(), }); const data = await res.json().catch(() => ({})); clearToken(); localStorage.removeItem("snapotter-username"); if (data.logoutUrl) { window.location.href = data.logoutUrl; } else { window.location.href = "/login"; } } catch { clearToken(); localStorage.removeItem("snapotter-username"); window.location.href = "/login"; } }; const handleSave = useCallback(async () => { setSaving(true); setSaveMsg(null); try { await apiPut("/v1/settings", { defaultToolView }); setSaveMsg(t.settings.general.saveSuccess); useSettingsStore.setState({ defaultToolView: defaultToolView as "sidebar" | "fullscreen", }); } catch { setSaveMsg(t.settings.general.saveFailed); } finally { setSaving(false); setTimeout(() => setSaveMsg(null), 3000); } }, [defaultToolView]); const username = user?.username || "admin"; const role = user?.role || "unknown"; return (

{t.settings.general.heading}

{t.settings.general.description}

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

{loading ? t.common.loading : username}

{role}

{authEnabled && ( )}
{/* Default view */} {APP_VERSION}
{saveMsg && ( {saveMsg} )}
); } /* ────────────────────── System ────────────────────── */ function SystemSection() { const { t } = useTranslation(); 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({ 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(t.settings.system.saveSuccess); } catch { setSaveMsg(t.settings.system.saveFailed); } finally { setSaving(false); setTimeout(() => setSaveMsg(null), 3000); } }, [settings, t]); if (loading) { return (
); } return (

{t.settings.system.heading}

{t.settings.system.description}

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} />

{t.settings.fileManagement.title}

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 { t } = useTranslation(); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [showCurrent, setShowCurrent] = useState(false); const [showNew, setShowNew] = useState(false); const [showConfirm, setShowConfirm] = 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: t.settings.security.passwordsMismatch }); return; } if (newPassword.length < 8) { setMessage({ type: "error", text: t.settings.security.passwordTooShort }); return; } setSubmitting(true); setMessage(null); try { await apiPost("/auth/change-password", { currentPassword, newPassword }); setMessage({ type: "success", text: t.settings.security.changeSuccess }); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch (err) { const msg = err instanceof Error ? err.message : t.settings.security.changeFailed; setMessage({ type: "error", text: msg.includes("401") ? t.settings.security.currentPasswordIncorrect : msg, }); } finally { setSubmitting(false); } }, [currentPassword, newPassword, confirmPassword], ); return (

{t.settings.security.heading}

{t.settings.security.description}

{t.settings.security.changePasswordHeading}

setCurrentPassword(e.target.value)} placeholder={t.settings.security.currentPasswordPlaceholder} className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" required />
setNewPassword(e.target.value)} placeholder={t.settings.security.newPasswordPlaceholder} className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" required />
setConfirmPassword(e.target.value)} placeholder={t.settings.security.confirmPasswordPlaceholder} className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" required />
{message && (

{message.text}

)}

{t.settings.security.loginAttemptLimitNote}

); } /* ────────────────────── People ────────────────────── */ function secureRandom(max: number): number { const array = new Uint32Array(1); crypto.getRandomValues(array); return array[0] % max; } function generatePassword(): string { const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const lower = "abcdefghijklmnopqrstuvwxyz"; const digits = "0123456789"; const all = upper + lower + digits; const required = [ upper[secureRandom(upper.length)], lower[secureRandom(lower.length)], digits[secureRandom(digits.length)], ]; const rest = Array.from({ length: 13 }, () => all[secureRandom(all.length)]); const chars = [...required, ...rest]; for (let i = chars.length - 1; i > 0; i--) { const j = secureRandom(i + 1); [chars[i], chars[j]] = [chars[j], chars[i]]; } return chars.join(""); } function PeopleSection() { const { t } = useTranslation(); const isMobile = useMobile(); 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 [showGeneratedPw, setShowGeneratedPw] = useState(false); const [pwCopied, setPwCopied] = 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); setShowGeneratedPw(false); setPwCopied(false); setActionMsg({ type: "success", text: t.settings.people.createSuccess }); await loadUsers(); } catch (err) { const msg = err instanceof Error ? err.message : t.settings.people.createFailed; setAddError( msg.includes("403") ? format(t.settings.people.userLimitReached, { max: maxUsers }) : msg, ); } finally { setAdding(false); setTimeout(() => setActionMsg(null), 3000); } }, [newUsername, newPassword, newRole, newTeam, maxUsers, loadUsers], ); const handleDeleteUser = useCallback( async (id: string, username: string) => { if (!confirm(format(t.settings.people.deleteConfirm, { username }))) return; try { await apiDelete(`/auth/users/${id}`); setActionMsg({ type: "success", text: format(t.settings.people.deleteSuccess, { username }), }); await loadUsers(); } catch { setActionMsg({ type: "error", text: t.settings.people.deleteFailed }); } 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: t.settings.people.updateSuccess }); await loadUsers(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to update user"; setActionMsg({ type: "error", text: msg.includes("400") ? t.settings.people.cannotRemoveOwnAdmin : 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: t.settings.people.resetSuccess }); } 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 */}

{t.settings.people.heading}

{t.settings.people.description}

{/* User count */}

{maxUsers > 0 ? `${users.length} / ${maxUsers} ${plural(maxUsers, format(t.settings.people.userCount, { count: "" }), format(t.settings.people.userCountPlural, { count: "" })).trim()}` : plural( users.length, format(t.settings.people.userCount, { count: users.length }), format(t.settings.people.userCountPlural, { count: users.length }), )}

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

{t.settings.people.newMemberHeading}

setNewUsername(e.target.value)} placeholder={t.settings.people.usernamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
{ setNewPassword(e.target.value); setShowGeneratedPw(false); setPwCopied(false); }} placeholder={t.auth.password} required minLength={8} className={cn( "flex-1 min-w-0 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground", showGeneratedPw && "font-mono", )} /> {showGeneratedPw && ( )}
{showGeneratedPw && !pwCopied && (

{t.settings.people.copyPasswordWarning}

)} {addError &&

{addError}

} )} {editingUser && (

{t.common.edit} {editingUser.username}

)} {resetPasswordUser && (

{format(t.settings.people.resetPasswordHeading, { username: resetPasswordUser.username, })}

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

{t.settings.people.resetPasswordWarning}

)} {/* Users table */}
{/* Table header (desktop only) */} {!isMobile && (
{t.settings.people.tableHeaderUser} {t.settings.people.tableHeaderRole} {t.settings.people.tableHeaderTeam}
)} {/* Table rows */} {filteredUsers.length === 0 ? (
{search ? t.settings.people.noSearchResults : t.settings.people.noUsersFound}
) : ( filteredUsers.map((u) => (
{isMobile ? ( <> {/* Mobile card layout */}
{u.username.charAt(0).toUpperCase()}
{u.username} {u.hasOidcLink && u.hasLocalPassword !== false && ( {t.auth.methodBoth} )} {u.hasOidcLink && u.hasLocalPassword === false && ( {t.auth.methodOidc} )}
{u.role} {u.team}
) : ( <> {/* Desktop row layout */}
{u.username.charAt(0).toUpperCase()}
{u.username} {u.hasOidcLink && u.hasLocalPassword !== false && ( {t.auth.methodBoth} )} {u.hasOidcLink && u.hasLocalPassword === false && ( {t.auth.methodOidc} )}
{/* Role badge */}
{u.role}
{/* Team */} {u.team} )} {/* Actions */}
{/* Dropdown menu */} {openMenuId === u.id && (
{u.hasLocalPassword !== false && ( )}
)}
)) )}
); } /* ────────────────────── API Keys ────────────────────── */ function ApiKeysSection() { const { t } = useTranslation(); 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(t.settings.apiKeys.deleteConfirm)) return; try { await apiDelete(`/v1/api-keys/${id}`); await loadKeys(); } catch { // Silently fail } }, [loadKeys], ); if (loading) { return (
); } return (

{t.settings.apiKeys.heading}

{t.settings.apiKeys.description}

{/* Generate new key */}
setKeyName(e.target.value)} placeholder={t.settings.apiKeys.keyNamePlaceholder} 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}

{t.settings.apiKeys.keyWarning}

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

{t.settings.apiKeys.existingKeysHeading}

{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 && (

{t.settings.apiKeys.emptyState}

)}
); } /* ────────────────────── Teams ────────────────────── */ function TeamsSection() { const { t } = useTranslation(); const isMobile = useMobile(); 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: t.settings.teams.createSuccess }); await loadTeams(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to create team"; setActionMsg({ type: "error", text: msg.includes("409") ? t.settings.teams.duplicateName : msg, }); } finally { setCreating(false); setTimeout(() => setActionMsg(null), 3000); } }, [newTeamName, loadTeams], ); const handleRename = useCallback( async (id: string) => { if (!editingTeamName.trim()) return; try { await apiPut(`/v1/teams/${id}`, { name: editingTeamName.trim() }); setEditingTeamId(null); setEditingTeamName(""); setActionMsg({ type: "success", text: t.settings.teams.renameSuccess }); 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: string, name: string) => { if (!confirm(format(t.settings.teams.deleteConfirm, { name }))) 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") ? t.settings.teams.cannotDeleteDefault : msg, }); } setOpenMenuId(null); setTimeout(() => setActionMsg(null), 3000); }, [loadTeams], ); if (loading) { return (
); } return (

{t.settings.teams.heading}

{t.settings.teams.description}

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

{t.settings.teams.newTeamHeading}

setNewTeamName(e.target.value)} placeholder={t.settings.teams.teamNamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1" />
)}
{/* Table header (desktop only) */} {!isMobile && (
{t.settings.teams.tableHeaderTeamName} {t.settings.teams.totalMembers}
)} {teams.length === 0 ? (
{t.settings.teams.emptyState}
) : ( teams.map((tm) => (
{editingTeamId === tm.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(tm.id); if (e.key === "Escape") setEditingTeamId(null); }} />
) : (
{tm.name} {isMobile && ( {tm.memberCount} {plural(tm.memberCount, "member", "members")} )}
)}
{!isMobile && {tm.memberCount}}
{openMenuId === tm.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: "System", permissions: ["features:manage", "system:health", "audit:read"], }, ]; function RolesSection() { const { t } = useTranslation(); 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: t.settings.roles.createSuccess }); await loadRoles(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to create role"; setActionMsg({ type: "error", text: msg.includes("409") ? t.settings.roles.duplicateRoleError : 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: t.settings.roles.updateSuccess }); 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 (

{t.settings.roles.heading}

{t.settings.roles.description}

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

{t.settings.roles.newRoleHeading}

setNewName(e.target.value)} placeholder={t.settings.roles.roleNamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> setNewDescription(e.target.value)} placeholder={t.settings.roles.descriptionPlaceholder} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />

{t.settings.roles.permissionsLabel}

{PERMISSION_GROUPS.map((group) => (

{group.label}

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

{format(t.settings.roles.editHeading, { name: editingRole.name })}

setEditName(e.target.value)} placeholder={t.settings.roles.roleNamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> setEditDescription(e.target.value)} placeholder={t.settings.roles.descriptionPlaceholder} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />

{t.settings.roles.permissionsLabel}

{PERMISSION_GROUPS.map((group) => (

{group.label}

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

{t.settings.roles.emptyState}

) : ( roles.map((role) => (
{role.name} {role.isBuiltin && ( {t.settings.roles.builtInBadge} )} {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 { t } = useTranslation(); const isMobile = useMobile(); 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 (

{t.settings.auditLog.heading}

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

{t.settings.auditLog.emptyState}

) : (
{isMobile ? (
{entries.map((entry) => (
setExpandedId(expandedId === entry.id ? null : entry.id)} >
{entry.action} {formatRelativeTime(entry.createdAt)}
{entry.actorUsername} {entry.targetType && ( {entry.targetType} {entry.targetId ? ` #${entry.targetId}` : ""} )}
{expandedId === entry.id && entry.details && (
                        {JSON.stringify(entry.details, null, 2)}
                      
)}
))}
) : ( {entries.map((entry) => ( setExpandedId(expandedId === entry.id ? null : entry.id)} > {expandedId === entry.id && entry.details && ( )} ))}
{t.settings.auditLog.tableHeaderTime} {t.settings.auditLog.tableHeaderUser} {t.settings.auditLog.tableHeaderAction} {t.settings.auditLog.tableHeaderTarget}
{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 { t } = useTranslation(); 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 (

{t.settings.tools.heading}

{t.settings.tools.description}

{showRestartBanner && (
{t.settings.tools.restartBanner}
)}
setSearch(e.target.value)} placeholder={t.settings.tools.searchPlaceholder} className="w-full ps-9 pe-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (

{getCategoryName(t, category.id, category.name)}

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

{getToolName(t, tool.id, tool.name)}

{getToolDescription(t, tool.id, tool.description)}

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

{t.settings.tools.noSearchResults}

)} {loadFailed && (
{t.settings.tools.loadFailedError}
)}
{disabledTools.length} tool{disabledTools.length !== 1 ? "s" : ""} disabled
); } /* ────────────────────── Analytics ────────────────────── */ function AnalyticsSection() { const { t } = useTranslation(); const { consent, config, configLoaded, fetchConfig, toggleAnalytics } = useAnalyticsStore(); useEffect(() => { fetchConfig(); }, [fetchConfig]); if (!configLoaded) return null; const disabled = !config?.enabled; const enabled = consent.analyticsEnabled === true; return (

{t.analytics.settingsTitle}

{t.analytics.settingsDescription}

{t.analytics.settingsPrivacy}

{disabled ? (

{t.analytics.settingsDisabledByAdmin}

) : (
{enabled ? "Analytics enabled" : "Analytics disabled"}
)} {t.analytics.learnMore}
); } /* ────────────────────── About ────────────────────── */ function AboutSection() { const { t } = useTranslation(); return (

{t.settings.about.heading}

SnapOtter

{t.settings.about.appDescription}

Version: {APP_VERSION}
{t.settings.about.licenseLabel}
AGPLv3

{t.settings.about.licenseDescription}

); } /* ────────────────────── Shared ────────────────────── */ function SettingRow({ label, description, children, }: { label: string; description: string; children: React.ReactNode; }) { const isMobile = useMobile(); return (

{label}

{description}

{children}
); }