import { APP_VERSION, CATEGORIES, TOOLS } from "@ashim/shared"; import { Check, Copy, Eye, EyeOff, Info, Key, Loader2, LogOut, Monitor, MoreVertical, Pencil, RotateCcw, Search, Settings, Shield, Sparkles, Trash2, UserPlus, Users, UsersRound, Wrench, X, } from "lucide-react"; import { 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 { GemLogo } from "../common/gem-logo"; import { AiFeaturesSection } from "./ai-features-section"; interface SettingsDialogProps { open: boolean; onClose: () => void; } type Section = | "general" | "system" | "security" | "people" | "teams" | "api-keys" | "ai-features" | "tools" | "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: "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: "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; } 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 [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { apiGet<{ user: SessionUser }>("/auth/session") .then((data) => setUser(data.user)) .catch(() => { // Fallback to localStorage if session endpoint fails setUser({ id: 0, username: localStorage.getItem("ashim-username") || "", role: "unknown", }); }) .finally(() => setLoading(false)); }, []); const handleLogout = () => { clearToken(); localStorage.removeItem("ashim-username"); window.location.href = "/login"; }; 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}

{/* Default view */} {/* Version */} {APP_VERSION}
); } /* ────────────────────── 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: "ashim", 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); 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 { await fetch("/api/v1/settings/logo", { method: "POST", headers: formatHeaders(), body: formData, }); 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 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(); }, [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 = 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 */}

{users.length} / {maxUsers} 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 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 data = await apiPost<{ key: string }>("/v1/api-keys", { name: keyName || "default", }); setNewKey(data.key); setKeyName(""); await loadKeys(); } catch { // Silently fail } finally { setGenerating(false); } }, [keyName, 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 ashim.

{/* 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" />
{/* 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()}

))}
)} {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 && (
)}
)) )}
); } /* ────────────────────── Tools ────────────────────── */ function ToolsSection() { const [disabledTools, setDisabledTools] = useState([]); const [loading, setLoading] = useState(true); 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) : [], ); }) .catch(() => {}) .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.

)}
{disabledTools.length} tool{disabledTools.length !== 1 ? "s" : ""} disabled
); } /* ────────────────────── About ────────────────────── */ function AboutSection() { return (

About

ashim

A self-hosted, privacy-first image processing suite with 30+ 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}
); }