@podosoft/podokit 1.4.1 → 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 +3 -1
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +16 -8
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +23 -4
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +27 -2
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
- 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/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
- 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/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +6 -0
- package/dist/templates/fullstack/dot-env.example +23 -0
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -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/hooks.server.test.ts +24 -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/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- 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/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- 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/helpers/exclusive.ts +80 -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 +54 -47
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- 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 +19 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- 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/dist/templates/todo/README.md +1 -1
- package/package.json +2 -2
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}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts
CHANGED
|
@@ -61,6 +61,12 @@ describe("needsRuntimeContext", () => {
|
|
|
61
61
|
expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("skips them for the web server's health route, which must answer while the API is down", () => {
|
|
65
|
+
expect(needsRuntimeContext(request("/healthz", { headers: { accept: "*/*" } }))).toBe(false);
|
|
66
|
+
expect(needsRuntimeContext(request("/healthz", { headers: { accept: NAVIGATION } }))).toBe(false);
|
|
67
|
+
expect(needsRuntimeContext(request("/healthz/extra", { routeId: null, headers: { accept: NAVIGATION } }))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
64
70
|
it("skips them while prerendering", () => {
|
|
65
71
|
expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
|
|
66
72
|
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { APP_ASSET_PATHS } from "./asset-paths";
|
|
2
2
|
|
|
3
|
+
/** The web server's own health route (`routes/healthz/+server.ts`), which never calls the API. */
|
|
4
|
+
const WEB_HEALTH_PATH = "/healthz";
|
|
5
|
+
|
|
3
6
|
/** The request fields that decide whether a response can use the session and site settings. */
|
|
4
7
|
export interface RuntimeContextRequest {
|
|
5
8
|
url: URL;
|
|
@@ -92,6 +95,8 @@ export function needsRuntimeContext(
|
|
|
92
95
|
const { pathname } = event.url;
|
|
93
96
|
// The backend proxy forwards the cookies; the API resolves the session itself.
|
|
94
97
|
if (underPrefix(pathname, "/api")) return false;
|
|
98
|
+
// The web server's health route answers the probes even while the API is down.
|
|
99
|
+
if (pathname === WEB_HEALTH_PATH) return false;
|
|
95
100
|
// Remote functions run application code that may read the session.
|
|
96
101
|
if (event.isRemoteRequest) return true;
|
|
97
102
|
if (event.route.id === null) {
|
|
@@ -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
|
+
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isUserCreationAllowed,
|
|
5
5
|
} from "../../apps/api/src/auth/feature-gate";
|
|
6
6
|
import { ADMIN } from "../helpers/accounts";
|
|
7
|
+
import { exclusive } from "../helpers/exclusive";
|
|
7
8
|
|
|
8
9
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
9
10
|
const origin = { origin: base };
|
|
@@ -17,62 +18,68 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
test("admin can enable Google OAuth from the DB and it applies without a restart", async ({ playwright }) => {
|
|
20
|
-
|
|
21
|
-
await
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
test.slow();
|
|
22
|
+
await exclusive("social-providers", async () => {
|
|
23
|
+
const admin = await adminCtx(playwright);
|
|
24
|
+
await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
|
|
25
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
26
|
+
const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
|
|
27
|
+
|
|
28
|
+
// Before: provider not configured.
|
|
29
|
+
await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
|
|
30
|
+
|
|
31
|
+
// Paste credentials via the admin API.
|
|
32
|
+
const put = await admin.put("/api/account/auth-config", {
|
|
33
|
+
data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
|
|
34
|
+
});
|
|
35
|
+
expect(put.ok()).toBeTruthy();
|
|
36
|
+
const view = await put.json();
|
|
37
|
+
expect(view.social.google.enabled).toBe(true);
|
|
38
|
+
expect(view.social.google.hasSecret).toBe(true);
|
|
39
|
+
expect(view.social.google.clientId).toBe("dummy-google-client-id");
|
|
40
|
+
expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
|
|
41
|
+
|
|
42
|
+
// Applied live (no restart): social sign-in now redirects to Google.
|
|
43
|
+
await expect
|
|
44
|
+
.poll(async () => {
|
|
45
|
+
const r = await social();
|
|
46
|
+
return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
|
|
47
|
+
}, { timeout: 8000 })
|
|
48
|
+
.toContain("accounts.google.com");
|
|
49
|
+
const caps = await (await anon.get("/api/account/capabilities")).json();
|
|
50
|
+
expect(caps.providers).toContain("google");
|
|
51
|
+
|
|
52
|
+
await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
|
|
53
|
+
await anon.dispose();
|
|
54
|
+
await admin.dispose();
|
|
31
55
|
});
|
|
32
|
-
expect(put.ok()).toBeTruthy();
|
|
33
|
-
const view = await put.json();
|
|
34
|
-
expect(view.social.google.enabled).toBe(true);
|
|
35
|
-
expect(view.social.google.hasSecret).toBe(true);
|
|
36
|
-
expect(view.social.google.clientId).toBe("dummy-google-client-id");
|
|
37
|
-
expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
|
|
38
|
-
|
|
39
|
-
// Applied live (no restart): social sign-in now redirects to Google.
|
|
40
|
-
await expect
|
|
41
|
-
.poll(async () => {
|
|
42
|
-
const r = await social();
|
|
43
|
-
return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
|
|
44
|
-
}, { timeout: 8000 })
|
|
45
|
-
.toContain("accounts.google.com");
|
|
46
|
-
const caps = await (await anon.get("/api/account/capabilities")).json();
|
|
47
|
-
expect(caps.providers).toContain("google");
|
|
48
|
-
|
|
49
|
-
await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
|
|
50
|
-
await anon.dispose();
|
|
51
|
-
await admin.dispose();
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
test("admin can add and remove a social provider dynamically", async ({ playwright }) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
test.slow();
|
|
60
|
+
await exclusive("social-providers", async () => {
|
|
61
|
+
const admin = await adminCtx(playwright);
|
|
62
|
+
// The catalog offers providers beyond the two legacy env-backed ones.
|
|
63
|
+
const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
|
|
64
|
+
expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
|
|
65
|
+
|
|
66
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
67
|
+
const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
|
|
68
|
+
|
|
69
|
+
// Add GitHub (a provider with no env fallback) → applies live, no restart.
|
|
70
|
+
await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
|
|
71
|
+
await expect
|
|
72
|
+
.poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
|
|
73
|
+
.toContain("github.com");
|
|
74
|
+
|
|
75
|
+
// Remove it → the row is deleted and the provider stops working (again live).
|
|
76
|
+
await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
|
|
77
|
+
expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
|
|
78
|
+
await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
|
|
59
79
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Add GitHub (a provider with no env fallback) → applies live, no restart.
|
|
64
|
-
await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
|
|
65
|
-
await expect
|
|
66
|
-
.poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
|
|
67
|
-
.toContain("github.com");
|
|
68
|
-
|
|
69
|
-
// Remove it → the row is deleted and the provider stops working (again live).
|
|
70
|
-
await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
|
|
71
|
-
expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
|
|
72
|
-
await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
|
|
73
|
-
|
|
74
|
-
await anon.dispose();
|
|
75
|
-
await admin.dispose();
|
|
80
|
+
await anon.dispose();
|
|
81
|
+
await admin.dispose();
|
|
82
|
+
});
|
|
76
83
|
});
|
|
77
84
|
|
|
78
85
|
test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwright }) => {
|
|
@@ -85,43 +92,46 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
|
|
|
85
92
|
});
|
|
86
93
|
|
|
87
94
|
test("automatic logout validates and applies a sliding session lifetime", async ({ playwright }) => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
95
|
+
test.slow();
|
|
96
|
+
await exclusive("session-idle-timeout", async () => {
|
|
97
|
+
const admin = await adminCtx(playwright);
|
|
98
|
+
const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
expect(
|
|
102
|
+
(await admin.put("/api/account/auth-config", {
|
|
103
|
+
data: { server: { sessionIdleTimeoutMinutes: 4 } },
|
|
104
|
+
})).status(),
|
|
105
|
+
).toBe(400);
|
|
106
|
+
|
|
107
|
+
const enabled = await admin.put("/api/account/auth-config", {
|
|
108
|
+
data: { server: { sessionIdleTimeoutMinutes: 30 } },
|
|
109
|
+
});
|
|
110
|
+
expect(enabled.ok()).toBeTruthy();
|
|
111
|
+
expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
|
|
112
|
+
|
|
113
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
114
|
+
expect(caps.sessionIdleTimeoutMinutes).toBe(30);
|
|
115
|
+
|
|
116
|
+
const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
|
|
117
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
118
|
+
});
|
|
119
|
+
expect(signIn.ok()).toBeTruthy();
|
|
120
|
+
expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
|
|
121
|
+
|
|
122
|
+
const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
|
|
123
|
+
expiresAt: string;
|
|
124
|
+
}>;
|
|
125
|
+
const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
|
|
126
|
+
expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
|
|
127
|
+
} finally {
|
|
128
|
+
await admin.put("/api/account/auth-config", {
|
|
129
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
130
|
+
});
|
|
131
|
+
await freshAdmin.dispose();
|
|
132
|
+
await admin.dispose();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
125
135
|
});
|
|
126
136
|
|
|
127
137
|
test("new registrations require approval when the policy is enabled", async ({ playwright }) => {
|
|
@@ -204,75 +214,78 @@ test("new registrations require approval when the policy is enabled", async ({ p
|
|
|
204
214
|
});
|
|
205
215
|
|
|
206
216
|
test("closed public sign-up blocks new OAuth users but preserves existing sign-in", async ({ playwright }) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
217
|
+
test.slow();
|
|
218
|
+
await exclusive(["site-general", "social-providers"], async () => {
|
|
219
|
+
const admin = await adminCtx(playwright);
|
|
220
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
221
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
222
|
+
const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
|
|
223
|
+
const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
|
|
224
|
+
allowSignup?: string | null;
|
|
225
|
+
};
|
|
226
|
+
const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
|
|
227
|
+
let adminCreatedId = "";
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
expect(
|
|
231
|
+
(await admin.put("/api/account/auth-config", {
|
|
232
|
+
data: {
|
|
233
|
+
social: {
|
|
234
|
+
google: {
|
|
235
|
+
enabled: true,
|
|
236
|
+
clientId: "dummy-google-client-id",
|
|
237
|
+
clientSecret: "dummy-google-client-secret",
|
|
238
|
+
},
|
|
226
239
|
},
|
|
227
240
|
},
|
|
228
|
-
},
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
|
|
241
|
+
})).ok(),
|
|
242
|
+
).toBeTruthy();
|
|
243
|
+
expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
|
|
232
244
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const signup = await anon.post("/api/auth/sign-up/email", {
|
|
238
|
-
data: {
|
|
239
|
-
email: `closed-${Date.now()}@example.com`,
|
|
240
|
-
password,
|
|
241
|
-
name: "Closed Registration",
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
expect(signup.status()).toBe(403);
|
|
245
|
-
expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
|
|
245
|
+
// The site policy uses a short cache shared by sign-up requests and user
|
|
246
|
+
// creation hooks, so wait for the setting to become live.
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
246
248
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
249
|
+
const signup = await anon.post("/api/auth/sign-up/email", {
|
|
250
|
+
data: {
|
|
251
|
+
email: `closed-${Date.now()}@example.com`,
|
|
252
|
+
password,
|
|
253
|
+
name: "Closed Registration",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
expect(signup.status()).toBe(403);
|
|
257
|
+
expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
|
|
258
|
+
|
|
259
|
+
// OAuth initiation must stay available for existing social users. Only a
|
|
260
|
+
// callback that attempts to create a new user is rejected by the DB hook.
|
|
261
|
+
const social = await anon.post("/api/auth/sign-in/social", {
|
|
262
|
+
data: { provider: "google", callbackURL: `${base}/` },
|
|
263
|
+
});
|
|
264
|
+
expect(social.ok()).toBeTruthy();
|
|
265
|
+
expect(((await social.json()).url as string)).toContain("accounts.google.com");
|
|
266
|
+
expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
|
|
267
|
+
expect(isUserCreationAllowed(false, undefined)).toBe(false);
|
|
268
|
+
|
|
269
|
+
// Deliberate administrator provisioning remains available while the public
|
|
270
|
+
// site is closed.
|
|
271
|
+
expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
|
|
272
|
+
const adminCreated = await admin.post("/api/auth/admin/create-user", {
|
|
273
|
+
data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
|
|
274
|
+
});
|
|
275
|
+
expect(adminCreated.ok()).toBeTruthy();
|
|
276
|
+
adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
|
|
277
|
+
} finally {
|
|
278
|
+
if (adminCreatedId) {
|
|
279
|
+
await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
|
|
280
|
+
}
|
|
281
|
+
await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
|
|
282
|
+
await admin.put("/api/account/auth-config", {
|
|
283
|
+
data: { social: { google: { enabled: false } } },
|
|
284
|
+
});
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
286
|
+
await anon.get("/api/auth/get-session");
|
|
287
|
+
await anon.dispose();
|
|
288
|
+
await admin.dispose();
|
|
268
289
|
}
|
|
269
|
-
|
|
270
|
-
await admin.put("/api/account/auth-config", {
|
|
271
|
-
data: { social: { google: { enabled: false } } },
|
|
272
|
-
});
|
|
273
|
-
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
274
|
-
await anon.get("/api/auth/get-session");
|
|
275
|
-
await anon.dispose();
|
|
276
|
-
await admin.dispose();
|
|
277
|
-
}
|
|
290
|
+
});
|
|
278
291
|
});
|