feat(admin): an account panel, and a set-password link that needs no email

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.
This commit is contained in:
2026-09-05 01:46:58 +02:00
parent bc9e34d0ae
commit f98b060e2b
14 changed files with 778 additions and 10 deletions
+238
View File
@@ -0,0 +1,238 @@
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>
);
}
+11 -2
View File
@@ -31,6 +31,7 @@ import { Section } from "./ui/form";
import SegmentedToggle from "./SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
@@ -107,6 +108,9 @@ function UsersRoles({ me }: { me: Me }) {
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
// with per-row disabling the admin can now start actions on several rows concurrently.
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
// only one is ever mounted.
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
const endPending = (id: number) =>
setPendingIds((s) => {
@@ -192,6 +196,7 @@ function UsersRoles({ me }: { me: Me }) {
return (
<Section card title={t("users.roles.title")}>
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -207,13 +212,17 @@ function UsersRoles({ me }: { me: Me }) {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<button
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className="min-w-0 flex-1 text-left rounded-lg -m-1 p-1 hover:bg-hover transition"
>
<div className="text-sm truncate">
{u.display_name ?? u.email.split("@")[0]}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-2xs text-muted truncate">{u.email}</div>
</div>
</button>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
+15 -2
View File
@@ -25,11 +25,17 @@ export default function Modal({
onClose,
children,
maxWidth = "max-w-lg",
placement = "center",
}: {
title: ReactNode;
onClose: () => void;
children: ReactNode;
maxWidth?: string;
/** `drawer` slides in from the right edge and runs full height; everything else about the shell —
* the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
* is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
* two shells drift apart. */
placement?: "center" | "drawer";
}) {
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
@@ -106,9 +112,12 @@ export default function Modal({
};
}, [restoreTo]);
const drawer = placement === "drawer";
return createPortal(
<div
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 overlay-backdrop"
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
drawer ? "justify-end" : "items-center justify-center p-4 sm:p-6"
}`}
onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget;
}}
@@ -122,7 +131,11 @@ export default function Modal({
<div
ref={panelRef}
tabIndex={-1}
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`}
className={
drawer
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 id={titleId} className="text-lg font-semibold leading-snug">
@@ -5,6 +5,7 @@
"user_delete": "User deleted",
"user_activate": "Account activated",
"user_verify_resend": "Confirmation link re-sent",
"user_setpw_link": "Set-password link issued",
"invite_approve": "Access request approved",
"invite_deny": "Access request denied",
"invite_add": "Email whitelisted",
+33
View File
@@ -59,5 +59,38 @@
"title": "Delete account?",
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
"done": "{{email}} deleted"
},
"account": {
"access": "Access",
"activate": "Let this account in",
"alreadyConfirmed": "already confirmed",
"approval": "Admin approval",
"approvalDone": "let in",
"approvalNotRequested": "no approved invite yet",
"approvalWaitingOnYou": "waiting on you",
"copied": "Copied",
"copy": "Copy",
"copyFailed": "Couldn't copy — select the link and copy it by hand.",
"email": "Email",
"emailConfirmed": "confirmed",
"emailUnconfirmed": "not confirmed",
"google": "Google",
"googleAs": "connected as {{email}}",
"googleConnected": "connected",
"googleNone": "not connected",
"invite": "Invite",
"inviteMissing": "this address is not on the allow-list",
"linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.",
"mailOff": "no mail configured",
"open": "Open the account panel",
"password": "Password",
"passwordNone": "none — signs in with Google",
"passwordRevoked": "cleared for security",
"passwordSet": "set",
"recovery": "Recovery",
"resend": "Send a confirmation link",
"resendSent": "Confirmation link sent.",
"setPassword": "Create a set-password link",
"setPasswordAgain": "Create another link"
}
}
@@ -5,6 +5,7 @@
"user_delete": "Felhasználó törölve",
"user_activate": "Fiók aktiválva",
"user_verify_resend": "Megerősítő link újraküldve",
"user_setpw_link": "Jelszóbeállító link kiadva",
"invite_approve": "Hozzáférési kérelem jóváhagyva",
"invite_deny": "Hozzáférési kérelem elutasítva",
"invite_add": "Email fehérlistázva",
+33
View File
@@ -59,5 +59,38 @@
"title": "Törlöd a fiókot?",
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
"done": "{{email}} törölve"
},
"account": {
"access": "Hozzáférés",
"activate": "Fiók beengedése",
"alreadyConfirmed": "már megerősítve",
"approval": "Admin jóváhagyás",
"approvalDone": "beengedve",
"approvalNotRequested": "még nincs jóváhagyott meghívó",
"approvalWaitingOnYou": "rád vár",
"copied": "Másolva",
"copy": "Másolás",
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel.",
"email": "E-mail",
"emailConfirmed": "megerősítve",
"emailUnconfirmed": "nincs megerősítve",
"google": "Google",
"googleAs": "összekötve mint {{email}}",
"googleConnected": "összekötve",
"googleNone": "nincs összekötve",
"invite": "Meghívó",
"inviteMissing": "ez a cím nincs az engedélyezési listán",
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
"mailOff": "nincs beállítva levelezés",
"open": "Fiókpanel megnyitása",
"password": "Jelszó",
"passwordNone": "nincs — Google-lel lép be",
"passwordRevoked": "biztonsági okból törölve",
"passwordSet": "be van állítva",
"recovery": "Helyreállítás",
"resend": "Megerősítő link küldése",
"resendSent": "Megerősítő link elküldve.",
"setPassword": "Jelszóbeállító link készítése",
"setPasswordAgain": "Új link készítése"
}
}
+18
View File
@@ -657,6 +657,24 @@ html[data-perf="1"] .overlay-backdrop {
backdrop-filter: none;
}
/* Right-edge drawer (Modal placement="drawer"). The slide is what makes it read as a panel that
came from the side rather than a dialog that happens to be right-aligned — so it is part of the
affordance, not decoration. Honours prefers-reduced-motion, like every other motion here. */
.overlay-drawer {
animation: drawer-in 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes drawer-in {
from {
transform: translateX(1.5rem);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.overlay-drawer {
animation: none;
}
}
/* Skip layout/paint for off-screen feed cards so long lists stay smooth while
scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar
stays stable; `auto` lets the browser remember each card's real size after it
+24
View File
@@ -75,10 +75,26 @@ export interface AdminUserRow {
email_verified: boolean;
is_demo: boolean;
has_password: boolean;
/** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only
* account) from "we took it away" — different problems needing different help. */
password_revoked_at: string | null;
has_google: boolean;
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
google_email: string | null;
created_at: string | null;
}
/** The account panel's view of why someone can or cannot get in (E12 S5). */
export interface AdminUserAccess extends AdminUserRow {
/** The address passes the invite gate — so Google sign-in will be let through, not filed as a
* fresh access request. */
is_allowed: boolean;
can_activate: boolean;
/** Whether this instance has working mail at all. Decides whether the two mail-shaped recovery
* actions are offered or shown as unavailable — rather than failing after the click. */
email_configured: boolean;
}
export interface AuditRow {
id: number;
created_at: string | null;
@@ -197,6 +213,14 @@ export const adminApi = {
// because the caller is already an authenticated admin.
resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> =>
req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }),
// What the account panel shows: the two computed gate answers, resolved for one user on open.
adminUserAccess: (id: number): Promise<AdminUserAccess> => req(`/api/admin/users/${id}/access`),
// Mint a one-time, no-email link that lets this account set a password (SEC-15 / SEC-16, and the
// everyday "no SMTP and someone forgot theirs"). Issuing does not revoke the current password.
createSetPasswordLink: (
id: number,
): Promise<{ ok: boolean; url: string; expires_at: string | null; email: string }> =>
req(`/api/admin/users/${id}/set-password-link`, { method: "POST" }, { quiet: true }),
adminInvites: (status?: string): Promise<Invite[]> =>
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
+1
View File
@@ -85,6 +85,7 @@ export const qk = {
// --- admin ---
adminUsers: () => ["admin-users"] as const,
adminUserAccess: (id: number) => ["admin-user-access", id] as const,
adminInvites: () => ["admin-invites"] as const,
adminPendingAccess: () => ["admin-pending-access"] as const,
adminConfig: () => ["admin-config"] as const,