Design option C, chosen by the user 2026-09-05. Nothing in the admin UI ever showed an account's GATE STATE: an admin could see a "Pending approval" chip but not whether the address was confirmed, whether SEC-10 had cleared the password, or which Google account was attached — so every support conversation started by guessing. Clicking a row now opens a drawer that says which gate the person is actually stuck behind, with the recovery actions under that readout rather than in the row. The drawer is `Modal placement="drawer"`, not a second dialog: the layer stack, ESC, the focus trap, Back-to-close and the scroll lock are the hard parts, and duplicating them is how two shells drift apart. It slides in, and honours prefers-reduced-motion like the rest. The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone forgot theirs") reuses the existing token machinery and the SAME `#reset=` entry the mailed link uses, so the signed-out card consumes it with no new flow. It is a distinct token KIND for one reason: a mailed reset arrives in the mailbox, so completing it proves ownership and confirms the address; an admin-issued link proves nothing about the mailbox — the admin hands it over by whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would be the same false attestation SEC-15 is about. Issuing does not revoke the current password: a misclick must not lock anyone out. USING it rewrites the hash and bumps the session epoch, which is what evicts a squatter — SEC-16's actual remedy. Spending either kind burns the siblings of both, so a completed reset cannot leave a live admin link behind. The non-consuming probe learned the new kind too, or every admin link would have opened onto "this link is spent". The audit log records that a link was issued, by whom, for whom — never the link, which is a live credential-setting secret and the last thing that belongs in a long-lived log a human reads. Not gated on account state on purpose: it is useful for a pre-empted address, a revoked password, a Google-only account that wants one, and a plain forgotten password. Narrowing it would only make it refuse exactly when the admin needs it. The shared demo account is the one exception. Verified on localdev, both languages, end to end: issue from the panel -> the signed-in tab shows the honest "reset links open in a signed-out window" banner with the token still in the address bar -> signing out draws the "Set a new password" form, which is only possible because the probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676.
239 lines
9.3 KiB
TypeScript
239 lines
9.3 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
|
|
import Modal from "./Modal";
|
|
import { LoadingState, StateMessage } from "./QueryState";
|
|
import { api } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { notify } from "../lib/notifications";
|
|
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
|
|
|
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
|
// one account lives (E12 · S5, design option C).
|
|
//
|
|
// 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 (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
|
|
<span className="text-muted w-28 shrink-0">{label}</span>
|
|
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AccountPanel({
|
|
user,
|
|
onClose,
|
|
}: {
|
|
user: AdminUserRow;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const q = useQuery({
|
|
queryKey: qk.adminUserAccess(user.id),
|
|
queryFn: () => api.adminUserAccess(user.id),
|
|
});
|
|
|
|
const issue = useMutation({
|
|
mutationFn: () => api.createSetPasswordLink(user.id),
|
|
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
|
});
|
|
|
|
const resend = useMutation({
|
|
mutationFn: () => api.resendUserVerification(user.id),
|
|
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
|
});
|
|
|
|
const activate = useMutation({
|
|
mutationFn: () => api.activateUser(user.id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
|
qc.invalidateQueries({ queryKey: qk.adminUserAccess(user.id) });
|
|
},
|
|
});
|
|
|
|
const copy = async () => {
|
|
if (!link) return;
|
|
try {
|
|
await navigator.clipboard.writeText(link.url);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
// Clipboard access can be refused (permissions, an insecure origin). The link is on screen and
|
|
// selectable, so this is a convenience that failed — not the action failing.
|
|
notify({ level: "warning", message: t("users.account.copyFailed") });
|
|
}
|
|
};
|
|
|
|
const a: AdminUserAccess | undefined = q.data;
|
|
|
|
return (
|
|
<Modal
|
|
title={user.display_name ?? user.email.split("@")[0]}
|
|
onClose={onClose}
|
|
placement="drawer"
|
|
maxWidth="max-w-md"
|
|
>
|
|
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
|
|
|
{q.isError && !a ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : !a ? (
|
|
<LoadingState />
|
|
) : (
|
|
<>
|
|
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
|
{t("users.account.access")}
|
|
</div>
|
|
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
|
<AccessRow
|
|
icon={Mail}
|
|
label={t("users.account.email")}
|
|
value={
|
|
a.email_verified
|
|
? t("users.account.emailConfirmed")
|
|
: t("users.account.emailUnconfirmed")
|
|
}
|
|
tone={a.email_verified ? "ok" : "warn"}
|
|
/>
|
|
<AccessRow
|
|
icon={Clock}
|
|
label={t("users.account.approval")}
|
|
value={
|
|
a.is_active
|
|
? t("users.account.approvalDone")
|
|
: a.can_activate
|
|
? t("users.account.approvalWaitingOnYou")
|
|
: t("users.account.approvalNotRequested")
|
|
}
|
|
tone={a.is_active ? "ok" : "warn"}
|
|
/>
|
|
<AccessRow
|
|
icon={KeyRound}
|
|
label={t("users.account.password")}
|
|
value={
|
|
a.has_password
|
|
? t("users.account.passwordSet")
|
|
: a.password_revoked_at
|
|
? t("users.account.passwordRevoked")
|
|
: t("users.account.passwordNone")
|
|
}
|
|
// Not having a password is only a PROBLEM when it was taken away; a Google-only
|
|
// account never had one and is working exactly as intended.
|
|
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
|
|
/>
|
|
<AccessRow
|
|
icon={Globe}
|
|
label={t("users.account.google")}
|
|
value={
|
|
!a.has_google
|
|
? t("users.account.googleNone")
|
|
: a.google_email
|
|
? t("users.account.googleAs", { email: a.google_email })
|
|
: t("users.account.googleConnected")
|
|
}
|
|
tone={a.has_google ? "ok" : "muted"}
|
|
/>
|
|
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
|
{!a.is_allowed && (
|
|
<AccessRow
|
|
icon={ShieldAlert}
|
|
label={t("users.account.invite")}
|
|
value={t("users.account.inviteMissing")}
|
|
tone="warn"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
|
{t("users.account.recovery")}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{a.can_activate && (
|
|
<button
|
|
onClick={() => activate.mutate()}
|
|
disabled={activate.isPending}
|
|
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
|
>
|
|
<Check className="w-4 h-4 shrink-0" />
|
|
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => resend.mutate()}
|
|
disabled={!a.email_configured || a.email_verified || resend.isPending}
|
|
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
|
>
|
|
<Send className="w-4 h-4 shrink-0" />
|
|
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
|
{/* 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 && (
|
|
<span className="text-muted">{t("users.account.mailOff")}</span>
|
|
)}
|
|
{a.email_configured && a.email_verified && (
|
|
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => issue.mutate()}
|
|
disabled={issue.isPending || user.is_demo}
|
|
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
|
>
|
|
<KeyRound className="w-4 h-4 shrink-0" />
|
|
<span className="flex-1 text-left">
|
|
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{link && (
|
|
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
|
{link.url}
|
|
</div>
|
|
<button
|
|
onClick={copy}
|
|
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
|
>
|
|
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
|
{copied ? t("users.account.copied") : t("users.account.copy")}
|
|
</button>
|
|
</div>
|
|
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|