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")}
}
onClick={handleUnregisterSelf}
>
{t("settings.unregister.current")}
{isDesktop() && }
{pushSupported() && }
{user && (
{user.name}
{user.id !== user.name && (
{user.id}
)}
)}
}
onClick={handleSignOut}
>
{t("settings.account.signOut")}
);
}
// 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 ? (
}
loading={busy}
onClick={handleDisable}
>
{t("settings.push.disable")}
) : (
}
loading={busy}
disabled={denied}
onClick={handleEnable}
>
{t("settings.push.enable")}
)}
{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 && (
}
onClick={onRevoke}
style={{ color: "var(--state-error)" }}
>
{busy
? t("settings.sessions.signingOut")
: session.current
? t("settings.sessions.signOutCurrent")
: t("settings.sessions.signOut")}
)}
);
}