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 { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, startStepUpReauth, syncWebDeviceName } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import {
consumePendingRevoke, consumeStepUpCode, stashPendingRevoke,
} from "../features/qr/stepUp";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { apiFetch } from "../net/api";
import {
currentPushState,
disablePush,
enablePush,
pushSupported,
type PushState,
} from "../net/push";
import {
listSessions, postStepUp, revokeSession, StepUpRequiredError,
type AuthSession,
} from "../net/sessions";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { formatRelative, isAsciiDeviceName } from "../utils/format";
import { Badge, Button, Callout, 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
{
// 先在后端把旧设备记录删掉。Hub.Kick 会强制关闭旧的 SSE,
// 紧接着 root effect 检测到 selfDeviceName 变化会以新名重新注册。
// 404(旧记录已被 sweeper 清理)也按成功处理。
const r = await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
if (!r.ok && r.status !== 404)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
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 = async () =>
{
if (!selfDeviceName) { return; }
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
try
{
await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
}
catch
{
// 即便 DELETE 失败也按用户意图清理本地并跳转登录。
}
finally
{
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);
const [ stepUpRejected, setStepUpRejected ] = useState(false);
// 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 ]);
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
// 受限访客无权管理会话、后端 GET 会 403,故根本不发请求、直接走说明态。
useEffect(() =>
{
if (isGuest) { return; }
let cancelled = false;
const run = async () =>
{
// step-up 往返恢复:取出暂存(一次性)。无暂存则普通进页只拉列表。
const stash = consumeStepUpCode();
const pendingId = consumePendingRevoke();
const list = await reload();
if (cancelled) { return; }
if (stash && pendingId)
{
// 在 revoke 前就从这份列表里查出待登出的那条(尤其 current 标记):
// 若登出的正是本机会话,revoke 后令牌即失效,不能再 listSessions。
const target = list?.find((x) => x.id === pendingId);
setBusyId(pendingId);
try
{
await postStepUp(stash.code, stash.verifier);
await revokeSession(pendingId);
if (cancelled) { return; }
if (target) { finishRevoke(target); }
else { toast.ok(t("settings.sessions.revoked")); await reload(); }
}
catch (e)
{
if (cancelled) { return; }
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
else
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
void reload();
}
finally { if (!cancelled) { setBusyId(null); } }
}
};
void run();
return () => { cancelled = true; };
}, [ isGuest, reload, finishRevoke ]);
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);
setStepUpRejected(false);
try
{
await revokeSession(sess.id);
finishRevoke(sess);
}
catch (e)
{
if (e instanceof StepUpRequiredError)
{
// 需再认证:暂存待登出 id,发起 prompt=login 往返,回到 /settings 后
// 由 mount effect 接力重试。成功则整页跳走、本组件卸载。
stashPendingRevoke(sess.id);
try
{
await startStepUpReauth("/settings");
return; // 跳转去 provider,不再 setBusyId。
}
catch (reauthErr)
{
toast.error(t("settings.error.delete", {
message: reauthErr instanceof Error ? reauthErr.message : String(reauthErr),
}));
}
}
else
{
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")}
{stepUpRejected && (
}>
{t("settings.sessions.stepUpRejected")}
)}
{loadError ? (
{t("settings.sessions.loadError")}
) : sessions === null ? (
// 仍在加载(首屏闪一帧):不渲染占位文案,避免「暂无」误闪。
null
) : sessions.length === 0 ? (
{t("settings.sessions.empty")}
) : (
{sessions.map((sess) => (
handleRevoke(sess)}
/>
))}
)}
);
}
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
// (macos / windows / linux / ios)复用设备类型标签(「macOS 客户端」等)。
function sessionKindLabel(kind: AuthSession["kind"]): string
{
if (kind === "oidc" || kind === "self" || kind === "guest")
{
return t(`settings.sessions.kind.${kind}` as const);
}
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")}
)}
);
}