10cf36ecee
后端(委托 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 必填校验
494 lines
20 KiB
TypeScript
494 lines
20 KiB
TypeScript
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-up:DELETE 返回 403 step_up_required 时,把待登出的 sessionId 暂存 → 发起
|
||
// 一次 prompt=login 再认证(returnTo=/settings)→ 整页跳走 → 回来后由本组件 mount
|
||
// effect 取出暂存的 {code, verifier} + sessionId,POST /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>
|
||
);
|
||
}
|
||
|