@podosoft/podokit 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +1 -0
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +2 -0
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.js +3 -1
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +18 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/dot-env.docker.example +2 -0
- package/dist/templates/fullstack/dot-env.example +17 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/package.json +2 -2
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import { APIError } from "better-auth/api";
|
|
3
|
+
import type { OwnSessionRevoker } from "../account-sessions/account-sessions.routes";
|
|
3
4
|
import type { SessionRevoker } from "./admin-sessions.routes";
|
|
4
5
|
|
|
5
6
|
export const ADMIN_SESSION_REVOKE_FAILED = "ADMIN_SESSION_REVOKE_FAILED";
|
|
7
|
+
export const ACCOUNT_SESSION_REVOKE_FAILED = "ACCOUNT_SESSION_REVOKE_FAILED";
|
|
6
8
|
|
|
7
9
|
export interface RevokeUserSessionApi {
|
|
8
10
|
revokeUserSession(input: { body: { sessionToken: string }; headers: Headers }): Promise<unknown>;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
export interface RevokeSessionApi {
|
|
14
|
+
revokeSession(input: { body: { token: string }; headers: Headers }): Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* Revokes through the Better Auth admin endpoint rather than deleting the row, so
|
|
13
19
|
* its permission check and after-hooks (including the audit log) still run.
|
|
@@ -24,3 +30,21 @@ export function createSessionRevoker(api: () => RevokeUserSessionApi): SessionRe
|
|
|
24
30
|
}
|
|
25
31
|
};
|
|
26
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Revokes one of the caller's own sessions through Better Auth's
|
|
36
|
+
* `/revoke-session`, so its ownership check and after-hooks (including the
|
|
37
|
+
* audit log's `session.revoke`) still run.
|
|
38
|
+
*/
|
|
39
|
+
export function createOwnSessionRevoker(api: () => RevokeSessionApi): OwnSessionRevoker {
|
|
40
|
+
return async (token, headers) => {
|
|
41
|
+
try {
|
|
42
|
+
await api().revokeSession({ body: { token }, headers });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error instanceof APIError) {
|
|
45
|
+
throw new AppException(ACCOUNT_SESSION_REVOKE_FAILED, error.message, error.statusCode);
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -183,5 +183,23 @@ describe("AdminSessionsService", () => {
|
|
|
183
183
|
|
|
184
184
|
expect(await service.tokenOf("a")).toBe("token-a");
|
|
185
185
|
expect(await service.tokenOf("missing")).toBeNull();
|
|
186
|
+
// With a user, only that user's session.
|
|
187
|
+
expect(await service.tokenOf("a", "alice")).toBe("token-a");
|
|
188
|
+
expect(await service.tokenOf("a", "bob")).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("leaves one session out and finds an active session by id", async () => {
|
|
192
|
+
await seed(db, [
|
|
193
|
+
{ id: "a", userId: "alice", createdAt: "2026-01-01T00:00:01.000Z" },
|
|
194
|
+
{ id: "b", userId: "alice", createdAt: "2026-01-01T00:00:02.000Z" },
|
|
195
|
+
{ id: "old", userId: "alice", createdAt: "2026-01-01T00:00:03.000Z", expiresAt: "2020-01-01T00:00:00.000Z" },
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const page = await service.list({ userId: "alice", excludeId: "b" });
|
|
199
|
+
expect(page.items.map((item) => item.id)).toEqual(["a"]);
|
|
200
|
+
expect(page.total).toBe(1);
|
|
201
|
+
expect(await service.find("b")).toMatchObject({ id: "b", userId: "alice", ipAddress: "203.0.113.7" });
|
|
202
|
+
expect(await service.find("old")).toBeNull();
|
|
203
|
+
expect(await service.find("missing")).toBeNull();
|
|
186
204
|
});
|
|
187
205
|
});
|
|
@@ -30,6 +30,8 @@ export interface AdminSessionQuery {
|
|
|
30
30
|
cursor?: string;
|
|
31
31
|
search?: string;
|
|
32
32
|
userId?: string;
|
|
33
|
+
/** A session to leave out, such as the caller's own. */
|
|
34
|
+
excludeId?: string;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
interface SessionRow {
|
|
@@ -76,6 +78,20 @@ function decodeCursor(value: string): Cursor {
|
|
|
76
78
|
throw new AppException(ADMIN_SESSION_CURSOR_INVALID, "The sessions cursor is invalid.", 400);
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
function view(row: SessionRow): AdminSessionView {
|
|
82
|
+
return {
|
|
83
|
+
id: row.id,
|
|
84
|
+
userId: row.userId,
|
|
85
|
+
userEmail: row.userEmail,
|
|
86
|
+
userName: row.userName ?? "",
|
|
87
|
+
ipAddress: row.ipAddress,
|
|
88
|
+
userAgent: row.userAgent,
|
|
89
|
+
impersonatedBy: row.impersonatedBy,
|
|
90
|
+
createdAt: iso(row.createdAt),
|
|
91
|
+
expiresAt: iso(row.expiresAt),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
function likePattern(search: string): string {
|
|
80
96
|
return `%${search.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
|
|
81
97
|
}
|
|
@@ -100,6 +116,7 @@ export class AdminSessionsService {
|
|
|
100
116
|
const filters = (): SQL.Query<unknown> => this.sql`
|
|
101
117
|
WHERE s."expiresAt" > ${now}
|
|
102
118
|
${query.userId ? this.sql`AND s."userId" = ${query.userId}` : this.sql``}
|
|
119
|
+
${query.excludeId ? this.sql`AND s."id" <> ${query.excludeId}` : this.sql``}
|
|
103
120
|
${search ? this.searchFilter(likePattern(search)) : this.sql``}
|
|
104
121
|
`;
|
|
105
122
|
const [rows, totals] = await Promise.all([
|
|
@@ -127,17 +144,7 @@ export class AdminSessionsService {
|
|
|
127
144
|
const page = rows.slice(0, limit);
|
|
128
145
|
const last = page.at(-1);
|
|
129
146
|
return {
|
|
130
|
-
items: page.map(
|
|
131
|
-
id: row.id,
|
|
132
|
-
userId: row.userId,
|
|
133
|
-
userEmail: row.userEmail,
|
|
134
|
-
userName: row.userName ?? "",
|
|
135
|
-
ipAddress: row.ipAddress,
|
|
136
|
-
userAgent: row.userAgent,
|
|
137
|
-
impersonatedBy: row.impersonatedBy,
|
|
138
|
-
createdAt: iso(row.createdAt),
|
|
139
|
-
expiresAt: iso(row.expiresAt),
|
|
140
|
-
})),
|
|
147
|
+
items: page.map(view),
|
|
141
148
|
// The cursor keeps the database's own text form so sub-millisecond
|
|
142
149
|
// timestamps cannot skip or repeat rows at a page boundary.
|
|
143
150
|
nextCursor: rows.length > limit && last ? encodeCursor({ createdAt: last.cursorAt, id: last.id }) : null,
|
|
@@ -145,9 +152,25 @@ export class AdminSessionsService {
|
|
|
145
152
|
};
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
|
|
155
|
+
/** One active session, or null when it does not exist or has expired. */
|
|
156
|
+
async find(sessionId: string): Promise<AdminSessionView | null> {
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
const rows = await this.sql<SessionRow[]>`
|
|
159
|
+
SELECT s."id", s."userId", u."email" AS "userEmail", u."name" AS "userName",
|
|
160
|
+
s."ipAddress", s."userAgent", s."impersonatedBy", s."createdAt", s."expiresAt",
|
|
161
|
+
CAST(s."createdAt" AS TEXT) AS "cursorAt"
|
|
162
|
+
FROM "session" s
|
|
163
|
+
JOIN "user" u ON u."id" = s."userId"
|
|
164
|
+
WHERE s."id" = ${sessionId} AND s."expiresAt" > ${now}
|
|
165
|
+
`;
|
|
166
|
+
return rows[0] ? view(rows[0]) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The token of a session, only when `userId` is given and owns it. */
|
|
170
|
+
async tokenOf(sessionId: string, userId?: string): Promise<string | null> {
|
|
149
171
|
const rows = await this.sql<Array<{ token: string }>>`
|
|
150
|
-
SELECT "token" FROM "session"
|
|
172
|
+
SELECT "token" FROM "session"
|
|
173
|
+
WHERE "id" = ${sessionId} ${userId === undefined ? this.sql`` : this.sql`AND "userId" = ${userId}`}
|
|
151
174
|
`;
|
|
152
175
|
return rows[0]?.token ?? null;
|
|
153
176
|
}
|
|
@@ -5,8 +5,14 @@ import {
|
|
|
5
5
|
} from "@podosoft/podokit-contracts";
|
|
6
6
|
import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
|
|
7
7
|
import { Elysia, t } from "elysia";
|
|
8
|
+
import { accountSessionRoutes } from "./account-sessions/account-sessions.routes";
|
|
8
9
|
import { adminSessionRoutes } from "./admin-sessions/admin-sessions.routes";
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createOwnSessionRevoker,
|
|
12
|
+
createSessionRevoker,
|
|
13
|
+
type RevokeSessionApi,
|
|
14
|
+
type RevokeUserSessionApi,
|
|
15
|
+
} from "./admin-sessions/admin-sessions.revoker";
|
|
10
16
|
import { AdminSessionsService } from "./admin-sessions/admin-sessions.service";
|
|
11
17
|
import { adminUserRoutes } from "./admin-users/admin-users.routes";
|
|
12
18
|
import { AdminUsersService } from "./admin-users/admin-users.service";
|
|
@@ -57,6 +63,12 @@ const adminPlugin: AppPlugin = ({ services }) => {
|
|
|
57
63
|
sessions: services.resolve(ADMIN_SESSIONS),
|
|
58
64
|
revoke: createSessionRevoker(() => getAuth().api as unknown as RevokeUserSessionApi),
|
|
59
65
|
}))
|
|
66
|
+
.use(accountSessionRoutes({
|
|
67
|
+
auth,
|
|
68
|
+
sessions: services.resolve(ADMIN_SESSIONS),
|
|
69
|
+
revoke: createOwnSessionRevoker(() => getAuth().api as unknown as RevokeSessionApi),
|
|
70
|
+
freshAgeSeconds: async () => (await getAuth().$context).sessionConfig.freshAge,
|
|
71
|
+
}))
|
|
60
72
|
.use(adminUserRoutes({ auth, users: services.resolve(ADMIN_USERS) }))
|
|
61
73
|
.use(siteAssetRoutes(site, profileImages))
|
|
62
74
|
.get("/site/settings", async () => {
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { listAccountSessions, revokeAccountSession } from "./account-sessions";
|
|
3
|
+
|
|
4
|
+
describe("account sessions", () => {
|
|
5
|
+
it("asks for one page with the cursor", async () => {
|
|
6
|
+
const paths: string[] = [];
|
|
7
|
+
const client = {
|
|
8
|
+
get: <T>(path: string): Promise<T> => {
|
|
9
|
+
paths.push(path);
|
|
10
|
+
return Promise.resolve({ current: null, items: [], nextCursor: null, total: 0 } as unknown as T);
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
await listAccountSessions(client, { limit: 20, cursor: "abc" });
|
|
15
|
+
await listAccountSessions(client, { limit: 20, cursor: null });
|
|
16
|
+
|
|
17
|
+
expect(paths).toEqual(["/account/sessions?limit=20&cursor=abc", "/account/sessions?limit=20"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("revokes by id", async () => {
|
|
21
|
+
const paths: string[] = [];
|
|
22
|
+
const client = {
|
|
23
|
+
del: <T>(path: string): Promise<T> => {
|
|
24
|
+
paths.push(path);
|
|
25
|
+
return Promise.resolve(undefined as unknown as T);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
await revokeAccountSession(client, "id/with slash");
|
|
30
|
+
|
|
31
|
+
expect(paths).toEqual(["/account/sessions/id%2Fwith%20slash"]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ApiClient } from "@podosoft/podokit-api-client";
|
|
2
|
+
|
|
3
|
+
/** One of the signed-in user's sessions from `GET /account/sessions`. The API never returns the token. */
|
|
4
|
+
export type AccountSession = {
|
|
5
|
+
id: string;
|
|
6
|
+
ipAddress: string | null;
|
|
7
|
+
userAgent: string | null;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type AccountSessionPage = {
|
|
13
|
+
/** The session making the request, on every page. */
|
|
14
|
+
current: AccountSession | null;
|
|
15
|
+
/** The other sessions, newest first. */
|
|
16
|
+
items: AccountSession[];
|
|
17
|
+
nextCursor: string | null;
|
|
18
|
+
/** Every active session, the current one included. */
|
|
19
|
+
total: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function listAccountSessions(
|
|
23
|
+
client: Pick<ApiClient, "get">,
|
|
24
|
+
query: { limit: number; cursor?: string | null },
|
|
25
|
+
): Promise<AccountSessionPage> {
|
|
26
|
+
const params = new URLSearchParams({ limit: String(query.limit) });
|
|
27
|
+
if (query.cursor) params.set("cursor", query.cursor);
|
|
28
|
+
return client.get<AccountSessionPage>(`/account/sessions?${params.toString()}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Revokes one of the user's own sessions by id; the server looks up the token. */
|
|
32
|
+
export function revokeAccountSession(client: Pick<ApiClient, "del">, id: string): Promise<void> {
|
|
33
|
+
return client.del<void>(`/account/sessions/${encodeURIComponent(id)}`);
|
|
34
|
+
}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { deviceLabel } from "#lib/device-label.js";
|
|
12
12
|
import { toast } from "svelte-sonner";
|
|
13
13
|
import { api } from "#lib/api.js";
|
|
14
|
+
import { listAccountSessions, revokeAccountSession, type AccountSession } from "#lib/account-sessions.js";
|
|
15
|
+
import { KeysetPages } from "#lib/admin/sessions.js";
|
|
14
16
|
import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
|
|
15
17
|
import { untrack } from "svelte";
|
|
16
18
|
import type { SessionUser } from "../../app.d.ts";
|
|
@@ -392,28 +394,34 @@
|
|
|
392
394
|
await goto("/login", { invalidateAll: true });
|
|
393
395
|
}
|
|
394
396
|
|
|
395
|
-
// Sessions — this user's own devices
|
|
396
|
-
|
|
397
|
+
// Sessions — this user's own devices. The API pages them, because Better Auth's
|
|
398
|
+
// /list-sessions returns at most 100 and could leave out the current one.
|
|
399
|
+
/** Better Auth's code for "this session is older than `session.freshAge`". */
|
|
397
400
|
const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
|
|
398
|
-
type Session =
|
|
401
|
+
type Session = AccountSession & { current: boolean };
|
|
399
402
|
const PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
403
|
+
// The API pages newest first by keyset, so the order is fixed rather than sortable.
|
|
404
|
+
const sessionsSort: SortState = { key: "createdAt", dir: "desc" };
|
|
405
|
+
const sessionPages = new KeysetPages();
|
|
400
406
|
let sessions = $state<Session[]>([]);
|
|
407
|
+
let sessionsTotal = $state(0);
|
|
408
|
+
let sessionsPagerTotal = $state(0);
|
|
401
409
|
let sessionsPage = $state(1);
|
|
402
|
-
let sessionsSort = $state<SortState | null>(null);
|
|
403
410
|
let busy = $state(false);
|
|
411
|
+
let latestSessionsRequest = 0;
|
|
404
412
|
const sessionsColumns: DataTableColumn<Session>[] = [
|
|
405
|
-
{ key: "userAgent", label: i18n.t.sessions.device
|
|
406
|
-
{ key: "ipAddress", label: i18n.t.sessions.ip
|
|
407
|
-
{ key: "createdAt", label: i18n.t.sessions.since
|
|
413
|
+
{ key: "userAgent", label: i18n.t.sessions.device },
|
|
414
|
+
{ key: "ipAddress", label: i18n.t.sessions.ip },
|
|
415
|
+
{ key: "createdAt", label: i18n.t.sessions.since },
|
|
408
416
|
{ key: "status", label: i18n.t.sessions.status },
|
|
409
417
|
{ key: "actions", label: "", class: "w-10" },
|
|
410
418
|
];
|
|
411
419
|
/**
|
|
412
420
|
* True when the session is too old to list sessions.
|
|
413
421
|
*
|
|
414
|
-
* better-auth
|
|
415
|
-
*
|
|
416
|
-
*
|
|
422
|
+
* The API keeps better-auth's freshness rule for listing sessions, and
|
|
423
|
+
* `session.freshAge` defaults to 24 hours. So for anyone who signed in yesterday —
|
|
424
|
+
* which is most people, most of the time — the list answers 403
|
|
417
425
|
* `SESSION_NOT_FRESH`. It is not a failure to report as one: the tab has to say what
|
|
418
426
|
* happened and what to do, or it is a blank table under an untranslated toast.
|
|
419
427
|
*/
|
|
@@ -423,32 +431,45 @@
|
|
|
423
431
|
await api.auth.signOut();
|
|
424
432
|
await goto("/login", { invalidateAll: true });
|
|
425
433
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
434
|
+
/** Loads one page; the current session leads the first. A newer request supersedes any in flight. */
|
|
435
|
+
async function loadSessions(target = 1): Promise<void> {
|
|
436
|
+
if (target === 1) sessionPages.reset();
|
|
437
|
+
const cursor = sessionPages.cursorFor(target);
|
|
438
|
+
if (cursor === undefined) return;
|
|
439
|
+
const request = ++latestSessionsRequest;
|
|
440
|
+
try {
|
|
441
|
+
const result = await listAccountSessions(api, { limit: PAGE_SIZE, cursor });
|
|
442
|
+
if (request !== latestSessionsRequest) return;
|
|
443
|
+
// A revoke can empty the last page; show the one before it instead.
|
|
444
|
+
if (result.items.length === 0 && target > 1) return loadSessions(target - 1);
|
|
445
|
+
sessionsStale = false;
|
|
446
|
+
sessionPages.record(target, result.nextCursor);
|
|
447
|
+
const others = result.items.map((s) => ({ ...s, current: false }));
|
|
448
|
+
sessions = target === 1 && result.current ? [{ ...result.current, current: true }, ...others] : others;
|
|
449
|
+
sessionsTotal = result.total;
|
|
450
|
+
sessionsPagerTotal = sessionPages.pagerTotal(result.total - (result.current ? 1 : 0), PAGE_SIZE);
|
|
451
|
+
sessionsPage = target;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (request !== latestSessionsRequest) return;
|
|
454
|
+
if (error instanceof ApiError && error.code === SESSION_NOT_FRESH) {
|
|
455
|
+
// Explained in the panel below, not thrown at the corner of the screen.
|
|
456
|
+
sessionsStale = true;
|
|
457
|
+
sessions = [];
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
toast.error(i18n.t.sessions.loadFailed);
|
|
433
461
|
}
|
|
434
|
-
sessionsStale = false;
|
|
435
|
-
if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
|
|
436
|
-
// Current session first (users expect it on top; also keeps it on page 1),
|
|
437
|
-
// then newest to oldest.
|
|
438
|
-
sessions = ((res ?? []) as Session[]).sort((a, b) => {
|
|
439
|
-
if (a.id === data.currentSessionId) return -1;
|
|
440
|
-
if (b.id === data.currentSessionId) return 1;
|
|
441
|
-
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
442
|
-
});
|
|
443
462
|
}
|
|
444
|
-
async function revoke(
|
|
463
|
+
async function revoke(id: string): Promise<void> {
|
|
445
464
|
busy = true;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (error) toast.error(error.message ?? i18n.t.sessions.revokeFailed);
|
|
449
|
-
else {
|
|
465
|
+
try {
|
|
466
|
+
await revokeAccountSession(api, id);
|
|
450
467
|
toast.success(i18n.t.sessions.revoked);
|
|
451
|
-
await loadSessions();
|
|
468
|
+
await loadSessions(sessionsPage);
|
|
469
|
+
} catch {
|
|
470
|
+
toast.error(i18n.t.sessions.revokeFailed);
|
|
471
|
+
} finally {
|
|
472
|
+
busy = false;
|
|
452
473
|
}
|
|
453
474
|
}
|
|
454
475
|
async function signOutOthers(): Promise<void> {
|
|
@@ -824,7 +845,7 @@
|
|
|
824
845
|
<Card.Root>
|
|
825
846
|
<Card.Header class="flex flex-row items-center justify-between gap-2">
|
|
826
847
|
<Card.Title>{i18n.t.sessions.title}</Card.Title>
|
|
827
|
-
<Button variant="outline" size="sm" disabled={busy || sessionsStale ||
|
|
848
|
+
<Button variant="outline" size="sm" disabled={busy || sessionsStale || sessionsTotal <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
|
|
828
849
|
</Card.Header>
|
|
829
850
|
<Card.Content>
|
|
830
851
|
{#if sessionsStale}
|
|
@@ -843,20 +864,23 @@
|
|
|
843
864
|
columns={sessionsColumns}
|
|
844
865
|
rows={sessions}
|
|
845
866
|
getKey={(s) => s.id}
|
|
846
|
-
|
|
867
|
+
sort={sessionsSort}
|
|
868
|
+
manualSort
|
|
869
|
+
manualPagination
|
|
847
870
|
bind:page={sessionsPage}
|
|
848
871
|
perPage={PAGE_SIZE}
|
|
849
|
-
|
|
872
|
+
total={sessionsPagerTotal}
|
|
873
|
+
label={fmt(i18n.t.sessions.total, { count: sessionsTotal })}
|
|
850
874
|
empty={i18n.t.sessions.empty}
|
|
875
|
+
onChange={(state) => void loadSessions(state.page)}
|
|
851
876
|
>
|
|
852
877
|
{#snippet row(s)}
|
|
853
|
-
{@const isCurrent = s.id === data.currentSessionId}
|
|
854
878
|
<Table.Cell class="max-w-xs truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
|
|
855
879
|
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
856
880
|
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
857
|
-
<Table.Cell>{#if
|
|
881
|
+
<Table.Cell>{#if s.current}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
|
|
858
882
|
<Table.Cell>
|
|
859
|
-
<Button variant="ghost" size="sm" disabled={busy ||
|
|
883
|
+
<Button variant="ghost" size="sm" disabled={busy || s.current} onclick={() => revoke(s.id)}>{i18n.t.sessions.revoke}</Button>
|
|
860
884
|
</Table.Cell>
|
|
861
885
|
{/snippet}
|
|
862
886
|
</DataTable>
|
|
@@ -86,7 +86,9 @@
|
|
|
86
86
|
{:else}
|
|
87
87
|
<Label class="text-sm" for="toolbar-search">{searchFields[0].label}</Label>
|
|
88
88
|
{/if}
|
|
89
|
-
|
|
89
|
+
<!-- The minimum width makes a narrow row wrap the input to its own line
|
|
90
|
+
instead of squeezing it next to the field select. -->
|
|
91
|
+
<Input id="toolbar-search" bind:value={search} placeholder={searchPlaceholder} class="min-w-48 max-w-xs flex-1" />
|
|
90
92
|
<Button type="submit">{searchButton}</Button>
|
|
91
93
|
</form>
|
|
92
94
|
{/if}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { signInOnSeparateDevices } from "../helpers/devices";
|
|
2
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
3
|
+
|
|
4
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
|
+
const origin = { origin: base };
|
|
6
|
+
|
|
7
|
+
type Page = {
|
|
8
|
+
current: { id: string } | null;
|
|
9
|
+
items: Array<{ id: string }>;
|
|
10
|
+
nextCursor: string | null;
|
|
11
|
+
total: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
test("account sessions page the user's own sessions and revoke one by id", async ({ playwright, disposableUsers }) => {
|
|
15
|
+
const email = `api-account-sessions-${Date.now()}@example.com`;
|
|
16
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
17
|
+
await disposableUsers.create({ email, password, name: "Account sessions" });
|
|
18
|
+
expect(await signInOnSeparateDevices(email, password, 4)).toEqual([200, 200, 200, 200]);
|
|
19
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
20
|
+
expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
|
|
21
|
+
const current = (await (await ctx.get("/api/auth/get-session")).json()) as { session: { id: string } };
|
|
22
|
+
|
|
23
|
+
const first = await ctx.get("/api/account/sessions?limit=2");
|
|
24
|
+
expect(first.ok()).toBeTruthy();
|
|
25
|
+
const firstText = await first.text();
|
|
26
|
+
expect(firstText).not.toContain("token");
|
|
27
|
+
const page1 = JSON.parse(firstText) as Page;
|
|
28
|
+
expect(page1.current?.id).toBe(current.session.id);
|
|
29
|
+
expect(page1.total).toBe(5);
|
|
30
|
+
expect(page1.items).toHaveLength(2);
|
|
31
|
+
expect(page1.nextCursor).toBeTruthy();
|
|
32
|
+
const page2 = (await (await ctx.get(`/api/account/sessions?limit=2&cursor=${encodeURIComponent(page1.nextCursor ?? "")}`)).json()) as Page;
|
|
33
|
+
expect(page2.current?.id).toBe(current.session.id);
|
|
34
|
+
expect(page2.nextCursor).toBeNull();
|
|
35
|
+
const ids = [...page1.items, ...page2.items].map((item) => item.id);
|
|
36
|
+
expect(new Set(ids).size).toBe(4);
|
|
37
|
+
expect(ids).not.toContain(current.session.id);
|
|
38
|
+
|
|
39
|
+
// Another user's session cannot be revoked through this endpoint.
|
|
40
|
+
const otherEmail = `api-account-sessions-other-${Date.now()}@example.com`;
|
|
41
|
+
await disposableUsers.create({ email: otherEmail, password, name: "Other user" });
|
|
42
|
+
const otherUser = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
43
|
+
expect((await otherUser.post("/api/auth/sign-in/email", { data: { email: otherEmail, password } })).ok()).toBeTruthy();
|
|
44
|
+
const otherSession = (await (await otherUser.get("/api/auth/get-session")).json()) as { session: { id: string } };
|
|
45
|
+
const foreign = await ctx.delete(`/api/account/sessions/${encodeURIComponent(otherSession.session.id)}`);
|
|
46
|
+
expect(foreign.status()).toBe(404);
|
|
47
|
+
expect(await foreign.json()).toMatchObject({ error: { code: "ACCOUNT_SESSION_NOT_FOUND" } });
|
|
48
|
+
expect((await otherUser.get("/api/auth/get-session")).ok()).toBeTruthy();
|
|
49
|
+
await otherUser.dispose();
|
|
50
|
+
|
|
51
|
+
const revoked = await ctx.delete(`/api/account/sessions/${encodeURIComponent(ids[0] ?? "")}`);
|
|
52
|
+
expect(revoked.status()).toBe(204);
|
|
53
|
+
expect(((await (await ctx.get("/api/account/sessions?limit=2")).json()) as Page).total).toBe(4);
|
|
54
|
+
await ctx.dispose();
|
|
55
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
3
|
+
import { signInOnSeparateDevices } from "../helpers/devices";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
const origin = { origin: base };
|
|
@@ -125,6 +126,31 @@ test("revokeOtherSessions keeps the current session valid", async ({ playwright,
|
|
|
125
126
|
await ctx.dispose();
|
|
126
127
|
});
|
|
127
128
|
|
|
129
|
+
test("revokeOtherSessions removes every other session, past Better Auth's page of 100", async ({
|
|
130
|
+
playwright,
|
|
131
|
+
disposableUsers,
|
|
132
|
+
}) => {
|
|
133
|
+
test.slow();
|
|
134
|
+
const email = `api-revoke-many-${Date.now()}@example.com`;
|
|
135
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
136
|
+
await disposableUsers.create({ email, password, name: "Many sessions" });
|
|
137
|
+
|
|
138
|
+
const statuses = await signInOnSeparateDevices(email, password, 105);
|
|
139
|
+
test.skip(statuses.includes(429), "needs an authentication rate limit above 105 sign-ins per window");
|
|
140
|
+
expect(statuses.every((status) => status === 200)).toBe(true);
|
|
141
|
+
|
|
142
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
143
|
+
expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
|
|
144
|
+
// Better Auth lists at most 100 sessions, so a full page shows the backlog.
|
|
145
|
+
expect(((await (await ctx.get("/api/auth/list-sessions")).json()) as unknown[]).length).toBe(100);
|
|
146
|
+
|
|
147
|
+
expect((await ctx.post("/api/auth/revoke-other-sessions")).ok()).toBeTruthy();
|
|
148
|
+
const left = (await (await ctx.get("/api/auth/list-sessions")).json()) as Array<{ token?: string }>;
|
|
149
|
+
expect(left).toHaveLength(1);
|
|
150
|
+
expect((await (await ctx.get("/api/auth/get-session")).json())?.user?.email).toBe(email);
|
|
151
|
+
await ctx.dispose();
|
|
152
|
+
});
|
|
153
|
+
|
|
128
154
|
test("sessions record a client IP (forwarded through the proxy)", async ({ playwright }) => {
|
|
129
155
|
const ctx = await signedIn(playwright, ADMIN);
|
|
130
156
|
const res = await ctx.get("/api/auth/list-sessions");
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Signs in `count` times, each as a separate device, and returns the response
|
|
5
|
+
* statuses. Each request carries no cookies: a Playwright request context keeps
|
|
6
|
+
* the cookies of its earlier sign-ins, and Better Auth's multi-session plugin
|
|
7
|
+
* deletes a same-user session that a sign-in request carries, so signing in
|
|
8
|
+
* again from one context leaves one session instead of adding one.
|
|
9
|
+
*/
|
|
10
|
+
export async function signInOnSeparateDevices(email: string, password: string, count: number): Promise<number[]> {
|
|
11
|
+
const statuses: number[] = [];
|
|
12
|
+
for (let start = 0; start < count; start += 10) {
|
|
13
|
+
const batch = Array.from({ length: Math.min(10, count - start) }, async () => {
|
|
14
|
+
const response = await fetch(`${base}/api/auth/sign-in/email`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { origin: base, "content-type": "application/json" },
|
|
17
|
+
body: JSON.stringify({ email, password }),
|
|
18
|
+
});
|
|
19
|
+
await response.body?.cancel();
|
|
20
|
+
return response.status;
|
|
21
|
+
});
|
|
22
|
+
statuses.push(...(await Promise.all(batch)));
|
|
23
|
+
}
|
|
24
|
+
return statuses;
|
|
25
|
+
}
|
|
@@ -52,16 +52,9 @@ async function seedSession(
|
|
|
52
52
|
data: { email: account.email, password: account.password },
|
|
53
53
|
});
|
|
54
54
|
expect(res.ok(), `sign-in ${account.email}`).toBeTruthy();
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
// long-lived local database has accumulated them.
|
|
59
|
-
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
60
|
-
const sessionsResponse = await ctx.get("/api/auth/list-sessions");
|
|
61
|
-
const sessions = sessionsResponse.ok() ? (await sessionsResponse.json()) as unknown : [];
|
|
62
|
-
if (!Array.isArray(sessions) || sessions.length <= 1) break;
|
|
63
|
-
expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
|
|
64
|
-
}
|
|
55
|
+
// Each run signs in again, so keep a long-lived local database from piling up
|
|
56
|
+
// this account's sessions: the seeded one is the only one left.
|
|
57
|
+
expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
|
|
65
58
|
writeFileSync(path, `${JSON.stringify(withTestLocale(await ctx.storageState()), null, 2)}\n`);
|
|
66
59
|
await ctx.dispose();
|
|
67
60
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { signInOnSeparateDevices } from "../helpers/devices";
|
|
2
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
4
|
+
|
|
5
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
|
+
const origin = { origin: base };
|
|
7
|
+
|
|
8
|
+
// Better Auth's /list-sessions returns at most 100 sessions, the first ones the
|
|
9
|
+
// database finds, so a browser that signed in after 105 others used to see no
|
|
10
|
+
// "Current" row, and "Sign out other sessions" left sessions behind.
|
|
11
|
+
test("sessions tab shows the current session and signs out every other one past 100", async ({
|
|
12
|
+
browser,
|
|
13
|
+
playwright,
|
|
14
|
+
disposableUsers,
|
|
15
|
+
}) => {
|
|
16
|
+
test.slow();
|
|
17
|
+
const email = `ui-many-sessions-${Date.now()}@example.com`;
|
|
18
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
19
|
+
await disposableUsers.create({ email, password, name: "Many sessions" });
|
|
20
|
+
const statuses = await signInOnSeparateDevices(email, password, 105);
|
|
21
|
+
test.skip(statuses.includes(429), "needs an authentication rate limit above 105 sign-ins per window");
|
|
22
|
+
expect(statuses.every((status) => status === 200)).toBe(true);
|
|
23
|
+
|
|
24
|
+
// Sign the browser in last, so its session is the 106th.
|
|
25
|
+
const signIn = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
26
|
+
expect((await signIn.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
|
|
27
|
+
const context = await browser.newContext({ storageState: await signIn.storageState() });
|
|
28
|
+
await context.addCookies([{ name: "locale", value: "en", url: base }]);
|
|
29
|
+
const page = await context.newPage();
|
|
30
|
+
try {
|
|
31
|
+
await ready(page, "/account");
|
|
32
|
+
await page.getByRole("button", { name: "Sessions" }).click();
|
|
33
|
+
await expect(page.getByText("106 sessions", { exact: true })).toBeVisible();
|
|
34
|
+
await expect(page.getByText("Current", { exact: true })).toBeVisible();
|
|
35
|
+
|
|
36
|
+
await page.getByRole("button", { name: "Sign out other sessions" }).click();
|
|
37
|
+
await expect(page.getByText("Signed out other sessions")).toBeVisible();
|
|
38
|
+
await expect(page.getByText("1 sessions", { exact: true })).toBeVisible();
|
|
39
|
+
await expect(page.getByText("Current", { exact: true })).toBeVisible();
|
|
40
|
+
const left = (await (await signIn.get("/api/account/sessions?limit=100")).json()) as { total: number };
|
|
41
|
+
expect(left.total).toBe(1);
|
|
42
|
+
} finally {
|
|
43
|
+
await context.close();
|
|
44
|
+
await signIn.dispose();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -174,8 +174,8 @@ test("account security and sessions sub-navigation", async ({ page }) => {
|
|
|
174
174
|
/**
|
|
175
175
|
* ⚠ THE SUITE ALWAYS SIGNS IN FIRST, WHICH IS WHY THIS NEEDED A MOCK.
|
|
176
176
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
177
|
+
* The sessions list keeps better-auth's freshness rule, and `session.freshAge`
|
|
178
|
+
* defaults to 24 hours, so `GET /api/account/sessions` returns 403
|
|
179
179
|
* `SESSION_NOT_FRESH` to anyone who signed in yesterday. The seeded session is
|
|
180
180
|
* minutes old, so the test above passes for every run while the screen is broken for
|
|
181
181
|
* real users — the tab showed an empty table under an untranslated "Session is not
|
|
@@ -183,11 +183,14 @@ test("account security and sessions sub-navigation", async ({ page }) => {
|
|
|
183
183
|
* own setup prevents.
|
|
184
184
|
*/
|
|
185
185
|
test("sessions tab explains a session that is too old to list them", async ({ page }) => {
|
|
186
|
-
await page.route("**/api/
|
|
186
|
+
await page.route("**/api/account/sessions?*", (route) =>
|
|
187
187
|
route.fulfill({
|
|
188
188
|
status: 403,
|
|
189
189
|
contentType: "application/json",
|
|
190
|
-
body: JSON.stringify({
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
success: false,
|
|
192
|
+
error: { code: "SESSION_NOT_FRESH", message: "Session is not fresh", statusCode: 403 },
|
|
193
|
+
}),
|
|
191
194
|
}),
|
|
192
195
|
);
|
|
193
196
|
|