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 ( {t("settings.title")} {isGuest && {t("settings.guest.badge")}} {t("nav.backToHome")} {/* 受限访客标识:说明这台设备不能管理账号 / 授权设备,要完整权限请在 主设备上重新登录。放在最显眼处(标题下第一块)。 */} {isGuest && ( } title={t("settings.guest.title")} > {t("settings.guest.body")} )} setPendingName(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter" && canSaveName) { e.preventDefault(); void handleRename(); } }} />
{t("settings.unregister.currentHint")} {isDesktop() && } {pushSupported() && } {user && ( {user.name} {user.id !== user.name && ( {user.id} )} )} ); } // PushPanel:浏览器 / PWA 的 Web Push 开关。仅 pushSupported() 为真时渲染(桌面壳 // 与 dev 均为 false——桌面走原生通知、dev 无 SW)。订阅权威态在浏览器 pushManager, // 挂载时用 currentPushState 读取一次。 function PushPanel() { const [ state, setState ] = useState(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 ( {t("settings.push.hint")} {denied && !subscribed && ( {t("settings.push.deniedHint")} )} {subscribed ? ( ) : ( )} {subscribed && ( {t("settings.push.enabled")} )} ); } // 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(null); const [ loadError, setLoadError ] = useState(false); const [ busyId, setBusyId ] = useState(null); // reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke // 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话, // 令牌已失效、那次 listSessions 会 401)。在线会话排前、未活跃的排后,组内按最近 // 活跃时间降序。失败回 null。受限访客不会走到这里(调用方在 guest 分支直接返回)。 const reload = useCallback(async (): Promise => { 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 ( }> {t("settings.sessions.guestNote")} ); } return ( {t("settings.sessions.hint")} {loadError ? ( {t("settings.sessions.loadError")} ) : sessions === null ? ( // 仍在加载(首屏闪一帧):不渲染占位文案,避免「暂无」误闪。 null ) : sessions.length === 0 ? ( {t("settings.sessions.empty")} ) : ( {sessions.map((sess) => ( handleRevoke(sess)} /> ))} )} ); } // 会话种类标签:迁移后每条会话即一台设备,统一复用设备类型标签 // (「网页」/「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 ( {session.deviceName || kindLabel} {session.scope === "guest" ? t("settings.sessions.scopeGuest") : t("settings.sessions.scopeFull")} {session.current && ( {t("settings.sessions.current")} )} {onlineLabel} · {kindLabel} · {lastUsedText} {onRevoke && ( )} ); }