Files
siftlode/frontend/src/components/AccountPanel.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

272 lines
10 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, 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 { copyText } from "../lib/clipboard";
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";
// 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 (
<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, i18n } = useTranslation();
const lang = i18n.language;
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),
});
// 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")),
});
const copy = async () => {
if (!link) return;
// 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.
if (await copyText(link.url)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
return;
}
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
// action failing. Say so rather than leaving the button looking inert.
notify({ level: "warning", message: t("users.account.copyFailed") });
};
const a: AdminUserAccess | undefined = q.data;
return (
<Modal
initialFocus="close"
title={displayName(user)}
onClose={onClose}
placement="drawer"
maxWidth="max-w-md"
>
<div className="text-2xs text-muted truncate mb-4">{user.email}</div>
{q.isError && !a ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !a ? (
<LoadingState />
) : (
<>
<SectionLabel level={3} className="mb-2">
{t("users.account.access")}
</SectionLabel>
<div className={`${SURFACE.card} 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 exact moment; the list row beside it only says how long ago. */}
<AccessRow
icon={LogIn}
label={t("users.account.lastLogin")}
value={
a.last_login_at
? formatDateTime(a.last_login_at, lang)
: t("users.account.lastLoginNone")
}
/>
{/* 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>
<SectionLabel level={3} className="mb-2">
{t("users.account.recovery")}
</SectionLabel>
<div className="space-y-2">
{a.can_activate && (
<Button
variant="glass"
fullWidth
icon={<Check />}
loading={activate.isPending}
onClick={() => activate.mutate()}
className="border border-accent/40 text-accent"
>
<span className="flex-1 text-left">{t("users.account.activate")}</span>
</Button>
)}
<Button
variant="glass"
fullWidth
icon={<Send />}
loading={resend.isPending}
onClick={() => resend.mutate()}
disabled={!a.email_configured || a.email_verified}
>
<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
variant="glass"
fullWidth
icon={<KeyRound />}
loading={issue.isPending}
onClick={() => issue.mutate()}
className="border border-accent/40 text-accent"
>
<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
variant="glass"
icon={copied ? <Check /> : <Copy />}
onClick={copy}
className="border border-accent/40 text-accent"
>
{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>
);
}