@podosoft/podokit 1.1.6 → 1.3.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/add.d.ts +3 -1
- package/dist/add.js +116 -14
- package/dist/deploy-render.js +5 -0
- package/dist/index.js +8 -0
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +3 -2
- package/dist/lockfile.d.ts +3 -1
- package/dist/lockfile.js +17 -6
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- 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 +10 -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/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- 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/database/migrate.ts +1 -1
- 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/package.json +1 -1
- 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-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
- 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/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -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-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- 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 +102 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -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/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -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/lib/server/public-paths.ts +9 -0
- 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 +79 -37
- 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/admin-users.api.spec.ts +86 -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-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
- 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/helpers/disposable-users.ts +5 -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/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -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 +29 -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 +32 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- 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 +126 -3
- 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 +57 -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.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
- 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/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -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/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- 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 +250 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -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 +227 -70
- package/dist/templates/modules/sse/module.manifest.json +20 -5
- package/dist/update.d.ts +17 -0
- package/dist/update.js +33 -8
- package/package.json +2 -2
|
@@ -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, adminUsersQuery, listAdminUsers } 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.
|
|
@@ -93,32 +101,50 @@
|
|
|
93
101
|
{ value: "name", label: i18n.t.users.name },
|
|
94
102
|
]);
|
|
95
103
|
|
|
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
|
-
|
|
104
|
+
// Loads only the current page; a newer request supersedes one still in flight.
|
|
110
105
|
async function load(): Promise<void> {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
const request = ++latestRequest;
|
|
107
|
+
const sortKey = sort ? `${sort.key}:${sort.dir}` : "";
|
|
108
|
+
if (sortKey !== loadedSortKey) page = 1;
|
|
109
|
+
const query = adminUsersQuery({
|
|
110
|
+
page,
|
|
111
|
+
perPage: PAGE_SIZE,
|
|
112
|
+
sort,
|
|
113
|
+
search: appliedSearch,
|
|
114
|
+
searchField: appliedSearchField,
|
|
115
|
+
role: appliedFilters.role ?? "",
|
|
116
|
+
status: appliedFilters.status ?? "",
|
|
117
|
+
});
|
|
118
|
+
loading = true;
|
|
119
|
+
let result: { items: Row[]; total: number };
|
|
120
|
+
try {
|
|
121
|
+
result = await listAdminUsers(api, query);
|
|
122
|
+
} catch {
|
|
123
|
+
if (request !== latestRequest) return;
|
|
124
|
+
loading = false;
|
|
125
|
+
return void toast.error(i18n.t.users.loadFailed);
|
|
126
|
+
}
|
|
127
|
+
if (request !== latestRequest) return;
|
|
128
|
+
loading = false;
|
|
129
|
+
const rows = result.items;
|
|
130
|
+
const count = result.total;
|
|
131
|
+
// An action can empty the last page; show the one before it instead.
|
|
132
|
+
const lastPage = Math.max(Math.ceil(count / PAGE_SIZE), 1);
|
|
133
|
+
if (rows.length === 0 && page > lastPage) {
|
|
134
|
+
page = lastPage;
|
|
135
|
+
return load();
|
|
136
|
+
}
|
|
137
|
+
loadedSortKey = sortKey;
|
|
138
|
+
users = rows;
|
|
139
|
+
total = count;
|
|
114
140
|
}
|
|
115
141
|
|
|
116
|
-
|
|
117
|
-
await load(); // refresh from the server on explicit search, then filter client-side
|
|
142
|
+
function applySearch(): void {
|
|
118
143
|
appliedSearch = search;
|
|
119
144
|
appliedSearchField = searchField;
|
|
120
145
|
appliedFilters = { ...filterValues };
|
|
121
146
|
page = 1;
|
|
147
|
+
void load();
|
|
122
148
|
}
|
|
123
149
|
|
|
124
150
|
// Impersonate — become the user, then continue on the signed-in account page
|
|
@@ -176,7 +202,6 @@
|
|
|
176
202
|
|
|
177
203
|
// Manage user — a two-pane modal mirroring the account settings, admin-scoped.
|
|
178
204
|
type ManageSection = "profile" | "security" | "sessions" | "danger";
|
|
179
|
-
type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
|
|
180
205
|
let manageOpen = $state(false);
|
|
181
206
|
let mUser = $state<Row | null>(null);
|
|
182
207
|
let mSection = $state<ManageSection>("profile");
|
|
@@ -192,10 +217,11 @@
|
|
|
192
217
|
let mBanReason = $state("");
|
|
193
218
|
let mBanDays = $state("");
|
|
194
219
|
// sessions
|
|
195
|
-
let mSessions = $state<
|
|
220
|
+
let mSessions = $state<AdminSession[]>([]);
|
|
221
|
+
let mSessionsTotal = $state(0);
|
|
196
222
|
let mSessionsPage = $state(1);
|
|
197
223
|
let mSessionsSort = $state<SortState | null>(null);
|
|
198
|
-
const mSessionsColumns: DataTableColumn<
|
|
224
|
+
const mSessionsColumns: DataTableColumn<AdminSession>[] = [
|
|
199
225
|
{ key: "userAgent", label: i18n.t.adminSessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
|
|
200
226
|
{ key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
|
|
201
227
|
{ key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
|
|
@@ -217,6 +243,7 @@
|
|
|
217
243
|
mBanDays = "";
|
|
218
244
|
mDeleteArmed = false;
|
|
219
245
|
mSessions = [];
|
|
246
|
+
mSessionsTotal = 0;
|
|
220
247
|
mSessionsPage = 1;
|
|
221
248
|
manageOpen = true;
|
|
222
249
|
await loadManagedSessions();
|
|
@@ -224,8 +251,15 @@
|
|
|
224
251
|
|
|
225
252
|
async function loadManagedSessions(): Promise<void> {
|
|
226
253
|
if (!mUser) return;
|
|
227
|
-
const
|
|
228
|
-
|
|
254
|
+
const userId = mUser.id;
|
|
255
|
+
try {
|
|
256
|
+
const result = await listAdminSessions(api, { limit: MANAGED_SESSIONS_LIMIT, userId });
|
|
257
|
+
if (mUser?.id !== userId) return;
|
|
258
|
+
mSessions = result.items;
|
|
259
|
+
mSessionsTotal = result.total;
|
|
260
|
+
} catch {
|
|
261
|
+
toast.error(i18n.t.adminSessions.loadFailed);
|
|
262
|
+
}
|
|
229
263
|
}
|
|
230
264
|
|
|
231
265
|
async function saveProfile(event: Event): Promise<void> {
|
|
@@ -329,11 +363,15 @@
|
|
|
329
363
|
await load();
|
|
330
364
|
}
|
|
331
365
|
|
|
332
|
-
async function revokeSession(
|
|
366
|
+
async function revokeSession(id: string): Promise<void> {
|
|
333
367
|
busy = true;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
368
|
+
try {
|
|
369
|
+
await revokeAdminSession(api, id);
|
|
370
|
+
} catch {
|
|
371
|
+
return void toast.error(i18n.t.adminSessions.revokeFailed);
|
|
372
|
+
} finally {
|
|
373
|
+
busy = false;
|
|
374
|
+
}
|
|
337
375
|
toast.success(i18n.t.adminSessions.revoked);
|
|
338
376
|
await loadManagedSessions();
|
|
339
377
|
}
|
|
@@ -343,7 +381,7 @@
|
|
|
343
381
|
busy = true;
|
|
344
382
|
const { error } = await api.auth.admin.revokeUserSessions({ userId: mUser.id });
|
|
345
383
|
busy = false;
|
|
346
|
-
if (error) return void toast.error(error.message ?? i18n.t.adminSessions.
|
|
384
|
+
if (error) return void toast.error(error.message ?? i18n.t.adminSessions.revokeFailed);
|
|
347
385
|
toast.success(i18n.t.adminSessions.allRevoked);
|
|
348
386
|
await loadManagedSessions();
|
|
349
387
|
}
|
|
@@ -360,7 +398,7 @@
|
|
|
360
398
|
}
|
|
361
399
|
|
|
362
400
|
$effect(() => {
|
|
363
|
-
void load();
|
|
401
|
+
untrack(() => void load());
|
|
364
402
|
});
|
|
365
403
|
</script>
|
|
366
404
|
|
|
@@ -383,14 +421,18 @@
|
|
|
383
421
|
|
|
384
422
|
<DataTable
|
|
385
423
|
{columns}
|
|
386
|
-
rows={
|
|
424
|
+
rows={users}
|
|
387
425
|
getKey={(u) => u.id}
|
|
388
|
-
empty={i18n.t.users.empty}
|
|
426
|
+
empty={loading ? "…" : i18n.t.users.empty}
|
|
389
427
|
bind:sort
|
|
390
428
|
bind:page
|
|
429
|
+
manualSort
|
|
430
|
+
manualPagination
|
|
391
431
|
perPage={PAGE_SIZE}
|
|
432
|
+
{total}
|
|
392
433
|
ariaLabel={i18n.t.users.title}
|
|
393
|
-
label={fmt(i18n.t.users.total, { count:
|
|
434
|
+
label={fmt(i18n.t.users.total, { count: total })}
|
|
435
|
+
onChange={() => void load()}
|
|
394
436
|
>
|
|
395
437
|
{#snippet row(user, { cellClass })}
|
|
396
438
|
<Table.Cell class={cn(cellClass("name"), "font-medium")}>{user.name}</Table.Cell>
|
|
@@ -555,14 +597,14 @@
|
|
|
555
597
|
bind:sort={mSessionsSort}
|
|
556
598
|
bind:page={mSessionsPage}
|
|
557
599
|
perPage={PAGE_SIZE}
|
|
558
|
-
label={fmt(i18n.t.adminSessions.total, { count:
|
|
600
|
+
label={fmt(i18n.t.adminSessions.total, { count: mSessionsTotal })}
|
|
559
601
|
empty={i18n.t.adminSessions.empty}
|
|
560
602
|
>
|
|
561
603
|
{#snippet row(s)}
|
|
562
604
|
<Table.Cell class="max-w-40 truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
563
605
|
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
564
606
|
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
565
|
-
<Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.
|
|
607
|
+
<Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.id)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
|
|
566
608
|
{/snippet}
|
|
567
609
|
</DataTable>
|
|
568
610
|
</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
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
|
|
8
|
+
type UserItem = {
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
role: string | null;
|
|
12
|
+
banned: boolean;
|
|
13
|
+
signupApproved: boolean;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
};
|
|
16
|
+
type UserPage = { items: UserItem[]; total: number };
|
|
17
|
+
|
|
18
|
+
async function signedIn(
|
|
19
|
+
playwright: PlaywrightWorkerArgs["playwright"],
|
|
20
|
+
account: { email: string; password: string },
|
|
21
|
+
): Promise<APIRequestContext> {
|
|
22
|
+
const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
23
|
+
expect((await context.post("/api/auth/sign-in/email", { data: account })).ok()).toBeTruthy();
|
|
24
|
+
return context;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function list(admin: APIRequestContext, query: Record<string, string>): Promise<UserPage> {
|
|
28
|
+
const response = await admin.get(`/api/admin/users?${new URLSearchParams(query).toString()}`);
|
|
29
|
+
expect(response.status(), await response.text()).toBe(200);
|
|
30
|
+
return (await response.json()) as UserPage;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("the admin user list combines role and status filters with a search", async ({
|
|
34
|
+
playwright,
|
|
35
|
+
disposableUsers,
|
|
36
|
+
}) => {
|
|
37
|
+
const prefix = `users-filter-${Date.now()}`;
|
|
38
|
+
const ids = {
|
|
39
|
+
activeAdmin: await disposableUsers.create({ email: `${prefix}-a@example.com`, role: "admin" }),
|
|
40
|
+
bannedAdmin: await disposableUsers.create({ email: `${prefix}-b@example.com`, role: "admin" }),
|
|
41
|
+
activeUser: await disposableUsers.create({ email: `${prefix}-c@example.com` }),
|
|
42
|
+
pendingUser: await disposableUsers.create({ email: `${prefix}-d@example.com` }),
|
|
43
|
+
};
|
|
44
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
45
|
+
try {
|
|
46
|
+
expect((await admin.post("/api/auth/admin/ban-user", { data: { userId: ids.bannedAdmin } })).ok())
|
|
47
|
+
.toBeTruthy();
|
|
48
|
+
expect((await admin.post("/api/auth/admin/update-user", {
|
|
49
|
+
data: { userId: ids.pendingUser, data: { signupApproved: false } },
|
|
50
|
+
})).ok()).toBeTruthy();
|
|
51
|
+
|
|
52
|
+
const matching = async (role: string, status: string): Promise<string[]> =>
|
|
53
|
+
(await list(admin, { search: prefix.toUpperCase(), role, status })).items.map((user) => user.id);
|
|
54
|
+
expect(await matching("admin", "active")).toEqual([ids.activeAdmin]);
|
|
55
|
+
expect(await matching("admin", "banned")).toEqual([ids.bannedAdmin]);
|
|
56
|
+
expect(await matching("user", "pending")).toEqual([ids.pendingUser]);
|
|
57
|
+
expect(await matching("user", "active")).toEqual([ids.activeUser]);
|
|
58
|
+
|
|
59
|
+
const all = await list(admin, { search: prefix, limit: "2", sortBy: "email", sortDirection: "asc" });
|
|
60
|
+
expect(all.total).toBe(4);
|
|
61
|
+
expect(all.items.map((user) => user.id)).toEqual([ids.activeAdmin, ids.bannedAdmin]);
|
|
62
|
+
const banned = all.items[1];
|
|
63
|
+
expect(banned?.banned).toBe(true);
|
|
64
|
+
expect(banned?.signupApproved).toBe(true);
|
|
65
|
+
expect(banned).not.toHaveProperty("password");
|
|
66
|
+
} finally {
|
|
67
|
+
await admin.dispose();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the admin user list is for administrators and validates its query", async ({ playwright }) => {
|
|
72
|
+
const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
73
|
+
const user = await signedIn(playwright, USER);
|
|
74
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
75
|
+
try {
|
|
76
|
+
expect((await anonymous.get("/api/admin/users")).status()).toBe(401);
|
|
77
|
+
expect((await user.get("/api/admin/users")).status()).toBe(403);
|
|
78
|
+
for (const query of ["status=deleted", "sortBy=password", "limit=101"]) {
|
|
79
|
+
expect((await admin.get(`/api/admin/users?${query}`)).status(), query).toBe(400);
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
await anonymous.dispose();
|
|
83
|
+
await user.dispose();
|
|
84
|
+
await admin.dispose();
|
|
85
|
+
}
|
|
86
|
+
});
|