Files
Commilitia-Drop/web/src/routes/settings.tsx
T
admin 10cf36ecee 鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移
后端(委托 Auth Broker,路径 A):
- 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest()
- internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等

统一会话模型“委派设备会话”(Delegated Device Sessions):
- 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表
- 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备
- 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定
- 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行

Web 前端:
- 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh
- 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环
- 去 OIDC PKCE / step-up(删 oauth.callback / stepUp)

桌面客户端(Wails):
- loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置

iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥):
- 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库)
- 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId

实时 / 健壮性:
- presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线)
- Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热)

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
2026-06-26 22:10:19 +08:00

494 lines
20 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 { useCallback, useEffect, useState } from "react";
import {
Anchor,
Container,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, renameDeviceSession, syncWebDeviceName } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import {
currentPushState,
disablePush,
enablePush,
pushSupported,
type PushState,
} from "../net/push";
import {
listSessions, revokeSession,
type AuthSession,
} from "../net/sessions";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { formatRelative, isAsciiDeviceName } from "../utils/format";
import { Badge, Button, Callout, DynText, Panel, TextField } from "../ui/primitives";
import { StateDot } from "../ui/glyphs";
import { toast } from "../ui/feedback";
export const Route = createFileRoute("/settings")({
component: SettingsPage,
beforeLoad: () =>
{
const { user, selfDeviceName } = useAppStore.getState();
if (!user) { throw redirect({ to: "/login", search: { dev_user: undefined } }); }
if (!selfDeviceName) { throw redirect({ to: "/setup" }); }
},
});
function SettingsPage()
{
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const sessionScope = useAppStore((s) => s.sessionScope);
const isGuest = sessionScope === "guest";
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
const [ renaming, setRenaming ] = useState(false);
const trimmedName = pendingName.trim();
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
const handleRename = async () =>
{
if (!selfDeviceName) { return; }
if (!trimmedName) { toast.warn(t("settings.deviceName.empty")); return; }
if (!isAsciiDeviceName(trimmedName))
{
toast.warn(t("settings.deviceName.asciiOnly"));
return;
}
if (trimmedName === selfDeviceName)
{
toast.warn(t("settings.deviceName.unchanged"));
return;
}
setRenaming(true);
try
{
// 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连
// SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话,
// 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名,
// broker label 于下次登录代铸时同步。
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
await renameDeviceSession();
toast.ok(t("settings.deviceName.success"));
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRenaming(false);
}
};
const handleUnregisterSelf = () =>
{
if (!selfDeviceName) { return; }
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
// logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按
// 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。
setSelfDeviceName(null);
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
const handleSignOut = () =>
{
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
return (
<Container size="sm" py="lg">
<Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap">
<Title order={2}>{t("settings.title")}</Title>
{isGuest && <Badge tone="warn">{t("settings.guest.badge")}</Badge>}
</Group>
<Anchor component={Link} to="/" size="sm">
{t("nav.backToHome")}
</Anchor>
</Group>
{/* 受限访客标识:说明这台设备不能管理账号 / 授权设备,要完整权限请在
主设备上重新登录。放在最显眼处(标题下第一块)。 */}
{isGuest && (
<Callout
tone="warn"
icon={<ShieldAlert size={16} />}
title={t("settings.guest.title")}
>
{t("settings.guest.body")}
</Callout>
)}
<Panel title={t("settings.currentDevice.title")}>
<Stack gap="sm">
<TextField
label={t("settings.deviceName.field")}
value={pendingName}
onChange={(e) => setPendingName(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && canSaveName)
{
e.preventDefault();
void handleRename();
}
}}
/>
<Group>
<Button
variant="primary"
onClick={handleRename}
disabled={!canSaveName}
loading={renaming}
>
{t("settings.deviceName.save")}
</Button>
</Group>
<div style={{ height: 1, background: "var(--divider)" }} />
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">
{t("settings.unregister.currentHint")}
</Text>
<Group>
<Button
variant="danger"
leftIcon={<Trash2 size={14} />}
onClick={handleUnregisterSelf}
>
{t("settings.unregister.current")}
</Button>
</Group>
</Stack>
</Panel>
{isDesktop() && <DesktopSettings />}
{pushSupported() && <PushPanel />}
<SessionsPanel
isGuest={isGuest}
onSignedOutSelf={handleSignOut}
/>
<Panel title={t("settings.account.title")}>
<Stack gap="sm">
{user && (
<Stack gap={2}>
<Text size="sm" fw={500} truncate>{user.name}</Text>
{user.id !== user.name && (
<Text size="xs" c="dimmed" truncate>{user.id}</Text>
)}
</Stack>
)}
<Group>
<Button
variant="ghost"
leftIcon={<LogOut size={14} />}
onClick={handleSignOut}
>
{t("settings.account.signOut")}
</Button>
</Group>
</Stack>
</Panel>
</Stack>
</Container>
);
}
// PushPanel:浏览器 / PWA 的 Web Push 开关。仅 pushSupported() 为真时渲染(桌面壳
// 与 dev 均为 false——桌面走原生通知、dev 无 SW)。订阅权威态在浏览器 pushManager
// 挂载时用 currentPushState 读取一次。
function PushPanel()
{
const [ state, setState ] = useState<PushState | null>(null);
const [ busy, setBusy ] = useState(false);
useEffect(() =>
{
let alive = true;
void currentPushState().then((s) => { if (alive) { setState(s); } });
return () => { alive = false; };
}, []);
const handleEnable = async () =>
{
setBusy(true);
try
{
const s = await enablePush();
setState(s);
if (s.subscribed) { toast.ok(t("settings.push.enableOk")); }
else if (s.permission === "denied") { toast.warn(t("settings.push.denied")); }
else { toast.error(t("settings.push.failed")); }
}
finally { setBusy(false); }
};
const handleDisable = async () =>
{
setBusy(true);
try
{
const s = await disablePush();
setState(s);
toast.ok(t("settings.push.disableOk"));
}
finally { setBusy(false); }
};
const subscribed = state?.subscribed ?? false;
const denied = state?.permission === "denied";
return (
<Panel title={t("settings.push.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.push.hint")}</Text>
{denied && !subscribed && (
<Text size="sm" className="justify" data-jz-level="paragraph" style={{ color: "var(--state-error)" }}>
{t("settings.push.deniedHint")}
</Text>
)}
<Group>
{subscribed ? (
<Button
variant="ghost"
leftIcon={<Bell size={14} />}
loading={busy}
onClick={handleDisable}
>
{t("settings.push.disable")}
</Button>
) : (
<Button
variant="primary"
leftIcon={<Bell size={14} />}
loading={busy}
disabled={denied}
onClick={handleEnable}
>
{t("settings.push.enable")}
</Button>
)}
{subscribed && (
<Badge tone="online">{t("settings.push.enabled")}</Badge>
)}
</Group>
</Stack>
</Panel>
);
}
// sortSessions 把会话排成「在线在上、未活跃(offline)在下」,组内按最近活跃时间降序
// (活跃越近越靠前)。不改变原数组,返回新数组。
function sortSessions(list: AuthSession[]): AuthSession[]
{
return [ ...list ].sort((a, b) =>
{
if (a.online !== b.online) { return a.online ? -1 : 1; }
return b.lastUsedAt - a.lastUsedAt;
});
}
// SessionsPanel:登录会话管理——浏览器 / 扫码登录的设备会话。每条显示在线状态点 +
// 设备名 + 权限级别 Badge + 「本机」标记 + 最近活跃,并提供「登出」(真正吊销该登录
// 的访问令牌)。在线会话排在上、未活跃(offline)的排在下;离线会话仍可登出。
//
// step-upDELETE 返回 403 step_up_required 时,把待登出的 sessionId 暂存 → 发起
// 一次 prompt=login 再认证(returnTo=/settings)→ 整页跳走 → 回来后由本组件 mount
// effect 取出暂存的 {code, verifier} + sessionIdPOST /auth/stepup 记录窗口,再
// 重试那条 DELETE。step-up 窗口内(后端 5 分钟)后续登出不再 403、直接成功。
//
// 受限访客(isGuest):无权管理登录会话、后端 GET 会 403,故根本不发该请求,直接展示
// 一句克制的说明 Callout(不出现任何加载 / 失败态)。
function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
{
const { isGuest, onSignedOutSelf } = props;
const [ sessions, setSessions ] = useState<AuthSession[] | null>(null);
const [ loadError, setLoadError ] = useState(false);
const [ busyId, setBusyId ] = useState<string | null>(null);
// reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke
// 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话,
// 令牌已失效、那次 listSessions 会 401)。在线会话排前、未活跃的排后,组内按最近
// 活跃时间降序。失败回 null。受限访客不会走到这里(调用方在 guest 分支直接返回)。
const reload = useCallback(async (): Promise<AuthSession[] | null> =>
{
setLoadError(false);
try
{
const list = sortSessions(await listSessions());
setSessions(list);
return list;
}
catch { setLoadError(true); setSessions(null); return null; }
}, []);
// doRevoke 真正执行一条登出。current(本机)会话登出后清本地 auth、回登录页
// (等价退出登录);其余刷新列表。403 step_up_required 由调用方分流,不入此处。
const finishRevoke = useCallback((sess: AuthSession) =>
{
if (sess.current) { onSignedOutSelf(); return; }
toast.ok(t("settings.sessions.revoked"));
void reload();
}, [ onSignedOutSelf, reload ]);
// 进页面拉一次列表。受限访客无权管理会话、后端 GET 会 403,故跳过、走说明态。
useEffect(() =>
{
if (isGuest) { return; }
void reload();
}, [ isGuest, reload ]);
// handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。
// step-up 二次认证已移除(full 档即可信)。
const handleRevoke = async (sess: AuthSession) =>
{
const confirmMsg = sess.current
? t("settings.sessions.confirmCurrent")
: t("settings.sessions.confirmOther", { name: sess.deviceName });
if (!window.confirm(confirmMsg)) { return; }
setBusyId(sess.id);
try
{
await revokeSession(sess.id);
finishRevoke(sess);
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally { setBusyId(null); }
};
// 受限访客:不展示列表 / 加载 / 失败态,只给一句克制的说明——管理登录会话需在主设备
// 上完整登录。后端 GET 本就 403,前面 effect 也已跳过请求,这里纯文案分支。
if (isGuest)
{
return (
<Panel title={t("settings.sessions.title")}>
<Callout tone="warn" icon={<ShieldAlert size={16} />}>
{t("settings.sessions.guestNote")}
</Callout>
</Panel>
);
}
return (
<Panel title={t("settings.sessions.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.sessions.hint")}</Text>
{loadError ? (
<Group>
<Text size="sm" style={{ color: "var(--state-error)" }}>
{t("settings.sessions.loadError")}
</Text>
<Button size="sm" variant="ghost" onClick={() => void reload()}>
{t("settings.sessions.retry")}
</Button>
</Group>
) : sessions === null ? (
// 仍在加载(首屏闪一帧):不渲染占位文案,避免「暂无」误闪。
null
) : sessions.length === 0 ? (
<Text size="sm" c="dimmed">{t("settings.sessions.empty")}</Text>
) : (
<Stack gap="xs">
{sessions.map((sess) => (
<SessionRow
key={sess.id}
session={sess}
busy={busyId === sess.id}
// 在线、离线会话都可登出(离线亦走 step-up,流程一致)。
onRevoke={() => handleRevoke(sess)}
/>
))}
</Stack>
)}
</Stack>
</Panel>
);
}
// 会话种类标签:迁移后每条会话即一台设备,统一复用设备类型标签
// (「网页」/「macOS 客户端」等)。
function sessionKindLabel(kind: AuthSession["kind"]): string
{
return t(`deviceType.${kind}` as const);
}
function SessionRow(props: { session: AuthSession; busy: boolean; onRevoke?: () => void })
{
const { session, busy, onRevoke } = props;
const lastUsedText = session.lastUsedAt
? t("settings.sessions.lastUsed", { time: formatRelative(session.lastUsedAt) })
: t("settings.sessions.neverUsed");
const kindLabel = sessionKindLabel(session.kind);
const onlineLabel = t(session.online ? "settings.online" : "settings.offline");
return (
<Panel variant="sunken" padding="tight">
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap">
<StateDot
tone={session.online ? "online" : "offline"}
title={onlineLabel}
/>
<Text fw={500} size="sm" truncate>
{session.deviceName || kindLabel}
</Text>
<Badge tone={session.scope === "guest" ? "warn" : "accent"}>
{session.scope === "guest"
? t("settings.sessions.scopeGuest")
: t("settings.sessions.scopeFull")}
</Badge>
{session.current && (
<Badge tone="online">{t("settings.sessions.current")}</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
<DynText>{onlineLabel}</DynText> · {kindLabel} · <DynText>{lastUsedText}</DynText>
</Text>
</Stack>
{onRevoke && (
<Button
size="sm"
variant="ghost"
loading={busy}
leftIcon={<LogOut size={13} />}
onClick={onRevoke}
style={{ color: "var(--state-error)" }}
>
{busy
? t("settings.sessions.signingOut")
: session.current
? t("settings.sessions.signOutCurrent")
: t("settings.sessions.signOut")}
</Button>
)}
</Group>
</Panel>
);
}