feat(api): 会话记录设备与最近活动,账户页可查看并退出任一设备,删除 passkey 或更换邮箱时退出其它设备,staff 会话空闲 30 分钟失效,吊销会话校验所属用户

This commit is contained in:
Lemon-miaow committed 2026-09-25 14:06:02 +08:00
1 parent 98295e630e
commit 9e7f23ca13
40 files changed
+2139 -179

No files matched your search

+86 -12
View File
@@ -572,6 +572,51 @@ function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
// thisSessionHash is the session the mock cookie stands for: one per account,
// so the account page can mark "This device".
function thisSessionHash(acc: MockAccount): string {
return `mock-this-${acc.id}`;
}
/** accountSessions seeds, on first read, the browsers an account is signed in
* on: this one, a phone seen yesterday and a PC idle for a week. */
function accountSessions(acc: MockAccount): SessionView[] {
if (!acc.sessions) {
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const until = new Date(Date.now() + 30 * 86_400_000).toISOString();
acc.sessions = [
{
token_hash: thisSessionHash(acc),
created_at: ago(3 * 86_400_000),
expires_at: until,
last_seen_at: ago(0),
user_agent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
client_ip: "2001:db8::1",
},
{
token_hash: `mock-phone-${acc.id}`,
created_at: ago(9 * 86_400_000),
expires_at: until,
last_seen_at: ago(20 * 3_600_000),
user_agent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
client_ip: "203.0.113.24",
},
{
token_hash: `mock-pc-${acc.id}`,
created_at: ago(20 * 86_400_000),
expires_at: until,
last_seen_at: ago(7 * 86_400_000),
user_agent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2739.42",
client_ip: "198.51.100.7",
},
];
}
return acc.sessions;
}
function identity(accountInfo: MockAccount): Identity {
return {
user_id: `mock-${accountInfo.id}`,
@@ -997,7 +1042,39 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendJSON(ctx.res, 200, { credentials: list });
return true;
}
case "GET account/migrate":
// No migration pending: the real API's answer until one is started in-game.
sendJSON(ctx.res, 200, { active: false });
return true;
case "GET account/sessions": {
const here = thisSessionHash(ctx.account);
const sessions = accountSessions(ctx.account)
.map((s) => (s.token_hash === here ? { ...s, last_seen_at: new Date().toISOString(), current: true } : s))
.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
sendJSON(ctx.res, 200, { sessions });
return true;
}
case "POST account/sessions/revoke-others": {
const here = thisSessionHash(ctx.account);
const before = accountSessions(ctx.account);
ctx.account.sessions = before.filter((s) => s.token_hash === here);
sendJSON(ctx.res, 200, { revoked: before.length - ctx.account.sessions.length });
return true;
}
default:
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "sessions" && ctx.parts[4]) {
const hash = ctx.parts[4];
const list = accountSessions(ctx.account);
if (!list.some((s) => s.token_hash === hash)) {
sendError(ctx.res, 404, "session_not_found", "that session has already ended or is not one of yours");
return true;
}
ctx.account.sessions = list.filter((s) => s.token_hash !== hash);
const signedOut = hash === thisSessionHash(ctx.account);
if (signedOut) clearSessionCookie(ctx.res);
sendJSON(ctx.res, 200, { ok: true, signed_out: signedOut });
return true;
}
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
const id = ctx.parts[5];
const list = ctx.state.passkeys[ctx.account.id] ?? [];
@@ -1013,6 +1090,9 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
return true;
}
list.splice(idx, 1);
// Like the real API, removing a passkey signs the other devices out.
const here = thisSessionHash(ctx.account);
ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here);
ctx.res.statusCode = 204;
ctx.res.end();
return true;
@@ -1285,25 +1365,19 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
// GET /api/v1/users/{id}/sessions
if (is("GET", ctx) && subAction === "sessions") {
if (!acc.sessions) {
acc.sessions = [
{
token_hash: "mock-token-hash-1",
created_at: new Date(Date.now() - 3600000).toISOString(),
expires_at: new Date(Date.now() + 3600000 * 24).toISOString(),
}
];
}
sendJSON(ctx.res, 200, { sessions: acc.sessions });
sendJSON(ctx.res, 200, { sessions: accountSessions(acc) });
return true;
}
// DELETE /api/v1/users/{id}/sessions/{hash} — revoke single session
if (is("DELETE", ctx) && subAction === "sessions" && ctx.parts[5]) {
const hash = ctx.parts[5];
if (acc.sessions) {
acc.sessions = acc.sessions.filter((s) => s.token_hash !== hash);
const list = accountSessions(acc);
if (!list.some((s) => s.token_hash === hash)) {
sendError(ctx.res, 404, "session_not_found", "that session has already ended or does not belong to this user");
return true;
}
acc.sessions = list.filter((s) => s.token_hash !== hash);
sendJSON(ctx.res, 200, { ok: true });
return true;
}
+19 -3
View File
@@ -1,8 +1,24 @@
{
"title": "Account",
"subtitle": "Identity and Minecraft linking.",
"session": "Session",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by a passkey / email sign-in or the platform's identity proxy (Zero-Trust / Access).",
"sessions_title": "Signed-in devices",
"sessions_desc": "Every browser signed in to your account. Sign out any you don't recognize.",
"sessions_staff_idle": "Operator sessions sign out on their own after 30 minutes without activity.",
"loading_sessions": "Loading devices…",
"sessions_empty": "No devices are signed in with a panel session.",
"session_this_device": "This device",
"session_device": "{{browser}} on {{os}}",
"session_device_unknown": "Unknown device",
"session_active_now": "Active now",
"session_active": "Active {{when}}",
"session_signed_in": "Signed in {{when}}",
"session_sign_out_aria": "Sign out {{device}}",
"session_signed_out_device": "Signed out {{device}}.",
"sessions_sign_out_others": "Sign out other devices",
"sessions_sign_out_others_title": "Sign out every other device?",
"sessions_sign_out_others_desc": "Every device except this one is signed out and has to sign in again.",
"sessions_signed_out_others_one": "Signed out {{count}} other device.",
"sessions_signed_out_others_other": "Signed out {{count}} other devices.",
"sign_out": "Sign out",
"signing_out": "Signing out…",
"minecraft_link": "Minecraft link",
@@ -46,7 +62,7 @@
"never": "Never",
"passkey_delete_aria": "Delete passkey “{{name}}”",
"passkey_delete_title": "Delete this passkey?",
"passkey_delete_desc": "“{{name}}” (registered {{created}}) will no longer sign you in. You can register it again later.",
"passkey_delete_desc": "“{{name}}” (registered {{created}}) will no longer sign you in. You can register it again later. Your other devices are signed out too.",
"passkey_delete_confirm": "Delete",
"passkey_last_hint": "This is your only passkey and your email is not verified, so deleting it would lock you out. Verify an email or add another passkey first.",
"migration": "Account migration",
+1 -1
View File
@@ -197,7 +197,7 @@
"users_no_sessions": "No active sessions.",
"users_session_expires": "Expires",
"users_sessions_revoke_all": "Revoke All",
"users_session_revoke_one": "Revoke this session",
"users_session_revoke_device": "Revoke the session on {{device}}",
"users_sessions_revoked": "All sessions revoked.",
"users_session_revoke_one_dlg_title": "Revoke Session",
"users_session_revoke_one_dlg_desc": "Are you sure you want to revoke this session? The user will be logged out from this device immediately.",
@@ -29,6 +29,7 @@
"forbidden": "You are not allowed to do that.",
"self_protected": "You can't do that to the account you're signed in with.",
"owner_protected": "The owner account can't be demoted, disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.",
"session_not_found": "That session has already ended.",
"generic": "Something went wrong.",
"otp_resend_cooldown": "Verification code requested too frequently, please try again later.",
"otp_locked": "Too many incorrect attempts, please request a new verification code.",
+18 -3
View File
@@ -1,8 +1,23 @@
{
"title": "账户",
"subtitle": "身份验证与 Minecraft 关联。",
"session": "会话",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由 Passkey / 邮箱登录还是平台身份代理(Zero-Trust / Access)签发。",
"sessions_title": "已登录的设备",
"sessions_desc": "所有登录了你账号的浏览器。发现不认识的设备,请立即让它退出。",
"sessions_staff_idle": "运维账号的会话连续 30 分钟没有操作会自动退出。",
"loading_sessions": "正在加载设备…",
"sessions_empty": "没有设备以面板会话登录。",
"session_this_device": "本设备",
"session_device": "{{os}} 上的 {{browser}}",
"session_device_unknown": "未知设备",
"session_active_now": "正在使用",
"session_active": "{{when}}活跃",
"session_signed_in": "{{when}}登录",
"session_sign_out_aria": "让 {{device}} 退出登录",
"session_signed_out_device": "{{device}} 已退出登录。",
"sessions_sign_out_others": "退出其它设备",
"sessions_sign_out_others_title": "让其它所有设备退出登录?",
"sessions_sign_out_others_desc": "除本设备外,其它设备都会退出登录,需要重新登录才能继续使用。",
"sessions_signed_out_others_other": "已让 {{count}} 台其它设备退出登录。",
"sign_out": "退出登录",
"signing_out": "退出中…",
"minecraft_link": "Minecraft 关联",
@@ -46,7 +61,7 @@
"never": "从未",
"passkey_delete_aria": "删除 Passkey「{{name}}」",
"passkey_delete_title": "删除这个 Passkey?",
"passkey_delete_desc": "「{{name}}」(注册于 {{created}})删除后无法再用它登录,需要时可以重新注册。",
"passkey_delete_desc": "「{{name}}」(注册于 {{created}})删除后无法再用它登录,需要时可以重新注册。其它设备上的登录也会一并退出。",
"passkey_delete_confirm": "删除",
"passkey_last_hint": "这是你唯一的 Passkey,邮箱也还没验证,删掉就没法登录了。先验证邮箱或再注册一个 Passkey,才能删除它。",
"migration": "账户迁移",
+1 -1
View File
@@ -197,7 +197,7 @@
"users_no_sessions": "无活跃会话。",
"users_session_expires": "过期时间",
"users_sessions_revoke_all": "全部撤销",
"users_session_revoke_one": "单独撤销",
"users_session_revoke_device": "吊销 {{device}} 上的会话",
"users_sessions_revoked": "所有会话已撤销。",
"users_session_revoke_one_dlg_title": "撤销会话",
"users_session_revoke_one_dlg_desc": "确定撤销此会话吗?用户将立即从该设备登出。",
@@ -29,6 +29,7 @@
"forbidden": "你无权执行此操作。",
"self_protected": "不能对当前登录的账号执行此操作。",
"owner_protected": "所有者账号不能在面板里降级、禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
"session_not_found": "这个会话已经结束了。",
"generic": "出了点问题,请稍后重试。",
"otp_resend_cooldown": "验证码发送频繁,请稍后再试。",
"otp_locked": "验证码错误次数过多,请重新获取验证码。",
+40
View File
@@ -852,6 +852,46 @@ describe("path parameters", () => {
});
});
describe("the caller's own sessions", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
function call(spy: typeof fetch, i = 0): [string, string | undefined] {
const [url, opts] = (spy as unknown as ReturnType<typeof vi.fn>).mock.calls[i];
return [String(url), (opts as RequestInit).method];
}
it("lists, revokes one and revokes the rest at /account/sessions", async () => {
const row = {
token_hash: "h1",
created_at: "2026-09-01T00:00:00Z",
expires_at: "2026-10-01T00:00:00Z",
last_seen_at: "2026-09-24T00:00:00Z",
user_agent: "UA",
client_ip: "192.0.2.1",
current: true,
};
let spy = fakeFetch({ sessions: [row] });
vi.stubGlobal("fetch", spy);
expect(await api.listMySessions()).toEqual([row]);
expect(call(spy)).toEqual(["/account/sessions", "GET"]);
spy = fakeFetch({ ok: true, signed_out: false });
vi.stubGlobal("fetch", spy);
expect(await api.revokeMySession("a/b")).toEqual({ ok: true, signed_out: false });
expect(call(spy)).toEqual(["/account/sessions/a%2Fb", "DELETE"]);
spy = fakeFetch({ revoked: 2 });
vi.stubGlobal("fetch", spy);
expect(await api.revokeMyOtherSessions()).toEqual({ revoked: 2 });
expect(call(spy)).toEqual(["/account/sessions/revoke-others", "POST"]);
});
it("says a session that is already gone has ended", () => {
expect(humanizeError({ status: 404, code: "session_not_found" })).toBe("That session has already ended.");
});
});
describe("responses that are not the API's JSON", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
+13
View File
@@ -579,6 +579,17 @@ export const api = rejectingSync({
passkeyDelete: (id: string) =>
request<void>("DELETE", urlPath`/account/passkey/credentials/${id}`),
// The caller's own sessions: every browser signed in to the account, the one
// making the request marked current. Revoking the current one is a sign-out.
listMySessions: () =>
request<{ sessions: SessionView[] }>("GET", "/account/sessions").then((r) => r.sessions ?? []),
revokeMySession: (hash: string) =>
request<{ ok: boolean; signed_out: boolean }>("DELETE", urlPath`/account/sessions/${hash}`),
revokeMyOtherSessions: () =>
request<{ revoked: number }>("POST", "/account/sessions/revoke-others"),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
// otherwise) → issue-code (source names the target account and reads the one-time
@@ -828,6 +839,8 @@ export function humanizeError(e: unknown): string {
return t("self_protected");
case "owner_protected":
return t("owner_protected");
case "session_not_found":
return t("session_not_found");
case "quota_exceeded":
return t("quota_exceeded");
case "already_claimed":
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import i18next from "i18next";
import { deviceLabel, guessDevice } from "./device";
// Real User-Agent strings, as the browsers send them.
const UA = {
chromeWindows:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
edgeWindows:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2739.42",
firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
safariMac:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
safariIphone:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
chromeIphone:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.98 Mobile/15E148 Safari/604.1",
chromeAndroid:
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36",
samsungAndroid:
"Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36",
operaMac:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/113.0.0.0",
chromebook:
"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
};
describe("guessDevice", () => {
it.each([
["chromeWindows", "Chrome", "Windows", false],
["edgeWindows", "Edge", "Windows", false],
["firefoxLinux", "Firefox", "Linux", false],
["safariMac", "Safari", "macOS", false],
["safariIphone", "Safari", "iPhone", true],
["chromeIphone", "Chrome", "iPhone", true],
["chromeAndroid", "Chrome", "Android", true],
["samsungAndroid", "Samsung Internet", "Android", true],
["operaMac", "Opera", "macOS", false],
["chromebook", "Chrome", "ChromeOS", false],
] as const)("names %s", (key, browser, os, mobile) => {
expect(guessDevice(UA[key])).toEqual({ browser, os, mobile });
});
it("leaves what it cannot tell as null", () => {
expect(guessDevice("curl/8.9.1")).toEqual({ browser: null, os: null, mobile: false });
expect(guessDevice("")).toEqual({ browser: null, os: null, mobile: false });
});
});
describe("deviceLabel", () => {
const t = i18next.t.bind(i18next);
it("names the browser and the system when both show", () => {
expect(deviceLabel(UA.edgeWindows, t)).toBe("Edge on Windows");
expect(deviceLabel(UA.safariIphone, t)).toBe("Safari on iPhone");
});
it("falls back to whichever half it can tell, then to a generic name", () => {
expect(deviceLabel("Mozilla/5.0 (Linux x86_64) okhttp/4.12", t)).toBe("Linux");
expect(deviceLabel("Mozilla/5.0 Firefox/130.0", t)).toBe("Firefox");
expect(deviceLabel("curl/8.9.1", t)).toBe("Unknown device");
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { TFunction } from "i18next";
/** A signed-in device as its owner would name it, read from the User-Agent the
* browser sent at sign-in. A part the string does not reveal stays null so the
* caller can fall back to a generic label. */
export interface DeviceGuess {
browser: string | null;
os: string | null;
mobile: boolean;
}
// First match wins. Edge, Opera and Samsung Internet also claim Chrome, and
// Chrome claims Safari, so the specific names come first.
const BROWSERS: [RegExp, string][] = [
[/\bEdg(?:e|A|iOS)?\//, "Edge"],
[/\b(?:OPR|Opera)\//, "Opera"],
[/\bSamsungBrowser\//, "Samsung Internet"],
[/\b(?:Firefox|FxiOS)\//, "Firefox"],
[/\b(?:Chrome|CriOS)\//, "Chrome"],
[/\bVersion\/[\d.]+.*\bSafari\//, "Safari"],
];
// iPhones say "like Mac OS X" and Android says Linux, so they come first.
const SYSTEMS: [RegExp, string][] = [
[/\b(?:iPhone|iPod)\b/, "iPhone"],
[/\biPad\b/, "iPad"],
[/\bAndroid\b/, "Android"],
[/\bCrOS\b/, "ChromeOS"],
[/\bWindows\b/, "Windows"],
[/\bMacintosh\b|\bMac OS X\b/, "macOS"],
[/\bLinux\b/, "Linux"],
];
function first(table: [RegExp, string][], ua: string): string | null {
return table.find(([re]) => re.test(ua))?.[1] ?? null;
}
export function guessDevice(userAgent: string): DeviceGuess {
return {
browser: first(BROWSERS, userAgent),
os: first(SYSTEMS, userAgent),
mobile: /\b(?:Mobile|iPhone|iPod|Android)\b/.test(userAgent),
};
}
/** deviceLabel names a session's device for a list row: "Chrome on Windows",
* or whichever half the User-Agent reveals, or "Unknown device". */
export function deviceLabel(userAgent: string, t: TFunction): string {
const { browser, os } = guessDevice(userAgent);
if (browser && os) return t("account:session_device", { browser, os });
return browser ?? os ?? t("account:session_device_unknown");
}
+166 -4
View File
@@ -1383,7 +1383,7 @@ export interface paths {
put?: never;
/**
* Redeem an email one-time code and mark the caller's email verified (spec §B2).
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
*/
post: operations["emailOtpVerify"];
delete?: never;
@@ -1484,7 +1484,7 @@ export interface paths {
post?: never;
/**
* Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in.
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it.
*/
delete: operations["passkeyDelete"];
options?: never;
@@ -1492,6 +1492,63 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/account/sessions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List the caller's own live sessions, marking the one this request came in on.
* @description Every device signed in to the caller's account, most recently seen first. A caller signed in through Cloudflare Access has no session of its own, so no entry is marked current.
*/
get: operations["listMySessions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/sessions/{hash}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Sign out one of the caller's sessions.
* @description Scoped to the caller: a hash that is not one of the caller's live sessions is a 404 whoever it belongs to. Revoking the session the request came in on is a sign-out; the cookie is cleared and signed_out is true.
*/
delete: operations["revokeMySession"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/sessions/revoke-others": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Sign out every session of the caller except the one making this request. */
post: operations["revokeMyOtherSessions"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/migrate": {
parameters: {
query?: never;
@@ -2185,18 +2242,30 @@ export interface components {
max_memory_mb?: number | null;
max_storage_gb?: number | null;
};
/** @description One live session of a user visible to an admin (internal/api/repo.go SessionView). */
/** @description One live session, as the account holder and an admin see it (internal/api/repo.go SessionView). */
SessionView: {
/** @description The sha-256 of the session cookie; the id the revoke routes take. */
token_hash: string;
/** Format: date-time */
created_at: string;
/** Format: date-time */
expires_at: string;
/**
* Format: date-time
* @description When the session last authenticated a request, recorded at most once a minute. A staff session idle for 30 minutes stops authenticating and leaves the list.
*/
last_seen_at: string;
/** @description The browser's User-Agent at sign-in (at most 256 bytes; empty when none was sent). */
user_agent: string;
/** @description The address the sign-in came from (empty when unknown). */
client_ip: string;
/**
* Format: date-time
* @description Present only once the session is revoked.
*/
revoked_at?: string;
/** @description On the holder's own list only, true on the session the request came in on. Absent otherwise. */
current?: boolean;
};
};
responses: {
@@ -5381,7 +5450,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description Live (unrevoked, unexpired) sessions, newest first. */
/** @description Live sessions, most recently seen first. */
200: {
headers: {
[name: string]: unknown;
@@ -5449,6 +5518,15 @@ export interface operations {
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description session_not_found — the hash is not a live session of this user (another user's, already ended, or unknown). Nothing is revoked. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
unbindUserPasskeys: {
@@ -5939,6 +6017,90 @@ export interface operations {
};
};
};
listMySessions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The caller's live sessions. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
sessions: components["schemas"]["SessionView"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
revokeMySession: {
parameters: {
query?: never;
header?: never;
path: {
hash: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Session revoked. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @constant */
ok: true;
/** @description True when the revoked session was the caller's own, which is now signed out. */
signed_out: boolean;
};
};
};
401: components["responses"]["Unauthorized"];
/** @description session_not_found — not a live session of the caller. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
revokeMyOtherSessions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Other sessions revoked. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @description How many sessions were signed out. */
revoked: number;
};
};
};
401: components["responses"]["Unauthorized"];
};
};
migrateStatus: {
parameters: {
query?: never;
+8
View File
@@ -427,5 +427,13 @@ export interface SessionView {
token_hash: string;
created_at: string;
expires_at: string;
/** When the session last authenticated a request (recorded at most once a minute). */
last_seen_at: string;
/** The browser's User-Agent at sign-in; empty when none was sent. */
user_agent: string;
/** The address the sign-in came from; empty when unknown. */
client_ip: string;
revoked_at?: string;
/** On the holder's own list only: the session this request came in on. */
current?: boolean;
}
+41 -1
View File
@@ -4,12 +4,13 @@ import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import i18next from "i18next";
import type { Identity, PasskeyCredential } from "@/lib/types";
import type { Identity, PasskeyCredential, SessionView } from "@/lib/types";
import { Account } from "./Account";
const mocks = vi.hoisted(() => ({
passkeyList: vi.fn(),
passkeyDelete: vi.fn(),
listMySessions: vi.fn(),
identity: null as Identity | null,
}));
@@ -23,6 +24,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
migrateStatus: () => Promise.resolve({ active: false }),
passkeyList: mocks.passkeyList,
passkeyDelete: mocks.passkeyDelete,
listMySessions: mocks.listMySessions,
},
};
});
@@ -36,6 +38,13 @@ const deleteButton = (name: string) => ({ name: t("account:passkey_delete_aria",
const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
function session(hash: string, userAgent: string, current?: boolean): SessionView {
const now = new Date().toISOString();
return { token_hash: hash, created_at: now, expires_at: now, last_seen_at: now, user_agent: userAgent, client_ip: "", current };
}
const thisMac = session("h-mac", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/128.0.0.0 Safari/537.36", true);
const otherPC = session("h-pc", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/130.0");
function identity(emailVerified: boolean): Identity {
return {
user_id: "u-1",
@@ -58,6 +67,8 @@ function renderAccount() {
beforeEach(() => {
mocks.passkeyList.mockReset();
mocks.passkeyDelete.mockReset();
mocks.listMySessions.mockReset();
mocks.listMySessions.mockResolvedValue([thisMac]);
mocks.identity = identity(true);
});
@@ -137,4 +148,33 @@ describe("Account passkey delete", () => {
expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
expect(screen.queryByText("passkey not found")).toBeNull();
});
it("refreshes the device list, since removing a passkey signs the other devices out", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockResolvedValue(undefined);
mocks.listMySessions.mockReset();
mocks.listMySessions.mockResolvedValueOnce([thisMac, otherPC]).mockResolvedValue([thisMac]);
renderAccount();
expect(await screen.findByText("Firefox on Windows")).toBeTruthy();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
expect(within(screen.getByRole("dialog")).getByText(/Your other devices are signed out too\./)).toBeTruthy();
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
await waitFor(() => expect(screen.queryByText("Firefox on Windows")).toBeNull());
expect(screen.getByText("Chrome on macOS")).toBeTruthy();
expect(mocks.listMySessions).toHaveBeenCalledTimes(2);
});
it("leaves the device list alone when the removal is refused", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue({ status: 409, code: "last_passkey", message: "raw" });
renderAccount();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
await within(screen.getByRole("dialog")).findByRole("alert");
expect(mocks.listMySessions).toHaveBeenCalledTimes(1);
});
});
+11 -20
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type FormEvent } from "react";
import { ArrowRightLeft, CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { ArrowRightLeft, CheckCircle2, Link2, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -13,6 +13,7 @@ import { api, clientError, humanizeError } from "@/lib/api";
import { formatAbsolute } from "@/lib/format";
import type { PasskeyCredential } from "@/lib/types";
import { useAsync } from "@/lib/hooks";
import { AccountSessionsCard } from "@/pages/AccountSessions";
import { useTier } from "@/lib/tier";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import {
@@ -102,6 +103,7 @@ export function Account() {
// (it would be left with no way back in); the button mirrors that rule so the
// refusal is explained up front instead of after a round trip.
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(null);
const [sessionsVersion, setSessionsVersion] = useState(0);
const [deletingPasskey, setDeletingPasskey] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const credentials = passkeys.data?.credentials ?? [];
@@ -189,6 +191,8 @@ export function Account() {
try {
await api.passkeyDelete(pendingDelete.id);
setPendingDelete(null);
// The server signed the other devices out along with the passkey.
setSessionsVersion((v) => v + 1);
await passkeys.reload();
} catch (err) {
// Another device may have changed the list meanwhile: refresh it. A 404
@@ -508,25 +512,12 @@ export function Account() {
hasPasskey={credentials.length > 0}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4 text-primary" /> {t("session")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground">
<p>{t("session_desc")}</p>
<Button
variant="outline"
size="sm"
onClick={signOut}
disabled={signingOut}
>
<LogOut className="mr-2 h-4 w-4" />
{signingOut ? t("signing_out") : t("sign_out")}
</Button>
</CardContent>
</Card>
<AccountSessionsCard
version={sessionsVersion}
staff={identity !== null && identity.role !== "user"}
onSignOut={() => void signOut()}
signingOut={signingOut}
/>
</>
);
}
+208
View File
@@ -0,0 +1,208 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { SessionView } from "@/lib/types";
import { AccountSessionsCard } from "./AccountSessions";
const mocks = vi.hoisted(() => ({
listMySessions: vi.fn(),
revokeMySession: vi.fn(),
revokeMyOtherSessions: vi.fn(),
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
listMySessions: mocks.listMySessions,
revokeMySession: mocks.revokeMySession,
revokeMyOtherSessions: mocks.revokeMyOtherSessions,
},
};
});
const HOUR = 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function session(hash: string, userAgent: string, seenAgo: number, extra: Partial<SessionView> = {}): SessionView {
return {
token_hash: hash,
created_at: ago(3 * 24 * HOUR),
expires_at: new Date(Date.now() + 24 * HOUR).toISOString(),
last_seen_at: ago(seenAgo),
user_agent: userAgent,
client_ip: "",
...extra,
};
}
const mac = session(
"h-mac",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
// Whatever time is stored, the device reading the list is in use right now.
10 * 60_000,
{ current: true, client_ip: "2001:db8::1" },
);
const iphone = session(
"h-iphone",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
20 * HOUR,
{ client_ip: "203.0.113.24" },
);
const windows = session(
"h-windows",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2739.42",
60_000,
);
function renderCard(props: Partial<Parameters<typeof AccountSessionsCard>[0]> = {}) {
const onSignOut = vi.fn();
render(<AccountSessionsCard version={0} staff={false} onSignOut={onSignOut} signingOut={false} {...props} />);
return { onSignOut };
}
const row = (device: string) => screen.getByText(device).closest("li") as HTMLElement;
beforeEach(() => {
mocks.listMySessions.mockReset();
mocks.revokeMySession.mockReset();
mocks.revokeMyOtherSessions.mockReset();
});
describe("AccountSessionsCard", () => {
it("names each device and marks the one in use", async () => {
mocks.listMySessions.mockResolvedValue([mac, iphone, windows]);
renderCard();
await screen.findByText("Chrome on macOS");
const here = row("Chrome on macOS");
expect(within(here).getByText("This device")).toBeTruthy();
expect(within(here).getByText("Active now")).toBeTruthy();
expect(within(here).getByText("2001:db8::1")).toBeTruthy();
expect(within(here).getByText("Signed in 3 days ago")).toBeTruthy();
expect(within(here).queryByRole("button")).toBeNull();
const phone = row("Safari on iPhone");
expect(within(phone).queryByText("This device")).toBeNull();
expect(within(phone).getByText("Active 20 hours ago")).toBeTruthy();
expect(within(phone).getByText("203.0.113.24")).toBeTruthy();
expect(within(phone).getByRole("button", { name: "Sign out Safari on iPhone" })).toBeTruthy();
// Seen a minute ago: the server records activity once a minute, so that is now.
expect(within(row("Edge on Windows")).getByText("Active now")).toBeTruthy();
expect(screen.queryByText("Operator sessions sign out on their own after 30 minutes without activity.")).toBeNull();
});
it("tells an operator that their sessions idle out", async () => {
mocks.listMySessions.mockResolvedValue([mac]);
renderCard({ staff: true });
expect(
await screen.findByText("Operator sessions sign out on their own after 30 minutes without activity."),
).toBeTruthy();
});
it("signs one device out and drops it from the list", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
mocks.revokeMySession.mockResolvedValue({ ok: true, signed_out: false });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out Safari on iPhone" }));
expect(mocks.revokeMySession).toHaveBeenCalledWith("h-iphone");
expect((await screen.findByRole("status")).textContent).toBe("Signed out Safari on iPhone.");
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
expect(mocks.listMySessions).toHaveBeenCalledTimes(2);
});
it("counts a session that had already ended as signed out", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
mocks.revokeMySession.mockRejectedValue({ status: 404, code: "session_not_found", message: "gone" });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out Safari on iPhone" }));
expect((await screen.findByRole("status")).textContent).toBe("Signed out Safari on iPhone.");
expect(screen.queryByRole("alert")).toBeNull();
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
});
it("says why signing a device out failed and keeps it listed", async () => {
mocks.listMySessions.mockResolvedValue([mac, iphone]);
mocks.revokeMySession.mockRejectedValue({ status: 403, code: "self_protected", message: "no" });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out Safari on iPhone" }));
expect((await screen.findByRole("alert")).textContent).toBe(
"You can't do that to the account you're signed in with.",
);
expect(screen.queryByRole("status")).toBeNull();
await waitFor(() => expect(mocks.listMySessions).toHaveBeenCalledTimes(2));
expect(screen.getByText("Safari on iPhone")).toBeTruthy();
});
it("signs every other device out after a confirmation", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone, windows]).mockResolvedValue([mac]);
mocks.revokeMyOtherSessions.mockResolvedValue({ revoked: 2 });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out other devices" }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Sign out every other device?")).toBeTruthy();
expect(mocks.revokeMyOtherSessions).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Sign out other devices" }));
expect(mocks.revokeMyOtherSessions).toHaveBeenCalledTimes(1);
expect((await screen.findByRole("status")).textContent).toBe("Signed out 2 other devices.");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
expect(screen.getByText("Chrome on macOS")).toBeTruthy();
});
it("uses the singular for one device", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
mocks.revokeMyOtherSessions.mockResolvedValue({ revoked: 1 });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out other devices" }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Sign out other devices" }));
expect((await screen.findByRole("status")).textContent).toBe("Signed out 1 other device.");
});
it("has nothing to sign out elsewhere when this is the only device", async () => {
mocks.listMySessions.mockResolvedValue([mac]);
renderCard();
await screen.findByText("Chrome on macOS");
expect(screen.getByRole("button", { name: "Sign out other devices" }).hasAttribute("disabled")).toBe(true);
});
it("signs this device out through the page's sign-out", async () => {
mocks.listMySessions.mockResolvedValue([mac, iphone]);
const { onSignOut } = renderCard();
await screen.findByText("Chrome on macOS");
await userEvent.click(screen.getByRole("button", { name: "Sign out" }));
expect(onSignOut).toHaveBeenCalledTimes(1);
expect(mocks.revokeMySession).not.toHaveBeenCalled();
});
it("reloads when the page says the server changed the list", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
const onSignOut = vi.fn();
const { rerender } = render(
<AccountSessionsCard version={0} staff={false} onSignOut={onSignOut} signingOut={false} />,
);
await screen.findByText("Safari on iPhone");
rerender(<AccountSessionsCard version={1} staff={false} onSignOut={onSignOut} signingOut={false} />);
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
expect(mocks.listMySessions).toHaveBeenCalledTimes(2);
});
});
+207
View File
@@ -0,0 +1,207 @@
import { useState } from "react";
import { Loader2, LogOut, Monitor, MonitorSmartphone, Smartphone } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loading, ErrorState } from "@/components/States";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { deviceLabel, guessDevice } from "@/lib/device";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { useAsync } from "@/lib/hooks";
import type { SessionView } from "@/lib/types";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
// The server records activity at most once a minute, so a device seen within
// two minutes is as live as the one reading this page.
const ACTIVE_NOW_MS = 2 * 60_000;
interface Props {
/** Bumped by the page after a change that signs other devices out on the
* server (removing a passkey), so the list reloads. */
version: number;
/** Operator accounts: their sessions idle out, which the card says. */
staff: boolean;
onSignOut: () => void;
signingOut: boolean;
}
/** AccountSessionsCard lists every browser signed in to the caller's account
* and signs any of them out. This device can only leave through the ordinary
* sign-out, which also drops the cookie and returns to the login page. */
export function AccountSessionsCard({ version, staff, onSignOut, signingOut }: Props) {
const { t, i18n } = useTranslation("account");
const sessions = useAsync(() => api.listMySessions(), [version]);
const [revoking, setRevoking] = useState<string | null>(null);
const [confirmOthers, setConfirmOthers] = useState(false);
const [revokingOthers, setRevokingOthers] = useState(false);
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
const list = sessions.data ?? [];
const others = list.filter((s) => !s.current);
const now = Date.now();
const locale = i18n.language;
async function revoke(s: SessionView) {
if (revoking) return;
const device = deviceLabel(s.user_agent, t);
setRevoking(s.token_hash);
setMessage(null);
try {
await api.revokeMySession(s.token_hash);
setMessage({ kind: "success", text: t("session_signed_out_device", { device }) });
} catch (err) {
// Already over (it expired, or another tab got there first) is what was asked.
if ((err as { code?: string }).code === "session_not_found") {
setMessage({ kind: "success", text: t("session_signed_out_device", { device }) });
} else {
setMessage({ kind: "error", text: humanizeError(err) });
}
} finally {
setRevoking(null);
sessions.reload();
}
}
async function revokeOthers() {
if (revokingOthers) return;
setRevokingOthers(true);
setMessage(null);
try {
const { revoked } = await api.revokeMyOtherSessions();
setConfirmOthers(false);
setMessage({ kind: "success", text: t("sessions_signed_out_others", { count: revoked }) });
} catch (err) {
setConfirmOthers(false);
setMessage({ kind: "error", text: humanizeError(err) });
} finally {
setRevokingOthers(false);
sessions.reload();
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MonitorSmartphone className="h-4 w-4 text-primary" /> {t("sessions_title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="space-y-1 text-muted-foreground">
<p>{t("sessions_desc")}</p>
{staff && <p className="text-xs">{t("sessions_staff_idle")}</p>}
</div>
{message && <MessageLine kind={message.kind} message={message.text} />}
{sessions.loading && !sessions.data ? (
<Loading label={t("loading_sessions")} />
) : sessions.error && !sessions.data ? (
<ErrorState error={sessions.error} onRetry={sessions.reload} />
) : list.length === 0 ? (
<p className="py-2 text-xs italic text-muted-foreground">{t("sessions_empty")}</p>
) : (
<ul className="divide-y rounded-md border bg-background/50">
{list.map((s) => {
const device = deviceLabel(s.user_agent, t);
const Icon = guessDevice(s.user_agent).mobile ? Smartphone : Monitor;
const seen = new Date(s.last_seen_at).getTime();
const activeNow = s.current || now - seen < ACTIVE_NOW_MS;
return (
<li key={s.token_hash} className="flex items-center justify-between gap-3 p-3">
<div className="flex min-w-0 items-start gap-3">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 space-y-1">
<p className="flex flex-wrap items-center gap-2 font-medium text-foreground">
<span className="truncate" title={s.user_agent || undefined}>
{device}
</span>
{s.current && <Badge>{t("session_this_device")}</Badge>}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span
className={activeNow ? "text-emerald-500" : undefined}
title={formatAbsolute(s.last_seen_at, locale)}
>
{activeNow
? t("session_active_now")
: t("session_active", { when: formatRelative(s.last_seen_at, now, locale) })}
</span>
{s.client_ip && <span className="font-mono">{s.client_ip}</span>}
<span title={formatAbsolute(s.created_at, locale)}>
{t("session_signed_in", { when: formatRelative(s.created_at, now, locale) })}
</span>
</div>
</div>
</div>
{!s.current && (
<Button
size="sm"
variant="ghost"
onClick={() => void revoke(s)}
disabled={revoking !== null || revokingOthers}
aria-label={t("session_sign_out_aria", { device })}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
{revoking === s.token_hash ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="h-4 w-4" />
)}
<span className="hidden sm:inline">{t("sign_out")}</span>
</Button>
)}
</li>
);
})}
</ul>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setMessage(null);
setConfirmOthers(true);
}}
disabled={others.length === 0 || revoking !== null}
>
<MonitorSmartphone className="mr-2 h-4 w-4" />
{t("sessions_sign_out_others")}
</Button>
<Button variant="outline" size="sm" onClick={onSignOut} disabled={signingOut}>
<LogOut className="mr-2 h-4 w-4" />
{signingOut ? t("signing_out") : t("sign_out")}
</Button>
</div>
<Dialog
open={confirmOthers}
onOpenChange={(open) => {
if (!open && !revokingOthers) setConfirmOthers(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("sessions_sign_out_others_title")}</DialogTitle>
<DialogDescription>{t("sessions_sign_out_others_desc")}</DialogDescription>
</DialogHeader>
<ConfirmFooter
onCancel={() => setConfirmOthers(false)}
onConfirm={() => void revokeOthers()}
loading={revokingOthers}
cancelLabel={t("common:cancel")}
confirmLabel={t("sessions_sign_out_others")}
/>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}
+89 -2
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { UserDetailPage } from "./UserDetailPage";
@@ -10,6 +11,8 @@ const calls = vi.hoisted(() => ({
getUser: vi.fn(),
getUserQuotas: vi.fn(),
listUserSessions: vi.fn(),
revokeUserSession: vi.fn(),
revokeUserSessions: vi.fn(),
}));
vi.mock("@/lib/tier", () => ({
useTier: () => ({ loading: false, identity: { user_id: "owner-1", role: "owner" }, isAdmin: true, isOwner: true }),
@@ -76,7 +79,14 @@ describe("UserDetailPage", () => {
it("names the auth source and writes dates in the UI language", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([
{ token_hash: "abcdef0123456789abcdef0123456789", created_at: VERIFIED, expires_at: EXPIRES },
{
token_hash: "abcdef0123456789abcdef0123456789",
created_at: VERIFIED,
expires_at: EXPIRES,
last_seen_at: VERIFIED,
user_agent: "",
client_ip: "",
},
]);
await i18next.changeLanguage("zh-CN");
renderPage();
@@ -89,6 +99,83 @@ describe("UserDetailPage", () => {
expect(await screen.findByText(zhExpires, { exact: false })).toBeTruthy();
});
describe("sessions card", () => {
const seen = new Date(Date.now() - 2 * 3_600_000).toISOString();
const phone = {
token_hash: "h-phone",
created_at: VERIFIED,
expires_at: EXPIRES,
last_seen_at: seen,
user_agent:
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36",
client_ip: "198.51.100.9",
};
const pc = { ...phone, token_hash: "h-pc", user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Firefox/130.0", client_ip: "" };
it("names each device with its address and last activity", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([phone, pc]);
renderPage();
const label = await screen.findByText("Chrome on Android");
const row = label.closest("div.rounded-md") as HTMLElement;
expect(label.getAttribute("title")).toBe(phone.user_agent);
expect(within(row).getByText("198.51.100.9")).toBeTruthy();
expect(within(row).getByText("Active 2 hours ago")).toBeTruthy();
expect(screen.getByText("Firefox on Linux")).toBeTruthy();
expect(screen.getByRole("button", { name: "Revoke the session on Chrome on Android" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Revoke the session on Firefox on Linux" })).toBeTruthy();
});
it("closes the confirmation and refreshes when the session had already ended", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValueOnce([phone, pc]).mockResolvedValue([pc]);
calls.revokeUserSession.mockRejectedValue({ status: 404, code: "session_not_found", message: "gone" });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Revoke the session on Chrome on Android" }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Revoke" }));
expect(calls.revokeUserSession).toHaveBeenCalledWith("u-1", "h-phone");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.queryByText("Chrome on Android")).toBeNull());
expect(screen.queryByText("That session has already ended.")).toBeNull();
});
it("shows why a revoke failed for any other reason", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([phone]);
calls.revokeUserSession.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Revoke the session on Chrome on Android" }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Revoke" }));
// Inside the dialog: the modal hides the card behind it.
const dialog = screen.getByRole("dialog");
expect((await within(dialog).findByRole("alert")).textContent).toBe("backend refused");
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(screen.getByText("Chrome on Android")).toBeTruthy();
// Asking again starts clean, without the last attempt's failure.
await userEvent.click(screen.getByRole("button", { name: "Revoke the session on Chrome on Android" }));
expect(within(screen.getByRole("dialog")).queryByRole("alert")).toBeNull();
});
it("shows why revoking every session failed inside the confirmation", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([phone]);
calls.revokeUserSessions.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Revoke All" }));
const dialog = screen.getByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Revoke" }));
expect((await within(dialog).findByRole("alert")).textContent).toBe("backend refused");
});
});
describe("danger zone for accounts the server protects", () => {
const SELF_REASON = "You can't disable or delete the account you're signed in with.";
const OWNER_REASON =
+70 -21
View File
@@ -48,7 +48,8 @@ import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { formatAbsolute } from "@/lib/format";
import { deviceLabel } from "@/lib/device";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ApiError, UserDetail, SessionView } from "@/lib/types";
@@ -533,6 +534,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
const [revokeAllDlg, setRevokeAllDlg] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const now = Date.now();
async function handleRevokeOne() {
if (!revokeOneDlg) return;
@@ -545,7 +547,14 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
await reload();
onChanged();
} catch (e) {
setErr(humanizeError(e));
// Already over (expired, or the user signed it out meanwhile): the list
// is stale, and the session is gone as asked.
if ((e as { code?: string }).code === "session_not_found") {
setRevokeOneDlg(null);
reload();
} else {
setErr(humanizeError(e));
}
} finally {
setRevoking(null);
}
@@ -580,7 +589,10 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
size="sm"
className="gap-1 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={!sessions || sessions.length === 0 || revokingAll}
onClick={() => setRevokeAllDlg(true)}
onClick={() => {
setErr(null);
setRevokeAllDlg(true);
}}
>
{revokingAll ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -591,9 +603,6 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
</Button>
</CardHeader>
<CardContent>
{err && (
<MessageLine kind="error" message={err} />
)}
{ok && (
<MessageLine kind="success" message={ok} />
)}
@@ -605,18 +614,29 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
<p className="text-sm text-muted-foreground">{t("users_no_sessions")}</p>
) : (
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{sessions.map((s: SessionView) => (
{sessions.map((s: SessionView) => {
const device = deviceLabel(s.user_agent, t);
return (
<div
key={s.token_hash}
className="flex items-center justify-between rounded-md border border-border/50 bg-muted/20 pl-3 pr-1 py-2 text-xs"
>
<div className="min-w-0 flex-1">
<span className="font-mono text-[11px] text-muted-foreground truncate block">
{s.token_hash.slice(0, 20)}...
<span
className="block truncate font-medium text-foreground"
title={s.user_agent || undefined}
>
{device}
</span>
<div className="mt-0.5 text-muted-foreground/70">
<Clock className="inline h-3 w-3 mr-0.5" />
{t("users_session_expires")}: {formatAbsolute(s.expires_at, i18n.language)}
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground/70">
{s.client_ip && <span className="font-mono">{s.client_ip}</span>}
<span title={formatAbsolute(s.last_seen_at, i18n.language)}>
{t("account:session_active", { when: formatRelative(s.last_seen_at, now, i18n.language) })}
</span>
<span>
<Clock className="inline h-3 w-3 mr-0.5" />
{t("users_session_expires")}: {formatAbsolute(s.expires_at, i18n.language)}
</span>
</div>
</div>
<Button
@@ -624,9 +644,12 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 ml-2 mr-0.5"
disabled={revoking === s.token_hash}
onClick={() => setRevokeOneDlg(s.token_hash)}
aria-label={t("users_session_revoke_one")}
title={t("users_session_revoke_one")}
onClick={() => {
setErr(null);
setRevokeOneDlg(s.token_hash);
}}
aria-label={t("users_session_revoke_device", { device })}
title={t("users_session_revoke_device", { device })}
>
{revoking === s.token_hash ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -635,31 +658,57 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
)}
</Button>
</div>
))}
);
})}
</div>
)}
</CardContent>
</Card>
{/* Revoke one confirmation dialog */}
<Dialog open={!!revokeOneDlg} onOpenChange={() => setRevokeOneDlg(null)}>
{/* Revoke one confirmation dialog. A failure is shown inside it: the
modal hides the card behind it. */}
<Dialog
open={!!revokeOneDlg}
onOpenChange={(open) => {
if (!open && !revoking) setRevokeOneDlg(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription>
</DialogHeader>
<ConfirmFooter onCancel={() => setRevokeOneDlg(null)} onConfirm={handleRevokeOne} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} />
{err && <MessageLine kind="error" message={err} />}
<ConfirmFooter
onCancel={() => setRevokeOneDlg(null)}
onConfirm={() => void handleRevokeOne()}
loading={revoking !== null}
cancelLabel={t("common:cancel")}
confirmLabel={t("users_session_revoke_confirm")}
/>
</DialogContent>
</Dialog>
{/* Revoke all confirmation dialog */}
<Dialog open={revokeAllDlg} onOpenChange={setRevokeAllDlg}>
<Dialog
open={revokeAllDlg}
onOpenChange={(open) => {
if (!open && !revokingAll) setRevokeAllDlg(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription>
</DialogHeader>
<ConfirmFooter onCancel={() => setRevokeAllDlg(false)} onConfirm={handleRevokeAll} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} />
{err && <MessageLine kind="error" message={err} />}
<ConfirmFooter
onCancel={() => setRevokeAllDlg(false)}
onConfirm={() => void handleRevokeAll()}
loading={revokingAll}
cancelLabel={t("common:cancel")}
confirmLabel={t("users_session_revoke_confirm")}
/>
</DialogContent>
</Dialog>
</>