登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复
接续 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
This commit is contained in:
+289
-107
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Anchor,
|
||||
Container,
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Bell, LogOut, Trash2 } from "lucide-react";
|
||||
import { logout, syncWebDeviceName } from "../features/auth/auth";
|
||||
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 {
|
||||
@@ -20,25 +23,15 @@ import {
|
||||
pushSupported,
|
||||
type PushState,
|
||||
} from "../net/push";
|
||||
import { useAppStore, type DeviceInfo } from "../store";
|
||||
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, Panel, TextField } from "../ui/primitives";
|
||||
import { Badge, Button, Callout, Panel, TextField } from "../ui/primitives";
|
||||
import { StateDot } from "../ui/glyphs";
|
||||
|
||||
// deviceTypeLabel 把后端存的设备类型本地化展示(浏览器 / macOS 客户端 / …)。
|
||||
// 容错匹配(与 DeviceStrip 的 mapKind 一致),未知类型回退原值。
|
||||
function deviceTypeLabel(type: string): string
|
||||
{
|
||||
const k = type.toLowerCase();
|
||||
if (k.includes("mac") || k === "darwin") { return t("deviceType.macos"); }
|
||||
if (k.includes("win")) { return t("deviceType.windows"); }
|
||||
if (k.includes("linux")) { return t("deviceType.linux"); }
|
||||
if (k === "shortcut") { return t("deviceType.shortcut"); }
|
||||
if (k.includes("ios") || k === "iphone" || k === "ipad") { return t("deviceType.ios"); }
|
||||
if (k === "browser") { return t("deviceType.browser"); }
|
||||
return type;
|
||||
}
|
||||
import { toast } from "../ui/feedback";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
@@ -55,19 +48,14 @@ 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 storeDevices = useAppStore((s) => s.devices);
|
||||
|
||||
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
|
||||
const [ removing, setRemoving ] = useState<Set<string>>(new Set());
|
||||
const [ renaming, setRenaming ] = useState(false);
|
||||
|
||||
const peerDevices = useMemo(
|
||||
() => storeDevices.filter((d) => d.name !== selfDeviceName),
|
||||
[ storeDevices, selfDeviceName ],
|
||||
);
|
||||
|
||||
const trimmedName = pendingName.trim();
|
||||
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
|
||||
|
||||
@@ -118,48 +106,6 @@ function SettingsPage()
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePeer = async (name: string) =>
|
||||
{
|
||||
if (!window.confirm(t("settings.unregister.peerConfirm", { name }))) { return; }
|
||||
|
||||
setRemoving((prev) =>
|
||||
{
|
||||
const next = new Set(prev);
|
||||
next.add(name);
|
||||
return next;
|
||||
});
|
||||
try
|
||||
{
|
||||
const r = await apiFetch(
|
||||
`/api/devices/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`${r.status}: ${text}`);
|
||||
}
|
||||
// 服务端的 publishPresence 会通过 SSE 推送新的设备列表,store
|
||||
// 会随之更新,这里不需要手动 setDevices。
|
||||
toast.ok("设备已移除", name);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
finally
|
||||
{
|
||||
setRemoving((prev) =>
|
||||
{
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnregisterSelf = async () =>
|
||||
{
|
||||
if (!selfDeviceName) { return; }
|
||||
@@ -194,12 +140,27 @@ function SettingsPage()
|
||||
<Container size="sm" py="lg">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Title order={2}>{t("settings.title")}</Title>
|
||||
<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
|
||||
@@ -226,7 +187,7 @@ function SettingsPage()
|
||||
</Button>
|
||||
</Group>
|
||||
<div style={{ height: 1, background: "var(--divider)" }} />
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">
|
||||
{t("settings.unregister.currentHint")}
|
||||
</Text>
|
||||
<Group>
|
||||
@@ -245,22 +206,10 @@ function SettingsPage()
|
||||
|
||||
{pushSupported() && <PushPanel />}
|
||||
|
||||
<Panel title={t("settings.peers.title")}>
|
||||
{peerDevices.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">{t("settings.peers.empty")}</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{peerDevices.map((d) => (
|
||||
<PeerRow
|
||||
key={d.name}
|
||||
device={d}
|
||||
removing={removing.has(d.name)}
|
||||
onRemove={() => handleRemovePeer(d.name)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
<SessionsPanel
|
||||
isGuest={isGuest}
|
||||
onSignedOutSelf={handleSignOut}
|
||||
/>
|
||||
|
||||
<Panel title={t("settings.account.title")}>
|
||||
<Stack gap="sm">
|
||||
@@ -335,9 +284,9 @@ function PushPanel()
|
||||
return (
|
||||
<Panel title={t("settings.push.title")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">{t("settings.push.hint")}</Text>
|
||||
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.push.hint")}</Text>
|
||||
{denied && !subscribed && (
|
||||
<Text size="sm" style={{ color: "var(--state-error)" }}>
|
||||
<Text size="sm" className="justify" data-jz-level="paragraph" style={{ color: "var(--state-error)" }}>
|
||||
{t("settings.push.deniedHint")}
|
||||
</Text>
|
||||
)}
|
||||
@@ -371,37 +320,270 @@ function PushPanel()
|
||||
);
|
||||
}
|
||||
|
||||
function PeerRow(props: { device: DeviceInfo; removing: boolean; onRemove: () => void })
|
||||
// sortSessions 把会话排成「在线在上、未活跃(offline)在下」,组内按最近活跃时间降序
|
||||
// (活跃越近越靠前)。不改变原数组,返回新数组。
|
||||
function sortSessions(list: AuthSession[]): AuthSession[]
|
||||
{
|
||||
const { device, removing, onRemove } = props;
|
||||
const lastSeenText = device.lastSeen
|
||||
? t("settings.lastSeen", { time: formatRelative(device.lastSeen) })
|
||||
: t("settings.lastSeenNever");
|
||||
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={device.online ? "online" : "offline"} />
|
||||
<Text fw={500} size="sm" truncate>{device.name}</Text>
|
||||
<Badge tone={device.online ? "online" : "neutral"}>
|
||||
{t(device.online ? "settings.online" : "settings.offline")}
|
||||
<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>
|
||||
{deviceTypeLabel(device.type)} · {lastSeenText}
|
||||
{onlineLabel} · {kindLabel} · {lastUsedText}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={removing}
|
||||
onClick={onRemove}
|
||||
style={{ color: "var(--state-error)" }}
|
||||
>
|
||||
{removing ? t("settings.peers.removing") : t("settings.peers.remove")}
|
||||
</Button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user