@podosoft/podokit 1.1.6 → 1.2.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-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PageServerLoad } from "./$types";
|
|
2
|
+
import { getCapabilities, type CapabilitiesEvent } from "#lib/server/capabilities.js";
|
|
2
3
|
import { requireAdmin } from "#lib/server/guards.js";
|
|
3
4
|
|
|
4
5
|
// Non-secret view of the DB-backed auth config (OAuth providers, SMTP, server
|
|
@@ -20,23 +21,23 @@ export type AuthConfigView = {
|
|
|
20
21
|
};
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
requireAdmin(locals.user, locals);
|
|
25
|
-
let authConfig: AuthConfigView | null = null;
|
|
24
|
+
async function loadAuthConfig(fetch: CapabilitiesEvent["fetch"]): Promise<AuthConfigView | null> {
|
|
26
25
|
try {
|
|
27
26
|
const res = await fetch("/api/account/auth-config");
|
|
28
|
-
if (res.ok)
|
|
27
|
+
if (res.ok) return (await res.json()) as AuthConfigView;
|
|
29
28
|
} catch {
|
|
30
29
|
/* leave null — the page shows the DB-config sections as unavailable */
|
|
31
30
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|
35
|
+
requireAdmin(locals.user, locals);
|
|
36
|
+
// The admin layout loads the same capabilities in this request, so the
|
|
37
|
+
// require-two-factor policy comes from that shared response.
|
|
38
|
+
const [authConfig, capabilities] = await Promise.all([
|
|
39
|
+
loadAuthConfig(fetch),
|
|
40
|
+
getCapabilities({ locals, fetch }),
|
|
41
|
+
]);
|
|
42
|
+
return { authConfig, require2fa: capabilities.require2fa };
|
|
42
43
|
};
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
const ac = $derived(data.authConfig);
|
|
25
25
|
|
|
26
26
|
type EditableFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
|
|
27
|
-
// require2fa is
|
|
28
|
-
//
|
|
27
|
+
// require2fa is an enrolment policy rather than a feature, so its card reads
|
|
28
|
+
// `data.require2fa` instead of the feature flags in `caps`.
|
|
29
29
|
type ToggleFlag = EditableFlag | "require2fa";
|
|
30
30
|
type ServerKey = "requireEmailVerification" | "requireSignupApproval" | "hibp" | "allowDelete" | "auditLog";
|
|
31
31
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
2
3
|
import { goto } from "$app/navigation";
|
|
3
4
|
import { Button } from "#lib/components/ui/button/index.js";
|
|
4
5
|
import { Input } from "#lib/components/ui/input/index.js";
|
|
@@ -15,6 +16,8 @@
|
|
|
15
16
|
import PlusIcon from "@lucide/svelte/icons/plus";
|
|
16
17
|
import { toast } from "svelte-sonner";
|
|
17
18
|
import { api } from "#lib/api.js";
|
|
19
|
+
import { listAdminSessions, revokeAdminSession, type AdminSession } from "#lib/admin/sessions.js";
|
|
20
|
+
import { DEFAULT_USER_SORT, keepLatestFilter, listUsersQuery } from "#lib/admin/users.js";
|
|
18
21
|
import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
|
|
19
22
|
import { cn } from "#lib/utils.js";
|
|
20
23
|
import { deviceLabel } from "#lib/device-label.js";
|
|
@@ -42,11 +45,16 @@
|
|
|
42
45
|
};
|
|
43
46
|
|
|
44
47
|
const PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
45
|
-
|
|
48
|
+
// Sessions shown in the manage dialog: the newest page for one user.
|
|
49
|
+
const MANAGED_SESSIONS_LIMIT = 100;
|
|
46
50
|
let users = $state<Row[]>([]);
|
|
51
|
+
let total = $state(0);
|
|
47
52
|
let page = $state(1);
|
|
48
|
-
let sort = $state<SortState | null>(
|
|
53
|
+
let sort = $state<SortState | null>({ ...DEFAULT_USER_SORT });
|
|
54
|
+
let loading = $state(false);
|
|
49
55
|
let busy = $state(false);
|
|
56
|
+
let latestRequest = 0;
|
|
57
|
+
let loadedSortKey = `${DEFAULT_USER_SORT.key}:${DEFAULT_USER_SORT.dir}`;
|
|
50
58
|
|
|
51
59
|
// Toolbar: filters + search both commit on the Search button (nothing applies
|
|
52
60
|
// until then), so multiple filter/search conditions apply together.
|
|
@@ -56,6 +64,19 @@
|
|
|
56
64
|
let appliedSearchField = $state("email");
|
|
57
65
|
let filterValues = $state<Record<string, string>>({ role: "", status: "" });
|
|
58
66
|
let appliedFilters = $state<Record<string, string>>({ role: "", status: "" });
|
|
67
|
+
let stagedFilters = { role: "", status: "" };
|
|
68
|
+
|
|
69
|
+
// The list endpoint takes a single filter, so picking a role clears the status
|
|
70
|
+
// and vice versa (see keepLatestFilter).
|
|
71
|
+
$effect(() => {
|
|
72
|
+
const next = keepLatestFilter(stagedFilters, {
|
|
73
|
+
role: filterValues.role ?? "",
|
|
74
|
+
status: filterValues.status ?? "",
|
|
75
|
+
});
|
|
76
|
+
stagedFilters = next;
|
|
77
|
+
if (filterValues.role !== next.role) filterValues.role = next.role;
|
|
78
|
+
if (filterValues.status !== next.status) filterValues.status = next.status;
|
|
79
|
+
});
|
|
59
80
|
|
|
60
81
|
const columns = $derived<DataTableColumn<Row>[]>([
|
|
61
82
|
{ key: "name", label: i18n.t.users.name, sortable: true },
|
|
@@ -82,7 +103,6 @@
|
|
|
82
103
|
label: i18n.t.users.status,
|
|
83
104
|
options: [
|
|
84
105
|
{ value: "", label: i18n.t.toolbar.all },
|
|
85
|
-
{ value: "active", label: i18n.t.users.active },
|
|
86
106
|
{ value: "pending", label: i18n.t.users.pendingApproval },
|
|
87
107
|
{ value: "banned", label: i18n.t.users.banned },
|
|
88
108
|
],
|
|
@@ -93,32 +113,44 @@
|
|
|
93
113
|
{ value: "name", label: i18n.t.users.name },
|
|
94
114
|
]);
|
|
95
115
|
|
|
96
|
-
|
|
97
|
-
users.filter((u) => {
|
|
98
|
-
if (appliedSearch) {
|
|
99
|
-
const hay = (appliedSearchField === "name" ? u.name : u.email).toLowerCase();
|
|
100
|
-
if (!hay.includes(appliedSearch.toLowerCase())) return false;
|
|
101
|
-
}
|
|
102
|
-
if (appliedFilters.role && (u.role ?? "user") !== appliedFilters.role) return false;
|
|
103
|
-
if (appliedFilters.status === "banned" && !u.banned) return false;
|
|
104
|
-
if (appliedFilters.status === "pending" && u.signupApproved !== false) return false;
|
|
105
|
-
if (appliedFilters.status === "active" && (u.banned || u.signupApproved === false)) return false;
|
|
106
|
-
return true;
|
|
107
|
-
}),
|
|
108
|
-
);
|
|
109
|
-
|
|
116
|
+
// Loads only the current page; a newer request supersedes one still in flight.
|
|
110
117
|
async function load(): Promise<void> {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
const request = ++latestRequest;
|
|
119
|
+
const sortKey = sort ? `${sort.key}:${sort.dir}` : "";
|
|
120
|
+
if (sortKey !== loadedSortKey) page = 1;
|
|
121
|
+
const query = listUsersQuery({
|
|
122
|
+
page,
|
|
123
|
+
perPage: PAGE_SIZE,
|
|
124
|
+
sort,
|
|
125
|
+
search: appliedSearch,
|
|
126
|
+
searchField: appliedSearchField,
|
|
127
|
+
role: appliedFilters.role ?? "",
|
|
128
|
+
status: appliedFilters.status ?? "",
|
|
129
|
+
});
|
|
130
|
+
loading = true;
|
|
131
|
+
const { data: res, error } = await api.auth.admin.listUsers({ query });
|
|
132
|
+
if (request !== latestRequest) return;
|
|
133
|
+
loading = false;
|
|
134
|
+
if (error) return void toast.error(i18n.t.users.loadFailed);
|
|
135
|
+
const rows = (res?.users ?? []) as Row[];
|
|
136
|
+
const count = res?.total ?? rows.length;
|
|
137
|
+
// An action can empty the last page; show the one before it instead.
|
|
138
|
+
const lastPage = Math.max(Math.ceil(count / PAGE_SIZE), 1);
|
|
139
|
+
if (rows.length === 0 && page > lastPage) {
|
|
140
|
+
page = lastPage;
|
|
141
|
+
return load();
|
|
142
|
+
}
|
|
143
|
+
loadedSortKey = sortKey;
|
|
144
|
+
users = rows;
|
|
145
|
+
total = count;
|
|
114
146
|
}
|
|
115
147
|
|
|
116
|
-
|
|
117
|
-
await load(); // refresh from the server on explicit search, then filter client-side
|
|
148
|
+
function applySearch(): void {
|
|
118
149
|
appliedSearch = search;
|
|
119
150
|
appliedSearchField = searchField;
|
|
120
151
|
appliedFilters = { ...filterValues };
|
|
121
152
|
page = 1;
|
|
153
|
+
void load();
|
|
122
154
|
}
|
|
123
155
|
|
|
124
156
|
// Impersonate — become the user, then continue on the signed-in account page
|
|
@@ -176,7 +208,6 @@
|
|
|
176
208
|
|
|
177
209
|
// Manage user — a two-pane modal mirroring the account settings, admin-scoped.
|
|
178
210
|
type ManageSection = "profile" | "security" | "sessions" | "danger";
|
|
179
|
-
type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
|
|
180
211
|
let manageOpen = $state(false);
|
|
181
212
|
let mUser = $state<Row | null>(null);
|
|
182
213
|
let mSection = $state<ManageSection>("profile");
|
|
@@ -192,10 +223,11 @@
|
|
|
192
223
|
let mBanReason = $state("");
|
|
193
224
|
let mBanDays = $state("");
|
|
194
225
|
// sessions
|
|
195
|
-
let mSessions = $state<
|
|
226
|
+
let mSessions = $state<AdminSession[]>([]);
|
|
227
|
+
let mSessionsTotal = $state(0);
|
|
196
228
|
let mSessionsPage = $state(1);
|
|
197
229
|
let mSessionsSort = $state<SortState | null>(null);
|
|
198
|
-
const mSessionsColumns: DataTableColumn<
|
|
230
|
+
const mSessionsColumns: DataTableColumn<AdminSession>[] = [
|
|
199
231
|
{ key: "userAgent", label: i18n.t.adminSessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
|
|
200
232
|
{ key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
|
|
201
233
|
{ key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
|
|
@@ -217,6 +249,7 @@
|
|
|
217
249
|
mBanDays = "";
|
|
218
250
|
mDeleteArmed = false;
|
|
219
251
|
mSessions = [];
|
|
252
|
+
mSessionsTotal = 0;
|
|
220
253
|
mSessionsPage = 1;
|
|
221
254
|
manageOpen = true;
|
|
222
255
|
await loadManagedSessions();
|
|
@@ -224,8 +257,15 @@
|
|
|
224
257
|
|
|
225
258
|
async function loadManagedSessions(): Promise<void> {
|
|
226
259
|
if (!mUser) return;
|
|
227
|
-
const
|
|
228
|
-
|
|
260
|
+
const userId = mUser.id;
|
|
261
|
+
try {
|
|
262
|
+
const result = await listAdminSessions(api, { limit: MANAGED_SESSIONS_LIMIT, userId });
|
|
263
|
+
if (mUser?.id !== userId) return;
|
|
264
|
+
mSessions = result.items;
|
|
265
|
+
mSessionsTotal = result.total;
|
|
266
|
+
} catch {
|
|
267
|
+
toast.error(i18n.t.adminSessions.loadFailed);
|
|
268
|
+
}
|
|
229
269
|
}
|
|
230
270
|
|
|
231
271
|
async function saveProfile(event: Event): Promise<void> {
|
|
@@ -329,11 +369,15 @@
|
|
|
329
369
|
await load();
|
|
330
370
|
}
|
|
331
371
|
|
|
332
|
-
async function revokeSession(
|
|
372
|
+
async function revokeSession(id: string): Promise<void> {
|
|
333
373
|
busy = true;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
374
|
+
try {
|
|
375
|
+
await revokeAdminSession(api, id);
|
|
376
|
+
} catch {
|
|
377
|
+
return void toast.error(i18n.t.adminSessions.revokeFailed);
|
|
378
|
+
} finally {
|
|
379
|
+
busy = false;
|
|
380
|
+
}
|
|
337
381
|
toast.success(i18n.t.adminSessions.revoked);
|
|
338
382
|
await loadManagedSessions();
|
|
339
383
|
}
|
|
@@ -343,7 +387,7 @@
|
|
|
343
387
|
busy = true;
|
|
344
388
|
const { error } = await api.auth.admin.revokeUserSessions({ userId: mUser.id });
|
|
345
389
|
busy = false;
|
|
346
|
-
if (error) return void toast.error(error.message ?? i18n.t.adminSessions.
|
|
390
|
+
if (error) return void toast.error(error.message ?? i18n.t.adminSessions.revokeFailed);
|
|
347
391
|
toast.success(i18n.t.adminSessions.allRevoked);
|
|
348
392
|
await loadManagedSessions();
|
|
349
393
|
}
|
|
@@ -360,7 +404,7 @@
|
|
|
360
404
|
}
|
|
361
405
|
|
|
362
406
|
$effect(() => {
|
|
363
|
-
void load();
|
|
407
|
+
untrack(() => void load());
|
|
364
408
|
});
|
|
365
409
|
</script>
|
|
366
410
|
|
|
@@ -383,14 +427,18 @@
|
|
|
383
427
|
|
|
384
428
|
<DataTable
|
|
385
429
|
{columns}
|
|
386
|
-
rows={
|
|
430
|
+
rows={users}
|
|
387
431
|
getKey={(u) => u.id}
|
|
388
|
-
empty={i18n.t.users.empty}
|
|
432
|
+
empty={loading ? "…" : i18n.t.users.empty}
|
|
389
433
|
bind:sort
|
|
390
434
|
bind:page
|
|
435
|
+
manualSort
|
|
436
|
+
manualPagination
|
|
391
437
|
perPage={PAGE_SIZE}
|
|
438
|
+
{total}
|
|
392
439
|
ariaLabel={i18n.t.users.title}
|
|
393
|
-
label={fmt(i18n.t.users.total, { count:
|
|
440
|
+
label={fmt(i18n.t.users.total, { count: total })}
|
|
441
|
+
onChange={() => void load()}
|
|
394
442
|
>
|
|
395
443
|
{#snippet row(user, { cellClass })}
|
|
396
444
|
<Table.Cell class={cn(cellClass("name"), "font-medium")}>{user.name}</Table.Cell>
|
|
@@ -555,14 +603,14 @@
|
|
|
555
603
|
bind:sort={mSessionsSort}
|
|
556
604
|
bind:page={mSessionsPage}
|
|
557
605
|
perPage={PAGE_SIZE}
|
|
558
|
-
label={fmt(i18n.t.adminSessions.total, { count:
|
|
606
|
+
label={fmt(i18n.t.adminSessions.total, { count: mSessionsTotal })}
|
|
559
607
|
empty={i18n.t.adminSessions.empty}
|
|
560
608
|
>
|
|
561
609
|
{#snippet row(s)}
|
|
562
610
|
<Table.Cell class="max-w-40 truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
563
611
|
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
564
612
|
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
565
|
-
<Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.
|
|
613
|
+
<Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.id)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
|
|
566
614
|
{/snippet}
|
|
567
615
|
</DataTable>
|
|
568
616
|
</div>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { LayoutServerLoad } from "./$types";
|
|
2
2
|
import { redirect } from "@sveltejs/kit";
|
|
3
3
|
import { safeAuthRedirect } from "#lib/auth-redirect.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getCapabilities } from "#lib/server/capabilities.js";
|
|
5
5
|
import { isPublicPath, requireBackendAvailable } from "#lib/server/guards.js";
|
|
6
|
-
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
7
6
|
|
|
8
7
|
// "/" is a public landing page. Auth pages return signed-in users to their safe
|
|
9
8
|
// requested page, and the admin area requires a session. /maintenance is public
|
|
@@ -43,19 +42,13 @@ export const load: LayoutServerLoad = async (event) => {
|
|
|
43
42
|
if (!locals.user && !isPublicPath(pathname)) {
|
|
44
43
|
redirect(303, `/login?redirect=${encodeURIComponent(`${pathname}${url.search}`)}`);
|
|
45
44
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// Keep automatic logout disabled in the browser if policy lookup fails.
|
|
56
|
-
// The server-side session expiration remains authoritative.
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
const messages = await loadMessages(locals.locale, site?.locale);
|
|
60
|
-
return { user: locals.user, locale: locals.locale, messages, site, sessionIdleTimeoutMinutes };
|
|
45
|
+
// Automatic logout stays disabled in the browser if the policy lookup fails;
|
|
46
|
+
// the server-side session expiration remains authoritative. Protected loads
|
|
47
|
+
// of the same request reuse this capabilities response.
|
|
48
|
+
const sessionIdleTimeoutMinutes = locals.user
|
|
49
|
+
? ((await getCapabilities(event)).sessionIdleTimeoutMinutes ?? null)
|
|
50
|
+
: null;
|
|
51
|
+
// Messages are resolved by the universal +layout.ts. This load re-runs on every
|
|
52
|
+
// client navigation for the path checks above and must not resend them.
|
|
53
|
+
return { user: locals.user, locale: locals.locale, site, sessionIdleTimeoutMinutes };
|
|
61
54
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { loadMessages } from "#lib/i18n/messages.js";
|
|
2
|
+
import type { LayoutLoad } from "./$types";
|
|
3
|
+
|
|
4
|
+
// Runs on the server for the first render and in the browser afterwards, so the
|
|
5
|
+
// catalogs never travel in `__data.json`. loadMessages() reuses one merged
|
|
6
|
+
// object per locale pair; the language switch changes `data.locale`.
|
|
7
|
+
export const load: LayoutLoad = async ({ data }) => ({
|
|
8
|
+
...data,
|
|
9
|
+
messages: await loadMessages(data.locale, data.site?.locale),
|
|
10
|
+
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Button } from "#lib/components/ui/button/index.js";
|
|
4
4
|
import * as Card from "#lib/components/ui/card/index.js";
|
|
5
5
|
import { getI18n } from "#lib/i18n/index.js";
|
|
6
|
+
import { signedOAuthQuery } from "#lib/oauth-query.js";
|
|
6
7
|
|
|
7
8
|
const i18n = getI18n();
|
|
8
9
|
// The OAuth provider redirects here with the authorization request in the query
|
|
@@ -14,13 +15,13 @@
|
|
|
14
15
|
async function decide(accept: boolean): Promise<void> {
|
|
15
16
|
busy = true;
|
|
16
17
|
// The authorization request is carried in the signed query params we were
|
|
17
|
-
// redirected here with;
|
|
18
|
-
//
|
|
18
|
+
// redirected here with; the server verifies them from the body, then restores
|
|
19
|
+
// the request. Credentials carry the user's session. The response says where next.
|
|
19
20
|
const res = await fetch(`/api/auth/oauth2/consent${page.url.search}`, {
|
|
20
21
|
method: "POST",
|
|
21
22
|
credentials: "include",
|
|
22
23
|
headers: { "content-type": "application/json" },
|
|
23
|
-
body: JSON.stringify({ accept }),
|
|
24
|
+
body: JSON.stringify({ accept, oauth_query: signedOAuthQuery(page.url.search) }),
|
|
24
25
|
});
|
|
25
26
|
const data = (await res.json().catch(() => ({}))) as { url?: string; redirectURI?: string };
|
|
26
27
|
const next = data.url ?? data.redirectURI;
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { goto } from "$app/navigation";
|
|
3
|
-
import QRCode from "qrcode";
|
|
4
3
|
import { Button } from "#lib/components/ui/button/index.js";
|
|
5
4
|
import { Input } from "#lib/components/ui/input/index.js";
|
|
6
5
|
import { Label } from "#lib/components/ui/label/index.js";
|
|
@@ -21,13 +20,20 @@
|
|
|
21
20
|
|
|
22
21
|
$effect(() => {
|
|
23
22
|
const uri = setup?.totpURI;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.then((url) =>
|
|
30
|
-
|
|
23
|
+
qrDataUrl = "";
|
|
24
|
+
if (!uri) return;
|
|
25
|
+
let active = true;
|
|
26
|
+
import("qrcode")
|
|
27
|
+
.then(({ toDataURL }) => toDataURL(uri, { margin: 1, width: 176 }))
|
|
28
|
+
.then((url) => {
|
|
29
|
+
if (active && setup?.totpURI === uri) qrDataUrl = url;
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
if (active) qrDataUrl = "";
|
|
33
|
+
});
|
|
34
|
+
return () => {
|
|
35
|
+
active = false;
|
|
36
|
+
};
|
|
31
37
|
});
|
|
32
38
|
|
|
33
39
|
async function begin(): Promise<void> {
|
package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md
CHANGED
|
@@ -44,5 +44,6 @@ put pagination outside the table.
|
|
|
44
44
|
above the DataTable. Filters + search apply together on the Search button/Enter
|
|
45
45
|
(don't query on every keystroke); the search field is a `select` of columns.
|
|
46
46
|
|
|
47
|
-
The `/admin/users` page is the reference implementation
|
|
47
|
+
The `/admin/users` page is the reference implementation (server offset paging);
|
|
48
|
+
`/admin/sessions` shows keyset paging with a cursor. See `docs/development.md`
|
|
48
49
|
("Data tables", "Search & filters") on the PodoKit repo.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { APIRequestContext, PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
3
|
+
import { ADMIN, USER } from "../helpers/accounts";
|
|
4
|
+
|
|
5
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
|
+
const origin = { origin: base };
|
|
7
|
+
const PASSWORD = "Podokit3e-Str0ng!pw";
|
|
8
|
+
// The rate-limit module's default ceiling for /api/auth/* per window. Listing
|
|
9
|
+
// sessions through Better Auth took 1 + one request per user from that budget;
|
|
10
|
+
// the sessions endpoint takes one general request per page.
|
|
11
|
+
const DEFAULT_AUTH_RATE_LIMIT = 20;
|
|
12
|
+
|
|
13
|
+
type SessionItem = {
|
|
14
|
+
id: string;
|
|
15
|
+
userId: string;
|
|
16
|
+
userEmail: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
type SessionPage = { items: SessionItem[]; nextCursor: string | null; total: number };
|
|
21
|
+
|
|
22
|
+
async function signedIn(
|
|
23
|
+
playwright: PlaywrightWorkerArgs["playwright"],
|
|
24
|
+
account: { email: string; password: string },
|
|
25
|
+
): Promise<APIRequestContext> {
|
|
26
|
+
const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
27
|
+
expect((await context.post("/api/auth/sign-in/email", { data: account })).ok()).toBeTruthy();
|
|
28
|
+
return context;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("admin pages through every user's session with a few requests outside the auth budget", async ({
|
|
32
|
+
playwright,
|
|
33
|
+
disposableUsers,
|
|
34
|
+
}) => {
|
|
35
|
+
test.setTimeout(180_000);
|
|
36
|
+
const prefix = `sess-page-${Date.now()}`;
|
|
37
|
+
const userCount = 27;
|
|
38
|
+
const pageSize = 10;
|
|
39
|
+
const userIds = new Set<string>();
|
|
40
|
+
const signIns = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
41
|
+
for (let index = 0; index < userCount; index += 1) {
|
|
42
|
+
const email = `${prefix}-${String(index).padStart(2, "0")}@example.com`;
|
|
43
|
+
userIds.add(await disposableUsers.create({ email, name: `Session pager ${index}` }));
|
|
44
|
+
expect((await signIns.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).ok())
|
|
45
|
+
.toBeTruthy();
|
|
46
|
+
}
|
|
47
|
+
await signIns.dispose();
|
|
48
|
+
|
|
49
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
50
|
+
const seen: SessionItem[] = [];
|
|
51
|
+
let requests = 0;
|
|
52
|
+
let cursor: string | null = null;
|
|
53
|
+
do {
|
|
54
|
+
const query = new URLSearchParams({ limit: String(pageSize), search: prefix });
|
|
55
|
+
if (cursor) query.set("cursor", cursor);
|
|
56
|
+
const response = await admin.get(`/api/admin/sessions?${query.toString()}`);
|
|
57
|
+
requests += 1;
|
|
58
|
+
expect(response.status()).toBe(200);
|
|
59
|
+
const remaining = response.headers()["ratelimit-remaining"];
|
|
60
|
+
if (remaining !== undefined) expect(Number(remaining)).toBeGreaterThan(0);
|
|
61
|
+
const page = (await response.json()) as SessionPage;
|
|
62
|
+
expect(page.total).toBe(userCount);
|
|
63
|
+
for (const item of page.items) expect(item).not.toHaveProperty("token");
|
|
64
|
+
seen.push(...page.items);
|
|
65
|
+
cursor = page.nextCursor;
|
|
66
|
+
} while (cursor && requests <= userCount);
|
|
67
|
+
|
|
68
|
+
expect(requests).toBe(Math.ceil(userCount / pageSize));
|
|
69
|
+
expect(requests).toBeLessThan(DEFAULT_AUTH_RATE_LIMIT);
|
|
70
|
+
expect(new Set(seen.map((item) => item.id)).size).toBe(userCount);
|
|
71
|
+
expect(new Set(seen.map((item) => item.userId))).toEqual(userIds);
|
|
72
|
+
const created = seen.map((item) => Date.parse(item.createdAt));
|
|
73
|
+
expect(created).toEqual([...created].sort((left, right) => right - left));
|
|
74
|
+
await admin.dispose();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a normal user cannot list or revoke sessions", async ({ playwright }) => {
|
|
78
|
+
const user = await signedIn(playwright, USER);
|
|
79
|
+
expect((await user.get("/api/admin/sessions")).status()).toBe(403);
|
|
80
|
+
expect((await user.delete("/api/admin/sessions/any-session")).status()).toBe(403);
|
|
81
|
+
await user.dispose();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("admin revokes a session by id and the action is audited", async ({ playwright, disposableUsers }) => {
|
|
85
|
+
const email = `sess-revoke-${Date.now()}@example.com`;
|
|
86
|
+
const userId = await disposableUsers.create({ email, name: "Revoke by id" });
|
|
87
|
+
const theirs = await signedIn(playwright, { email, password: PASSWORD });
|
|
88
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
89
|
+
|
|
90
|
+
const listed = await admin.get(`/api/admin/sessions?userId=${encodeURIComponent(userId)}`);
|
|
91
|
+
expect(listed.ok()).toBeTruthy();
|
|
92
|
+
const page = (await listed.json()) as SessionPage;
|
|
93
|
+
expect(page.items).toHaveLength(1);
|
|
94
|
+
const sessionId = page.items[0]?.id ?? "";
|
|
95
|
+
|
|
96
|
+
const revoked = await admin.delete(`/api/admin/sessions/${encodeURIComponent(sessionId)}`);
|
|
97
|
+
expect(revoked.status()).toBe(204);
|
|
98
|
+
expect(await (await theirs.get("/api/auth/get-session")).json()).toBeNull();
|
|
99
|
+
expect((await admin.delete(`/api/admin/sessions/${encodeURIComponent(sessionId)}`)).status()).toBe(404);
|
|
100
|
+
|
|
101
|
+
// The revoke runs through Better Auth, so the audit-log module records it when
|
|
102
|
+
// the module is installed and auditing is on.
|
|
103
|
+
const capabilities = (await (await admin.get("/api/account/capabilities")).json()) as { auditLog?: boolean };
|
|
104
|
+
const audit = await admin.get("/api/audit-logs");
|
|
105
|
+
if (capabilities.auditLog === true && audit.ok()) {
|
|
106
|
+
const body = (await audit.json()) as unknown;
|
|
107
|
+
const entries = (Array.isArray(body) ? body : (body as { items?: unknown[] }).items ?? []) as Array<{
|
|
108
|
+
action?: string;
|
|
109
|
+
actorEmail?: string | null;
|
|
110
|
+
}>;
|
|
111
|
+
expect(entries.some((entry) => entry.action === "user.session_revoke" && entry.actorEmail === ADMIN.email))
|
|
112
|
+
.toBeTruthy();
|
|
113
|
+
}
|
|
114
|
+
await theirs.dispose();
|
|
115
|
+
await admin.dispose();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a forged cursor is rejected", async ({ playwright }) => {
|
|
119
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
120
|
+
const response = await admin.get("/api/admin/sessions?cursor=forged");
|
|
121
|
+
expect(response.status()).toBe(400);
|
|
122
|
+
expect((await response.json())?.error?.code).toBe("ADMIN_SESSION_CURSOR_INVALID");
|
|
123
|
+
await admin.dispose();
|
|
124
|
+
});
|