import { useState, type Dispatch, type SetStateAction } from "react"; import { useTranslation } from "react-i18next"; import { deleteRole, deleteUser, getPermissionsCatalog, revokeAllUserSessions, revokeSession, updateRole, } from "@/main-axios"; import type { PermissionCatalogEntry, Role } from "@/main-axios"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Activity, Check, KeyRound, Pencil, Plus, RefreshCw, Settings2, Share2, Trash2, Unlink, User, } from "lucide-react"; import { toast } from "sonner"; import { AccordionSection } from "./AdminSettingsShared"; export type AdminUser = { id: string; username: string; isAdmin: boolean; isOidc: boolean; passwordHash?: string; dataUnlocked?: boolean; totpEnabled?: boolean; }; export type AdminSession = { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }; type ApiErrorLike = { response?: { data?: { error?: string; }; }; }; function apiErrorMessage(error: unknown, fallback: string) { return (error as ApiErrorLike).response?.data?.error || fallback; } type UsersSectionProps = { open: boolean; onToggle: () => void; users: AdminUser[]; setUsers: Dispatch>; loadUsers: () => void; setCreateUserOpen: Dispatch>; setEditUserTarget: Dispatch>; setEditUserOpen: Dispatch>; setLinkAccountTarget: Dispatch< SetStateAction<{ id: string; username: string; isOidc: boolean } | null> >; setLinkAccountOpen: Dispatch>; setUnlinkAccountTarget: Dispatch< SetStateAction<{ id: string; username: string } | null> >; setUnlinkAccountOpen: Dispatch>; onManageUser: (user: AdminUser) => void; search: string; onSearchChange: (value: string) => void; page: number; pageSize: number; total: number; onPageChange: Dispatch>; }; export function AdminUsersSection({ open, onToggle, users, setUsers, loadUsers, setCreateUserOpen, setEditUserTarget, setEditUserOpen, setLinkAccountTarget, setLinkAccountOpen, setUnlinkAccountTarget, setUnlinkAccountOpen, onManageUser, search, onSearchChange, page, pageSize, total, onPageChange, }: UsersSectionProps) { const { t } = useTranslation(); const pageCount = Math.max(1, Math.ceil(total / pageSize)); const hasPrev = page > 0; const hasNext = page + 1 < pageCount; return ( } open={open} onToggle={onToggle} >
{t("admin.usersCount", { count: total })}
onSearchChange(e.target.value)} placeholder={t("admin.searchUsers")} className="h-7 text-xs rounded-none" />
{users.map((user) => { const authLabel = user.isOidc && user.passwordHash ? t("admin.authTypeDual") : user.isOidc ? t("admin.authTypeOidc") : t("admin.authTypeLocal"); return (
{user.username[0].toUpperCase()}
{user.username}
{user.isAdmin && ( {t("admin.adminBadge")} )} {authLabel}
{user.isOidc && user.passwordHash ? ( ) : ( )}
); })} {users.length === 0 && (
{search ? t("admin.noUsersMatch") : t("admin.noUsers")}
)} {pageCount > 1 && (
{t("admin.pageOf", { page: page + 1, total: pageCount })}
)}
); } type SessionsSectionProps = { open: boolean; onToggle: () => void; sessions: AdminSession[]; setSessions: Dispatch>; loadSessions: () => void; }; export function AdminSessionsSection({ open, onToggle, sessions, setSessions, loadSessions, }: SessionsSectionProps) { const { t } = useTranslation(); return ( } open={open} onToggle={onToggle} >
{t("admin.sessionsActive", { count: sessions.length })}
{sessions.map((session) => (
{session.username} {session.isCurrentSession && ( {t("admin.youBadge")} )}
{session.deviceInfo} {t("admin.sessionActive", { time: session.lastActiveAt })} {t("admin.sessionExpires", { time: session.expiresAt })}
))}
); } type RolesSectionProps = { open: boolean; onToggle: () => void; roles: Role[]; setRoles: Dispatch>; showCreateRole: boolean; setShowCreateRole: Dispatch>; newRoleName: string; setNewRoleName: Dispatch>; newRoleDisplayName: string; setNewRoleDisplayName: Dispatch>; newRoleDescription: string; setNewRoleDescription: Dispatch>; handleCreateRole: () => void; createRoleLoading: boolean; }; export function AdminRolesSection({ open, onToggle, roles, setRoles, showCreateRole, setShowCreateRole, newRoleName, setNewRoleName, newRoleDisplayName, setNewRoleDisplayName, newRoleDescription, setNewRoleDescription, handleCreateRole, createRoleLoading, }: RolesSectionProps) { const { t } = useTranslation(); const [catalog, setCatalog] = useState([]); const [editingRoleId, setEditingRoleId] = useState(null); const [editingPermissions, setEditingPermissions] = useState>( new Set(), ); const [savingPermissions, setSavingPermissions] = useState(false); function rolePermissions(role: Role): string[] { if (Array.isArray(role.permissions)) return role.permissions; if (typeof role.permissions === "string") { try { return JSON.parse(role.permissions) as string[]; } catch { return []; } } return []; } async function openPermissionsEditor(role: Role) { if (editingRoleId === role.id) { setEditingRoleId(null); return; } try { if (catalog.length === 0) { const res = await getPermissionsCatalog(); setCatalog(res.catalog ?? []); } setEditingPermissions(new Set(rolePermissions(role))); setEditingRoleId(role.id); } catch { toast.error(t("admin.rolePermissions.loadError")); } } function togglePermission(permission: string) { setEditingPermissions((prev) => { const next = new Set(prev); if (next.has(permission)) next.delete(permission); else next.add(permission); return next; }); } async function savePermissions(role: Role) { setSavingPermissions(true); try { const permissions = [...editingPermissions]; await updateRole(role.id, { permissions }); setRoles((prev) => prev.map((entry) => entry.id === role.id ? { ...entry, permissions } : entry, ), ); setEditingRoleId(null); toast.success(t("admin.rolePermissions.saved")); } catch { toast.error(t("admin.rolePermissions.saveError")); } finally { setSavingPermissions(false); } } return ( } open={open} onToggle={onToggle} >
{t("admin.rolesCount", { count: roles.length })}
{showCreateRole && (
{t("admin.newRole")}
setNewRoleName(e.target.value)} className="text-xs" />
setNewRoleDisplayName(e.target.value)} className="text-xs" />