A right-aligned column at the end of every Users & roles row says how long ago the account signed in, with the exact moment as the hint and as a line in the account panel; a dash where nothing is recorded, and the demo account says it is not recorded. formatDateTime formats that moment in the UI language rather than the browser's.
265 lines
11 KiB
TypeScript
265 lines
11 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 { 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";
|
|
|
|
// 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),
|
|
});
|
|
|
|
// 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
|
|
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 exact moment; the list row beside it only says how long ago. Null has two different
|
|
meanings, so it says which: nothing recorded yet, or the shared demo account, which is
|
|
deliberately never stamped. */}
|
|
<AccessRow
|
|
icon={LogIn}
|
|
label={t("users.account.lastLogin")}
|
|
value={
|
|
a.last_login_at
|
|
? formatDateTime(a.last_login_at)
|
|
: a.is_demo
|
|
? t("users.account.lastLoginDemo")
|
|
: 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>
|
|
|
|
<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()}
|
|
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
|
// it refuses it a set-password link below (deep-review E11 round 3).
|
|
disabled={user.is_demo || !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>
|
|
);
|
|
}
|