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 (
{/* 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 && (
}
loading={activate.isPending}
onClick={() => activate.mutate()}
className="border border-accent/40 text-accent"
>
{t("users.account.activate")}
)}
}
loading={resend.isPending}
onClick={() => resend.mutate()}
disabled={!a.email_configured || a.email_verified}
>
{t("users.account.resend")}
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
whole reason the link below exists, so naming it here is the explanation. */}
{!a.email_configured && (
{t("users.account.mailOff")}
)}
{a.email_configured && a.email_verified && (
{t("users.account.alreadyConfirmed")}
)}
}
loading={issue.isPending}
onClick={() => issue.mutate()}
className="border border-accent/40 text-accent"
>
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}