Files
UHD_Media_Tool/apps/web/app/components/sites/site-accounts-tab.tsx
T

287 lines
11 KiB
TypeScript

import type { SiteAccount, SiteListItem } from "@umt/contracts";
import { KeyRound, LoaderCircle, RefreshCw, Trash2, UserRound } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useDeleteSiteAccount, useLoginHistory, usePutCredentials } from "~/api/sites.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx";
import { ACCOUNT_OVERALL, AUTH_KIND_LABEL, loginFailureText, REASON_SHORT } from "./site-fields.ts";
// 站点详情"账号":凭证管理(更新 / 刷新登录 / 删除 / Passkey)+ 登录历史
type Checks = ReturnType<typeof useSiteChecks>;
export function SiteAccountsTab({
site,
checks,
onDialog,
}: {
site: SiteListItem;
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
const accounts = site.accounts ?? [];
if (!accounts.length)
return (
<EmptyState
icon={UserRound}
title="还没有账号"
description="填写凭证后,这里显示登录状态和登录历史"
action={
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
}
/>
);
return (
<div className="space-y-6 pt-2">
{accounts.map((a) => (
<AccountBlock key={a.id} site={site} account={a} checks={checks} onDialog={onDialog} />
))}
<PasskeyForm site={site} label={accounts[0]?.label ?? site.key} has={accounts.some((a) => a.hasPasskey)} />
</div>
);
}
function AccountBlock({
site,
account: a,
checks,
onDialog,
}: {
site: SiteListItem;
account: SiteAccount;
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
const del = useDeleteSiteAccount();
const st = ACCOUNT_OVERALL[a.enabled ? a.status : "unconfigured"];
const busy = checks.isBusy(site.id, "login");
const loginKind = a.authKind === "passkey" ? undefined : a.authKind;
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<section className="pg-slab min-w-0 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-lead font-semibold text-foreground">账号 · {a.label}</h2>
<div className="mt-1 flex flex-wrap gap-1.5">
<StatusBadge tone={st.tone}>{a.enabled ? st.label : "已停用"}</StatusBadge>
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{AUTH_KIND_LABEL[a.authKind]}
</span>
{a.hasPasskey && a.authKind !== "passkey" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
Passkey
</span>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{a.authKind !== "passkey" ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void checks.refreshLogin(site, a)}>
{busy ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
刷新登录
</Button>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => onDialog({ kind: "credentials", site, account: a, loginKind })}
>
<KeyRound />
更新凭证
</Button>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" className="hover:text-destructive">
<Trash2 />
删除账号
</Button>
}
title={`删除账号「${a.label}」?`}
description="账号、凭证和用户数据历史一起删除,不可恢复。站点和资源保留。"
confirmLabel="删除账号"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success(`已删除账号「${a.label}」`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</div>
</div>
<KvList
items={[
{ label: "登录名", value: a.loginName },
{ label: "用户名", value: a.userData?.username },
{
label: "上次登录",
value: a.lastLoginAt ? `${relTime(a.lastLoginAt)} · ${formatDateTime(a.lastLoginAt)}` : null,
},
{ label: "上次验证", value: relTime(a.lastVerifiedAt) },
{
label: "二次验证",
value: a.authKind === "password" ? (a.hasTotp ? "已填 TOTP 密钥" : "未填 TOTP 密钥") : null,
},
{
label: "会话 Cookie",
value: a.authKind === "password" ? (a.hasSession ? "已保存" : "还没有") : null,
},
{ label: "User-Agent", value: UA_SOURCE_TEXT[a.userAgentSource], wide: true },
...(a.lastError
? [
{
label: `最近错误 · ${relTime(a.lastError.at)}`,
value: (
<span className="text-warning">
{loginFailureText(a.lastError.reason, a.lastError.message, a.authKind).text}
</span>
),
wide: true,
},
]
: []),
]}
/>
<TechInfo
className="mt-3"
columns={1}
items={[
{ label: "当前 User-Agent", value: a.effectiveUserAgent, wide: true },
{
label: "仅浏览器使用的 Cookie",
value: a.browserOnlyCookies.length ? a.browserOnlyCookies.join(", ") : null,
wide: true,
},
]}
/>
</section>
<LoginHistory accountId={a.id} />
</div>
);
}
/** 站点请求 UA 的来源(BR-4);原始 UA 在技术信息里 */
const UA_SOURCE_TEXT: Record<SiteAccount["userAgentSource"], string> = {
browser: "跟随浏览器",
manual: "手动填写",
global: "使用设置里的全局 UA",
pending: "待切换:下次刷新登录,或在原站视图里用过后改为跟随浏览器",
};
const ACTION_LABEL: Record<string, string> = {
"site_account.login_refreshed": "刷新登录",
"site_account.login_refresh_requested": "请求刷新登录",
"site_account.credentials_set": "更新凭证",
"site_account.created": "添加账号",
"site_account.updated": "修改账号",
};
function LoginHistory({ accountId }: { accountId: string }) {
const q = useLoginHistory(accountId);
const items = (q.data?.items ?? []).filter((e) => e.action in ACTION_LABEL);
return (
<DetailSection title="登录历史">
{q.isPending ? (
<LoadingState rows={3} />
) : q.error ? (
<ErrorState plain error={q.error} onRetry={() => void q.refetch()} />
) : !items.length ? (
<p className="text-caption text-muted-foreground">还没有记录。刷新登录或更新凭证后出现在这里</p>
) : (
<ol className="space-y-2">
{items.map((e) => {
const ok = e.payload.ok;
const reason = typeof e.payload.reason === "string" ? e.payload.reason : null;
return (
<li key={e.id} className="flex items-start justify-between gap-3 text-caption">
<span className="min-w-0">
<span className="text-foreground">{ACTION_LABEL[e.action]}</span>
{ok === true ? (
<StatusBadge tone="success" className="ml-2">
成功
</StatusBadge>
) : ok === false ? (
<StatusBadge tone="destructive" className="ml-2">
{(reason && REASON_SHORT[reason as keyof typeof REASON_SHORT]) || "失败"}
</StatusBadge>
) : null}
</span>
<time className="shrink-0 text-meta text-muted-foreground" dateTime={e.at} title={formatDateTime(e.at)}>
{relTime(e.at)}
</time>
</li>
);
})}
</ol>
)}
</DetailSection>
);
}
function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string; has: boolean }) {
const put = usePutCredentials();
const [v, setV] = useState("");
const [err, setErr] = useState<string | null>(null);
return (
<form
className="pg-slab max-w-xl rounded-xl p-4"
noValidate
onSubmit={async (e) => {
e.preventDefault();
if (!v.trim()) {
setErr(has ? "填写新的 Passkey 再保存" : "填写 Passkey");
return;
}
setErr(null);
try {
await put.mutateAsync({ siteId: site.id, body: { kind: "passkey", label, passkey: v.trim() } });
setV("");
toast.success("Passkey 已保存");
} catch (error) {
toast.error(errorMessage(error, "保存失败"));
}
}}
>
<h2 className="text-lead font-semibold text-foreground">站点 Passkey</h2>
<p className="mb-3 text-caption text-muted-foreground">
用于 RSS 和下载链接,不参与登录。{has ? "填写新值保存会替换。" : "还没有保存。"}
</p>
<div className="flex flex-wrap items-start gap-2">
<FormField label="Passkey" htmlFor="pk" error={err} className="min-w-0 flex-1">
<SecretInput
id="pk"
label="Passkey"
value={v}
isSet={has}
invalid={!!err}
onChange={setV}
disabled={put.isPending}
/>
</FormField>
<Button type="submit" size="sm" className="mt-6" disabled={put.isPending}>
{put.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存 Passkey
</Button>
</div>
</form>
);
}