- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so the dialog's notice is the only one whichever comes first; another device's upload follows after. - One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token dialog's two Copy buttons are named by what they copy. - The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no New link beside a list it has not seen. - The settings texts say that signing out elsewhere or a password change revokes the API tokens. - DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the Links, Tokens and Files rounds.
264 lines
10 KiB
TypeScript
264 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 { 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 (
|
|
<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, 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 (
|
|
<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>
|
|
);
|
|
}
|