feat(api): 会话记录设备与最近活动,账户页可查看并退出任一设备,删除 passkey 或更换邮箱时退出其它设备,staff 会话空闲 30 分钟失效,吊销会话校验所属用户
This commit is contained in:
40 files changed
+2139
-179
No files matched your search
+86
-12
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "账户迁移",
|
||||
|
||||
@@ -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": "验证码错误次数过多,请重新获取验证码。",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user