"use client"; import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { KeyRound, Loader2, LogOut, Pencil, Shield, Users } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; 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 { useTranslations } from "@/lib/i18n/context"; import { type AdminUser, PasswordInput } from "./_shared"; /** * 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(""); 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: ["admin", "users"], queryFn: async () => { const res = await fetch("/api/admin/users"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as AdminUser[]; }, }); 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; }) => { const res = await fetch(`/api/admin/users/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); const json = await res.json(); if (!res.ok) throw new Error(json.error || t("common.error")); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); setEditingUser(null); toast.success(t("common.saved")); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.settingsSaveError"), ); }, }); const resetPw = useMutation({ mutationFn: async ({ id, password }: { id: string; password: string }) => { const res = await fetch(`/api/admin/users/${id}/reset-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), }); const json = await res.json(); if (!res.ok) throw new Error(json.error || t("common.error")); }, onSuccess: () => { setResetMsg(t("admin.passwordReset")); setResetPassword(""); }, onError: (err: Error) => { setResetMsg(err.message); }, }); const forceLogout = useMutation({ mutationFn: async ({ id }: { id: string }) => { const res = await fetch(`/api/admin/users/${id}/force-logout`, { method: "POST", }); const json = await res.json(); if (!res.ok) throw new Error(json.error || t("common.error")); return json.data as { sessionsRevoked: number }; }, 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) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.users.forceLogoutFailed"), ); }, }); function startEdit(u: AdminUser) { setEditingUser(u); setEditUsername(u.username); setEditEmail(u.email ?? ""); } 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) => ( <> ); return (
{/* Header: stacks vertically on mobile so the filter pills get their own row instead of overlapping the title. The audit flagged this as CRITICAL #2 — at 393px the filter row was colliding with the count Badge and the user-count digit was floating into the title's gap. */}
{t("admin.userManagement")}
{filteredUsers && ( {filteredUsers.length} {filter !== "all" && users && filteredUsers.length !== users.length && ( / {users.length} )} )}
{/* Filters mirror the Settings-style horizontal pills */}
{(["all", "admin", "user"] as const).map((value) => ( ))}
{filteredUsers ? ( <> {/* 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. */}
{filteredUsers.map((u, i) => ( ))}
{t("admin.users")} {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 })}

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" />
{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 }); } }} className="bg-destructive hover:bg-destructive/90" > {forceLogout.isPending ? ( ) : null} {t("admin.section.users.forceLogout")}
); }