"use client"; import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Spinner } from "@kandev/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { IconMailForward, IconUserPlus, IconUsers } from "@tabler/icons-react"; import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover"; import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions"; import { MobileActionConfirmation, useConfirmationBoundary, } from "@/components/confirmation/mobile-action-confirmation"; import { useToast } from "@/components/toast-provider"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { ApiError } from "@/lib/api/client"; import { listUsers, updateUser, type AuthUser } from "@/lib/api/domains/auth-api"; import { CreateUserDialog } from "./create-user-dialog"; import { InviteDialog } from "./invite-dialog"; import { SettingsCardHeader } from "@/components/settings/settings-card-header"; import { SettingsErrorText } from "@/components/settings/settings-typography"; import { settingsActionClassName } from "@/components/settings/settings-control"; type PendingAction = { user: AuthUser; next: { role?: string; status?: string }; label: string }; /** * `role` and `status` are wire values sent straight back to the API, so the * `next` payload above always carries the raw token. Only these badge/button * labels are copy, and an unrecognized value from a newer backend echoes the * token rather than rendering blank. */ const ROLE_LABEL_KEYS: Record = { admin: "system:usersRoleAdmin", member: "system:usersRoleMember", }; const STATUS_LABEL_KEYS: Record = { active: "system:usersStatusActive", disabled: "system:usersStatusDisabled", }; function roleLabel(role: string, t: TFunction): string { return ROLE_LABEL_KEYS[role] ? t(ROLE_LABEL_KEYS[role]) : role; } function useUsersList(t: TFunction) { const [users, setUsers] = useState([]); const [loaded, setLoaded] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const reload = useCallback(async () => { setIsLoading(true); setError(null); try { const res = await listUsers({ cache: "no-store" }); setUsers(res.users); setLoaded(true); } catch (err) { setError(err instanceof ApiError ? err.message : t("system:usersLoadFailed")); } finally { setIsLoading(false); } }, [t]); useEffect(() => { void reload(); }, [reload]); return { users, loaded, isLoading, error, reload }; } function StatusBadge({ status }: { status: string }) { const { t } = useTranslation(); return ( {STATUS_LABEL_KEYS[status] ? t(STATUS_LABEL_KEYS[status]) : status} ); } type UserRowProps = { user: AuthUser; isLastActiveAdmin: boolean; isFinePointer: boolean; isMutating: boolean; pending: PendingAction | null; onToggleRole: (user: AuthUser) => void; onToggleStatus: (user: AuthUser) => void; onCancel: () => void; onConfirm: (action: PendingAction) => void; }; function UserRow({ user, ...props }: UserRowProps) { const { t } = useTranslation(); return ( {/* Email and display name are user data. */} {user.email} {user.display_name} {roleLabel(user.role, t)} ); } function MobileUserRow({ user, ...props }: UserRowProps) { const { t } = useTranslation(); return (
{/* Identity and current state stay above the row-owned action region on phones. */}

{user.email}

{user.display_name}

{t("system:usersColumnRole")}: {roleLabel(user.role, t)} {t("system:usersColumnStatus")}:
); } function UserActionRegion({ user, isLastActiveAdmin, isFinePointer, isMutating, pending, onToggleRole, onToggleStatus, onCancel, onConfirm, }: UserRowProps) { const { t } = useTranslation(); const roleAnchorRef = useRef(null); const statusAnchorRef = useRef(null); const action = pending?.user.id === user.id ? pending : null; const anchorRef = action?.next.role !== undefined ? roleAnchorRef : statusAnchorRef; const targetKey = `${user.id}:${JSON.stringify(action?.next)}`; const { isMobile } = useConfirmationBoundary(!!action, targetKey, () => onCancel()); if (!isMobile && !isFinePointer && action) { return ( onConfirm(action)} /> ); } return ( <> {(isMobile || isFinePointer) && action ? ( ) : null} ); } function UserActionButtons({ user, isLastActiveAdmin, isFinePointer, isMutating, roleAnchorRef, statusAnchorRef, onToggleRole, onToggleStatus, }: { user: AuthUser; isLastActiveAdmin: boolean; isFinePointer: boolean; isMutating: boolean; roleAnchorRef: RefObject; statusAnchorRef: RefObject; onToggleRole: (user: AuthUser) => void; onToggleStatus: (user: AuthUser) => void; }) { const { t } = useTranslation(); const roleButtonClassName = isFinePointer ? "cursor-pointer" : "min-h-11 cursor-pointer"; const statusButtonClassName = isFinePointer ? "cursor-pointer text-destructive" : "min-h-11 cursor-pointer text-destructive"; return (
); } function UserConfirmPopover({ action, anchorRef, onCancel, onConfirm, }: { action: PendingAction; anchorRef: RefObject; onCancel: () => void; onConfirm: (action: PendingAction) => void; }) { const { t } = useTranslation(); const actions = { title: action.label, description: t("system:usersTakesEffectImmediately", { email: action.user.email }), cancelLabel: t("common:cancel"), confirmLabel: t("system:usersConfirm"), confirmAriaLabel: action.label, confirmTestId: "users-table-confirm", onOpenChange: (open: boolean) => { if (!open) onCancel(); }, onConfirm: () => onConfirm(action), }; return ( } /> ); } /** * These two build the confirmation sentence outside JSX, which is why * `mode: "jsx-only"` never reported them. The email is interpolated as a value * and the target role travels as its own translated label. */ function roleTogglePending(u: AuthUser, t: TFunction): PendingAction { const next = u.role === "admin" ? "member" : "admin"; return { user: u, next: { role: next }, label: t("system:usersConfirmRoleChange", { email: u.email, role: roleLabel(next, t) }), }; } function statusTogglePending(u: AuthUser, t: TFunction): PendingAction { const disabling = u.status !== "disabled"; return { user: u, next: { status: disabling ? "disabled" : "active" }, label: disabling ? t("system:usersConfirmDisable", { email: u.email }) : t("system:usersConfirmEnable", { email: u.email }), }; } function UsersTableList({ users, onToggleRole, onToggleStatus, pending, isMutating, onCancel, onConfirm, }: { users: AuthUser[]; onToggleRole: (u: AuthUser) => void; onToggleStatus: (u: AuthUser) => void; pending: PendingAction | null; isMutating: boolean; onCancel: () => void; onConfirm: (action: PendingAction) => void; }) { const { t } = useTranslation(); const { isFinePointer, isMobile } = useResponsiveBreakpoint(); // Mirrors the backend last-admin guard (ensureAnotherAdmin): an active // admin cannot be demoted or disabled when no other active admin exists, // so those toggles are greyed out on that row. const activeAdminCount = users.filter((u) => u.role === "admin" && u.status === "active").length; const rowProps = (user: AuthUser): UserRowProps => ({ user, isLastActiveAdmin: user.role === "admin" && user.status === "active" && activeAdminCount === 1, isFinePointer, isMutating, pending, onToggleRole, onToggleStatus, onCancel, onConfirm, }); if (isMobile) { return (
{users.map((user) => ( ))}
); } return ( {t("system:usersColumnEmail")} {t("system:usersColumnName")} {t("system:usersColumnRole")} {t("system:usersColumnStatus")} {t("system:usersColumnActions")} {users.map((user) => ( ))}
); } export function UsersTable() { const { t } = useTranslation(); const { users, loaded, isLoading, error, reload } = useUsersList(t); const [createOpen, setCreateOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const [pending, setPending] = useState(null); const [activeMutation, setActiveMutation] = useState(null); const { toast } = useToast(); useEffect(() => { if (pending && !users.some((user) => user.id === pending.user.id)) setPending(null); }, [pending, users]); const onConfirm = async (action: PendingAction) => { if (activeMutation) return; setActiveMutation(action); try { await updateUser(action.user.id, action.next); await reload(); } catch (err) { toast({ variant: "error", title: t("system:usersUpdateFailed"), description: err instanceof ApiError ? err.message : t("system:usersLastAdminGuard"), }); } finally { setPending((current) => (current === action ? null : current)); setActiveMutation((current) => (current === action ? null : current)); } }; return ( {t("system:usersTitle")} } actions={
} /> {error && {error}} {!loaded && isLoading && (
{t("system:usersLoading")}
)} {loaded && users.length > 0 && ( setPending(roleTogglePending(u, t))} onToggleStatus={(u) => setPending(statusTogglePending(u, t))} pending={pending} isMutating={activeMutation !== null} onCancel={() => setPending(null)} onConfirm={(action) => void onConfirm(action)} /> )} {loaded && users.length === 0 && !error && (

{t("system:usersEmpty")}

)}
void reload()} /> void reload()} />
); }