018a77b13a
接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。
OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)
吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语
设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)
应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级
step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
仅此次=guest),默认偏安全的「仅此次」
文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
590 lines
23 KiB
TypeScript
590 lines
23 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 { 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 (
|
||
<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);
|
||
const [ stepUpRejected, setStepUpRejected ] = useState(false);
|
||
|
||
// 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 ]);
|
||
|
||
// 进页面拉一次。返回时若检测到 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 (
|
||
<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>
|
||
{stepUpRejected && (
|
||
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
|
||
{t("settings.sessions.stepUpRejected")}
|
||
</Callout>
|
||
)}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
// 会话种类标签:网页 / 扫码会话用 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 (
|
||
<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>
|
||
{onlineLabel} · {kindLabel} · {lastUsedText}
|
||
</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>
|
||
);
|
||
}
|
||
|