Files
UHD_Media_Tool/apps/web/app/components/sites/site-accounts-tab.tsx
T
rootandClaude Opus 5.5 adc853aa64 feat(sites): site requests follow browser UA, smooth UA switch, CF cookies browser-only (BR-4)
- every outbound site request (adapter list/search/detail/download/health,
  RSS, login, user data) carries the account UA via SourcesRuntime.siteFetch;
  no more Deno default UA
- priority: account manual > pending > global setting > browser identity
  (fallback: same-shape Windows Firefox UA); settings UA default is empty
- migration 0056: existing cookie/password accounts keep their old UA as
  ua_pending until re-login or browser cookie write-back switches cookie+UA
  in one transaction; browser_provider.identity caches the browser identity
- cf_clearance/__cf_bm stripped on browser write-back (names kept only)
- browser sessions get per-site egress (same proxy as HTTP, or direct)
- site account page shows UA source; raw UA in tech info

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 03:17:13 +08:00

287 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(RSS 与下载链接用)</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>
);
}