import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } from "lucide-react"; import Modal from "./ui/Modal"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { api } from "../lib/api"; import { displayName } from "../lib/api/account"; import { qk } from "../lib/queryKeys"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; import { formatDateTime } from "../lib/format"; import { invalidateUserAccess } from "../lib/adminUsers"; import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; import { Button } from "./ui/Button"; import { SURFACE } from "./ui/surfaces"; import { SectionLabel } from "./ui/Headings"; import { useCopyFeedback } from "./useCopyFeedback"; // The admin's answer to "why can this person not get in?", and the place every recovery action for // one account lives. // // It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a // "Pending approval" chip but not whether the address was confirmed, whether the password had been // cleared by SEC-10, or which Google account was attached. Every support conversation started by // guessing. The recovery actions sit under that readout rather than in the row, so the admin picks // one having read what is actually wrong. /** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */ function AccessRow({ icon: Icon, label, value, tone = "muted", }: { icon: typeof Mail; label: string; value: string; tone?: "muted" | "warn" | "ok"; }) { const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted"; return (
{label} {value}
); } export default function AccountPanel({ user, onClose, }: { user: AdminUserRow; onClose: () => void; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null); const { copied, copy: copyValue } = useCopyFeedback(); const q = useQuery({ queryKey: qk.adminUserAccess(user.id), queryFn: () => api.adminUserAccess(user.id), }); // Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error // dialog on the promise that the caller reports the failure inline. Without an `onError` that // promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this // panel exists for, the admin would read silence as "the mail went out". const issue = useMutation({ mutationFn: () => api.createSetPasswordLink(user.id), onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }), onError: (err) => notifyActionError(err, t("users.account.linkFailed")), }); const resend = useMutation({ mutationFn: () => api.resendUserVerification(user.id), onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }), onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")), }); const activate = useMutation({ mutationFn: () => api.activateUser(user.id), onSuccess: () => invalidateUserAccess(qc, user.id), onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")), }); // Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a // plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the // minimal self-host this no-email link was built for. const copy = () => { if (link) void copyValue(link.url); }; const a: AdminUserAccess | undefined = q.data; return (
{user.email}
{q.isError && !a ? ( q.refetch()}> {t("common.loadError")} ) : !a ? ( ) : ( <> {t("users.account.access")}
{/* The exact moment; the list row beside it only says how long ago. */} {/* The gate that explains a Google sign-in silently becoming a fresh access request. */} {!a.is_allowed && ( )}
{t("users.account.recovery")}
{a.can_activate && ( )}
{link && (
{link.url}

{t("users.account.linkNote")}

)} )}
); }