"use client"; import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { KeyRound, Loader2, LogOut, Pencil, Shield, ShieldCheck, Users, } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { Segmented } from "@/components/ui/segmented"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordStrength } from "@/components/ui/password-strength"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useAuth } from "@/hooks/use-auth"; import { formatDate } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ListRow } from "@/components/ui/list-row"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminUser, PasswordInput } from "./_shared"; import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { recentProofErrorMessage, useRecentProof, } from "@/components/settings/security-section/use-recent-proof"; /** * Filter values for the v1.5 users sub-route. The User model does NOT * carry a "suspended" boolean today (a force-logout deletes sessions but * leaves the row intact), so the spec's `suspended` bucket is mapped to * a passthrough — we keep the slug for forward compatibility but it * shows the same set as `all`. Documented in the phase 4b report. */ type UserFilter = "all" | "admin" | "user"; export function UserManagementSection() { const { t } = useTranslations(); const { user } = useAuth(); const currentUserId = user?.id ?? ""; const queryClient = useQueryClient(); const [editingUser, setEditingUser] = useState(null); const [editUsername, setEditUsername] = useState(""); const [editEmail, setEditEmail] = useState(""); // Document vault — per-user storage-quota override, edited in GB. // Empty string = no override (the instance default applies). const [editQuotaGb, setEditQuotaGb] = useState(""); const [resetUser, setResetUser] = useState(null); const [resetPassword, setResetPassword] = useState(""); const [resetMsg, setResetMsg] = useState(null); const [filter, setFilter] = useState("all"); const [logoutTarget, setLogoutTarget] = useState(null); const { data: users } = useQuery({ queryKey: queryKeys.adminUsers(), queryFn: async () => { return apiGet("/api/admin/users"); }, }); const filteredUsers = useMemo(() => { if (!users) return undefined; if (filter === "admin") return users.filter((u) => u.role === "ADMIN"); if (filter === "user") return users.filter((u) => u.role !== "ADMIN"); return users; }, [users, filter]); const updateUser = useMutation({ mutationFn: async ({ id, data, }: { id: string; data: Record; }) => { await apiPut(`/api/admin/users/${id}`, data); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() }); setEditingUser(null); toast.success(t("common.saved")); }, onError: (err) => { showErrorToast( err instanceof Error && err.message ? err.message : t("admin.settingsSaveError"), { cause: err }, ); }, }); // Setting another account's password asks for a fresh proof unless this // session signed in or re-proved within five minutes. const recentProof = useRecentProof(); const resetPw = useMutation({ mutationFn: async ({ id, password }: { id: string; password: string }) => { await recentProof.run(() => apiPost(`/api/admin/users/${id}/reset-password`, { password }), ); }, onSuccess: () => { setResetMsg(t("admin.passwordReset")); setResetPassword(""); }, onError: (err: Error) => { setResetMsg(recentProofErrorMessage(err, err.message)); }, }); const forceLogout = useMutation({ mutationFn: async ({ id }: { id: string }) => { return apiPost<{ sessionsRevoked: number }>( `/api/admin/users/${id}/force-logout`, ); }, onSuccess: (result, vars) => { const username = users?.find((u) => u.id === vars.id)?.username ?? vars.id; toast.success( t("admin.section.users.forceLogoutSuccess", { name: username, count: result.sessionsRevoked, }), ); setLogoutTarget(null); }, onError: (err) => { showErrorToast( err instanceof Error && err.message ? err.message : t("admin.section.users.forceLogoutFailed"), { cause: err }, ); }, }); function startEdit(u: AdminUser) { setEditingUser(u); setEditUsername(u.username); setEditEmail(u.email ?? ""); setEditQuotaGb( u.documentQuotaBytes === null ? "" : // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10), ); } function startReset(u: AdminUser) { setResetUser(u); setResetPassword(""); setResetMsg(null); } /** * Action buttons are reused for both the desktop-table (cramped) row * and the mobile-card layout. Pulling them into a single helper keeps * the two layouts in lock-step — when we add a new admin action we * never have to remember to add it to both. */ const renderUserActions = (u: AdminUser) => ( <> {/* The three icon tools travel as one group: on a phone the row wraps between the labelled toggles and the tools, never leaving the sign-out icon alone on a line. */} ); return ( {recentProof.dialog} {filteredUsers.length} {filter !== "all" && users && filteredUsers.length !== users.length && ( / {users.length} )} ) : null } /> {/* Filter toolbar. It used to sit in the header's status slot, which is where the neighbouring sections put a primary, a destructive, and a badge — three different meanings for one position. */}
({ value, label: t(`admin.section.users.filter.${value}`), }))} />
{filteredUsers ? ( filteredUsers.length === 0 ? ( // v1.4.15: dedicated empty state. The previous build // rendered an empty `` so an admin filter that returned // no rows produced a blank rectangle; the icon + filter-aware // copy + "Show all users" CTA make the state explicit.
} title={t("admin.section.users.emptyTitle")} description={t("admin.section.users.emptyDescription")} action={ filter !== "all" ? ( ) : undefined } />
) : ( <> {/* Desktop table: kept verbatim — only the wrapping `md:block hidden` swaps it out for the card-list below at `< md`. Hiding the table-only wrapper (instead of just the cells) saves DOM weight on mobile too. */}
{/* `admin-user-rows` names the read accounts. The section renders an empty state instead when the list is empty and nothing at all while the query is in flight, so the marker stands for answered data. The mobile card list below carries it too, so a wait on it holds at any viewport. */} {filteredUsers.map((u, i) => ( ))}
{t("admin.userName")} {t("admin.userEmail")} {t("admin.userRole")} {t("admin.userPasskeys")} {t("admin.userCreated")} {t("admin.userActions")}
{u.username} {u.email || "—"} {u.role} {u.passkeyCount} {formatDate(u.createdAt)}
{renderUserActions(u)}
{/* Mobile card list: each user renders as a self-contained card with the meta line + a flex-wrapping action row. All four actions stay visible and tap-targetable; nothing is hidden behind a horizontal scroll. */}
    {filteredUsers.map((u) => (
  • {u.username} {u.role}

    {u.email || "—"}

    {t("admin.userPasskeys")}: {u.passkeyCount} ·{" "} {formatDate(u.createdAt)}

    {renderUserActions(u)}
  • ))}
) ) : (
{t("admin.loadingUsers")}
)} {/* Edit Dialog */} {editingUser && (

{t("admin.editUserTitle", { name: editingUser.username })}

{/* v1.16.4 — a real form so Enter in the username / email fields submits; mirrors the dialog convention. */}
{ e.preventDefault(); if (updateUser.isPending) return; const quotaTrimmed = editQuotaGb.trim(); const quotaParsed = Number(quotaTrimmed); updateUser.mutate({ id: editingUser.id, data: { username: editUsername, email: editEmail || null, // Empty clears the override; a value clamps to the same // bounds the server schema enforces (0.1–1024 GB). documentQuotaBytes: quotaTrimmed === "" || !Number.isFinite(quotaParsed) ? null : Math.round( Math.min(1024, Math.max(0.1, quotaParsed)) * 1_073_741_824, ), }, }); }} >
setEditUsername(e.target.value)} autoComplete="off" data-lpignore="true" data-1p-ignore="true" />
setEditEmail(e.target.value)} placeholder={t("common.optional")} autoComplete="off" data-lpignore="true" data-1p-ignore="true" />
setEditQuotaGb(e.target.value)} placeholder={t("admin.userDocumentQuotaDefault")} autoComplete="off" data-lpignore="true" data-1p-ignore="true" /> GB

{t("admin.userDocumentQuotaHint")}

{updateUser.isError && (

{(updateUser.error as Error).message}

)}
)} {/* Password Reset Dialog */} {resetUser && (

{t("admin.resetPasswordTitle", { name: resetUser.username })}

setResetPassword(e.target.value)} placeholder={t("admin.newPasswordPlaceholder")} />
{resetMsg && (

{resetMsg}

)}
)} {/* Force-logout confirmation */} { if (!open) setLogoutTarget(null); }} > {t("admin.section.users.forceLogoutConfirmTitle", { name: logoutTarget?.username ?? "", })} {t("admin.section.users.forceLogoutConfirmBody")} {t("common.cancel")} { if (logoutTarget) { forceLogout.mutate({ id: logoutTarget.id }); } }} variant="destructive" disabled={forceLogout.isPending} aria-busy={forceLogout.isPending || undefined} > {forceLogout.isPending ? ( ) : null} {t("admin.section.users.forceLogout")}
); }