@podosoft/podokit 1.2.0 → 1.4.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 +13 -2
- package/dist/add.js +135 -20
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +23 -2
- package/dist/lib.d.ts +4 -2
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +26 -6
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -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 +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- 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 +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- 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-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 +5 -0
- 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/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -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/public-paths.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
- 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/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
- 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 +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- 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 +16 -37
- 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/migrate.ts +3 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/modules/sse/module.manifest.json +3 -1
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +22 -0
- package/dist/update.js +68 -8
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { APP_ASSET_PATHS } from "./asset-paths";
|
|
2
|
+
|
|
3
|
+
/** The request fields that decide whether a response can use the session and site settings. */
|
|
4
|
+
export interface RuntimeContextRequest {
|
|
5
|
+
url: URL;
|
|
6
|
+
route: { id: string | null };
|
|
7
|
+
request: Request;
|
|
8
|
+
isDataRequest: boolean;
|
|
9
|
+
isRemoteRequest: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Fetch destinations that SvelteKit answers with a plain-text 404, without the
|
|
13
|
+
// root layout, when no route matches.
|
|
14
|
+
const SUBRESOURCE_DESTINATIONS = new Set([
|
|
15
|
+
"audio",
|
|
16
|
+
"audioworklet",
|
|
17
|
+
"font",
|
|
18
|
+
"image",
|
|
19
|
+
"json",
|
|
20
|
+
"manifest",
|
|
21
|
+
"paintworklet",
|
|
22
|
+
"report",
|
|
23
|
+
"script",
|
|
24
|
+
"serviceworker",
|
|
25
|
+
"sharedworker",
|
|
26
|
+
"style",
|
|
27
|
+
"track",
|
|
28
|
+
"video",
|
|
29
|
+
"webidentity",
|
|
30
|
+
"worker",
|
|
31
|
+
"xslt",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function underPrefix(pathname: string, prefix: string): boolean {
|
|
35
|
+
return pathname === prefix || (prefix !== "/" && pathname.startsWith(`${prefix}/`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when an Accept header ranks HTML ahead of the wildcard. This mirrors how
|
|
40
|
+
* SvelteKit chooses between a page and an endpoint on the same route.
|
|
41
|
+
*/
|
|
42
|
+
export function prefersHtml(accept: string | null): boolean {
|
|
43
|
+
const ranges = (accept ?? "*/*")
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((part, index) => {
|
|
46
|
+
const [range = "", ...params] = part.trim().toLowerCase().split(";");
|
|
47
|
+
const quality = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
|
|
48
|
+
return { range: range.trim(), q: quality ? Number(quality.slice(2)) : 1, index };
|
|
49
|
+
})
|
|
50
|
+
.filter(({ range, q }) => range.includes("/") && q > 0)
|
|
51
|
+
.sort((a, b) => b.q - a.q || specificity(b.range) - specificity(a.range) || a.index - b.index);
|
|
52
|
+
const html = ranges.findIndex(({ range }) => range === "text/html" || range === "text/*");
|
|
53
|
+
const wildcard = ranges.findIndex(({ range }) => range === "*/*");
|
|
54
|
+
return html !== -1 && (wildcard === -1 || html < wildcard);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function specificity(range: string): number {
|
|
58
|
+
if (range === "*/*") return 0;
|
|
59
|
+
return range.endsWith("/*") ? 1 : 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Which files the matched route has, from `routes` in `$app/manifest`. */
|
|
63
|
+
export interface RouteFiles {
|
|
64
|
+
page: boolean;
|
|
65
|
+
endpoint: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when SvelteKit answers the request with the route's `+page` rather than its `+server`. */
|
|
69
|
+
function answeredByPage(event: RuntimeContextRequest, route: RouteFiles | undefined): boolean {
|
|
70
|
+
if (route && !route.page) return false;
|
|
71
|
+
if (route && !route.endpoint) return true;
|
|
72
|
+
if (event.isDataRequest) return true;
|
|
73
|
+
const { headers, method } = event.request;
|
|
74
|
+
if (method !== "GET" && method !== "HEAD" && method !== "POST") return false;
|
|
75
|
+
if (method === "POST" && headers.get("x-sveltekit-action") === "true") return true;
|
|
76
|
+
return prefersHtml(headers.get("accept"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether hooks.server.ts must load the session and the site settings
|
|
81
|
+
* before SvelteKit answers the request. It skips them only when the response
|
|
82
|
+
* cannot render a layout, or when the application listed the path in
|
|
83
|
+
* APP_ASSET_PATHS and a `+server` endpoint, not a `+page`, answers it.
|
|
84
|
+
*/
|
|
85
|
+
export function needsRuntimeContext(
|
|
86
|
+
event: RuntimeContextRequest,
|
|
87
|
+
options: { building?: boolean; route?: RouteFiles } = {},
|
|
88
|
+
): boolean {
|
|
89
|
+
// Prerendering runs at build time without the API, and the stored HTML must
|
|
90
|
+
// not depend on one visitor's session or on runtime site settings.
|
|
91
|
+
if (options.building) return false;
|
|
92
|
+
const { pathname } = event.url;
|
|
93
|
+
// The backend proxy forwards the cookies; the API resolves the session itself.
|
|
94
|
+
if (underPrefix(pathname, "/api")) return false;
|
|
95
|
+
// Remote functions run application code that may read the session.
|
|
96
|
+
if (event.isRemoteRequest) return true;
|
|
97
|
+
if (event.route.id === null) {
|
|
98
|
+
// SvelteKit renders its 404 page with the root layout, which redirects or
|
|
99
|
+
// greets the visitor, unless the browser fetched a subresource.
|
|
100
|
+
const destination = event.request.headers.get("sec-fetch-dest") ?? "";
|
|
101
|
+
return !SUBRESOURCE_DESTINATIONS.has(destination);
|
|
102
|
+
}
|
|
103
|
+
if (APP_ASSET_PATHS.some((prefix) => underPrefix(pathname, prefix))) {
|
|
104
|
+
return answeredByPage(event, options.route);
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { toast } from "svelte-sonner";
|
|
18
18
|
import { api } from "#lib/api.js";
|
|
19
19
|
import { listAdminSessions, revokeAdminSession, type AdminSession } from "#lib/admin/sessions.js";
|
|
20
|
-
import { DEFAULT_USER_SORT,
|
|
20
|
+
import { DEFAULT_USER_SORT, adminUsersQuery, listAdminUsers } from "#lib/admin/users.js";
|
|
21
21
|
import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
|
|
22
22
|
import { cn } from "#lib/utils.js";
|
|
23
23
|
import { deviceLabel } from "#lib/device-label.js";
|
|
@@ -64,19 +64,6 @@
|
|
|
64
64
|
let appliedSearchField = $state("email");
|
|
65
65
|
let filterValues = $state<Record<string, string>>({ role: "", status: "" });
|
|
66
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
|
-
});
|
|
80
67
|
|
|
81
68
|
const columns = $derived<DataTableColumn<Row>[]>([
|
|
82
69
|
{ key: "name", label: i18n.t.users.name, sortable: true },
|
|
@@ -103,6 +90,7 @@
|
|
|
103
90
|
label: i18n.t.users.status,
|
|
104
91
|
options: [
|
|
105
92
|
{ value: "", label: i18n.t.toolbar.all },
|
|
93
|
+
{ value: "active", label: i18n.t.users.active },
|
|
106
94
|
{ value: "pending", label: i18n.t.users.pendingApproval },
|
|
107
95
|
{ value: "banned", label: i18n.t.users.banned },
|
|
108
96
|
],
|
|
@@ -118,7 +106,7 @@
|
|
|
118
106
|
const request = ++latestRequest;
|
|
119
107
|
const sortKey = sort ? `${sort.key}:${sort.dir}` : "";
|
|
120
108
|
if (sortKey !== loadedSortKey) page = 1;
|
|
121
|
-
const query =
|
|
109
|
+
const query = adminUsersQuery({
|
|
122
110
|
page,
|
|
123
111
|
perPage: PAGE_SIZE,
|
|
124
112
|
sort,
|
|
@@ -128,12 +116,18 @@
|
|
|
128
116
|
status: appliedFilters.status ?? "",
|
|
129
117
|
});
|
|
130
118
|
loading = true;
|
|
131
|
-
|
|
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
|
+
}
|
|
132
127
|
if (request !== latestRequest) return;
|
|
133
128
|
loading = false;
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
const count = res?.total ?? rows.length;
|
|
129
|
+
const rows = result.items;
|
|
130
|
+
const count = result.total;
|
|
137
131
|
// An action can empty the last page; show the one before it instead.
|
|
138
132
|
const lastPage = Math.max(Math.ceil(count / PAGE_SIZE), 1);
|
|
139
133
|
if (rows.length === 0 && page > lastPage) {
|
|
@@ -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
|
+
});
|
|
@@ -31,6 +31,7 @@ test("admin can enable Google OAuth from the DB and it applies without a restart
|
|
|
31
31
|
});
|
|
32
32
|
expect(put.ok()).toBeTruthy();
|
|
33
33
|
const view = await put.json();
|
|
34
|
+
expect(view.social.google.enabled).toBe(true);
|
|
34
35
|
expect(view.social.google.hasSecret).toBe(true);
|
|
35
36
|
expect(view.social.google.clientId).toBe("dummy-google-client-id");
|
|
36
37
|
expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
|
|
@@ -291,7 +291,7 @@ test("a user can create, list and revoke a personal API key", async ({ playwrigh
|
|
|
291
291
|
const body = (await (await ctx.get("/api/auth/api-key/list")).json()) as { apiKeys: Array<{ id: string; name: string | null }> };
|
|
292
292
|
expect(body.apiKeys.some((k) => k.name === "ci")).toBeTruthy();
|
|
293
293
|
|
|
294
|
-
const del = await ctx.post("/api/auth/api-key/delete", { data: { keyId: body.apiKeys[0]
|
|
294
|
+
const del = await ctx.post("/api/auth/api-key/delete", { data: { keyId: body.apiKeys[0]?.id } });
|
|
295
295
|
expect(del.ok()).toBeTruthy();
|
|
296
296
|
await ctx.dispose();
|
|
297
297
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ADMIN } from "../helpers/accounts";
|
|
3
|
+
import { expectObjectLength } from "../helpers/object-response";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
const origin = { origin: base };
|
|
@@ -48,7 +49,7 @@ test("profile image upload, replacement, public read, and removal", async ({ pla
|
|
|
48
49
|
expect(firstImage.ok()).toBeTruthy();
|
|
49
50
|
expect(firstImage.headers()["content-type"]).toContain("image/png");
|
|
50
51
|
expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
|
|
51
|
-
|
|
52
|
+
expectObjectLength(firstImage.headers(), PNG.byteLength);
|
|
52
53
|
expect((await firstImage.body()).equals(PNG)).toBe(true);
|
|
53
54
|
const etag = firstImage.headers()["etag"];
|
|
54
55
|
expect(etag).toMatch(/^"[^"]+"$/);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ADMIN } from "../helpers/accounts";
|
|
3
|
+
import { expectObjectLength } from "../helpers/object-response";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
const origin = { origin: base };
|
|
@@ -101,7 +102,7 @@ test("site favicon: the versioned URL is cached for good and other URLs revalida
|
|
|
101
102
|
expect(current.status()).toBe(200);
|
|
102
103
|
expect(current.headers()["cache-control"]).toBe(IMMUTABLE);
|
|
103
104
|
expect(current.headers()["content-type"]).toContain("image/png");
|
|
104
|
-
|
|
105
|
+
expectObjectLength(current.headers(), ICON.byteLength);
|
|
105
106
|
expect((await current.body()).equals(ICON)).toBe(true);
|
|
106
107
|
const etag = current.headers()["etag"] ?? "";
|
|
107
108
|
expect(etag).toMatch(/^"[^"]+"$/);
|
|
@@ -15,14 +15,17 @@ type CreateUserInput = {
|
|
|
15
15
|
role?: string;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// UI specs pass the browser's create-user response; API specs pass a request response.
|
|
19
|
+
type CreatedUserResponse = Pick<APIResponse, "ok" | "json">;
|
|
20
|
+
|
|
18
21
|
export type DisposableUsers = {
|
|
19
22
|
create(input: CreateUserInput): Promise<string>;
|
|
20
|
-
trackResponse(response:
|
|
23
|
+
trackResponse(response: CreatedUserResponse): Promise<string>;
|
|
21
24
|
track(userId: string): void;
|
|
22
25
|
forget(userId: string): void;
|
|
23
26
|
};
|
|
24
27
|
|
|
25
|
-
async function readCreatedUserId(response:
|
|
28
|
+
async function readCreatedUserId(response: CreatedUserResponse): Promise<string> {
|
|
26
29
|
expect(response.ok()).toBeTruthy();
|
|
27
30
|
const body = (await response.json()) as { user?: { id?: unknown } };
|
|
28
31
|
expect(typeof body.user?.id).toBe("string");
|
|
@@ -32,16 +35,16 @@ async function readCreatedUserId(response: APIResponse): Promise<string> {
|
|
|
32
35
|
export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
33
36
|
disposableUsers: async ({ context, playwright }, use, testInfo) => {
|
|
34
37
|
// API projects do not inherit the UI project's admin storageState, while a
|
|
35
|
-
// second admin sign-in can invalidate the shared UI session.
|
|
36
|
-
// isolated request context
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// second admin sign-in can invalidate the shared UI session. API tests sign in
|
|
39
|
+
// an isolated request context. UI tests copy the browser's admin cookies into
|
|
40
|
+
// their own context once, so a test that later signs its page in as another
|
|
41
|
+
// user does not turn cleanup into that user's (forbidden) requests.
|
|
42
|
+
const request = await playwright.request.newContext({
|
|
43
|
+
baseURL,
|
|
44
|
+
extraHTTPHeaders: origin,
|
|
45
|
+
...(testInfo.project.name === "api" ? {} : { storageState: await context.storageState() }),
|
|
46
|
+
});
|
|
39
47
|
if (testInfo.project.name === "api") {
|
|
40
|
-
request = await playwright.request.newContext({
|
|
41
|
-
baseURL,
|
|
42
|
-
extraHTTPHeaders: origin,
|
|
43
|
-
});
|
|
44
|
-
ownsRequest = true;
|
|
45
48
|
const signIn = await request.post("/api/auth/sign-in/email", {
|
|
46
49
|
data: { email: ADMIN.email, password: ADMIN.password },
|
|
47
50
|
});
|
|
@@ -91,7 +94,7 @@ export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
} finally {
|
|
94
|
-
|
|
97
|
+
await request.dispose();
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// IN_MEMORY_READ_BYTES of @podosoft/podokit-runtime. Both object storage
|
|
4
|
+
// providers and the web proxy keep the Content-Length of objects up to this
|
|
5
|
+
// size; Bun may send larger ones chunked.
|
|
6
|
+
const IN_MEMORY_READ_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export function expectObjectLength(headers: Record<string, string>, size: number): void {
|
|
9
|
+
const length = headers["content-length"];
|
|
10
|
+
if (length === undefined && size > IN_MEMORY_READ_BYTES) return;
|
|
11
|
+
expect(length).toBe(String(size));
|
|
12
|
+
}
|
|
@@ -2,7 +2,7 @@ import { expect, test, type Response } from "@playwright/test";
|
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
// Sorting via the shared DataTable. Users sorts on the server (manual mode
|
|
5
|
-
// refetches
|
|
5
|
+
// refetches GET /admin/users), and the audit log asks the server
|
|
6
6
|
// for the other time order — both drive the same clickable headers + aria-sort.
|
|
7
7
|
test("users table sorts by a column header (server)", async ({ page }) => {
|
|
8
8
|
await ready(page, "/admin/users");
|
|
@@ -13,7 +13,7 @@ test("users table sorts by a column header (server)", async ({ page }) => {
|
|
|
13
13
|
const firstName = (): Promise<string> => page.getByRole("row").nth(1).getByRole("cell").first().innerText();
|
|
14
14
|
const sortedBy = (direction: string): Promise<Response> => page.waitForResponse((response) => {
|
|
15
15
|
const url = new URL(response.url());
|
|
16
|
-
return url.pathname === "/api/
|
|
16
|
+
return url.pathname === "/api/admin/users" &&
|
|
17
17
|
url.searchParams.get("sortBy") === "name" &&
|
|
18
18
|
url.searchParams.get("sortDirection") === direction;
|
|
19
19
|
});
|
|
@@ -3,7 +3,6 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
|
|
4
4
|
|
|
5
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
|
-
const origin = { origin: base };
|
|
7
6
|
|
|
8
7
|
// Verification badges/actions are gated by the emailVerification capability. The
|
|
9
8
|
// seeded suite runs with verification off, so assert the gating here (nothing
|
|
@@ -28,9 +28,7 @@ test("forgot password: request, open the emailed link, set a new password, sign
|
|
|
28
28
|
await expect(page.getByText(/reset link is on its way/i)).toBeVisible();
|
|
29
29
|
|
|
30
30
|
// Open the emailed link: the API validates it and redirects to the reset page.
|
|
31
|
-
await page
|
|
32
|
-
await page.waitForLoadState("load");
|
|
33
|
-
await page.waitForTimeout(400);
|
|
31
|
+
await ready(page, await waitForLink(email));
|
|
34
32
|
// `exact` because "Confirm new password" also contains "New password".
|
|
35
33
|
await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
|
|
36
34
|
// A mismatch is refused, and the single-use token survives it: the same page
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
3
4
|
|
|
4
5
|
// admin storageState (project default) — the admin is the resource owner.
|
|
5
6
|
const b64url = (b: Buffer): string => b.toString("base64url");
|
|
@@ -64,7 +65,8 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", a
|
|
|
64
65
|
// The consent page renders (reached in a real browser navigation), and allowing
|
|
65
66
|
// it sends the browser back to the client with an authorization code.
|
|
66
67
|
await page.route(`${REDIRECT}**`, (route) => route.fulfill({ status: 200, body: "callback" }));
|
|
67
|
-
|
|
68
|
+
// Wait for hydration: a click on the server-rendered button sends no consent.
|
|
69
|
+
await ready(page, consentUrl);
|
|
68
70
|
const allow = page.getByRole("button", { name: "Allow" });
|
|
69
71
|
await expect(allow).toBeVisible();
|
|
70
72
|
const callback = page.waitForURL((url) => url.href.startsWith(REDIRECT));
|
|
@@ -21,26 +21,25 @@ test("client navigation keeps the server checks without resending messages", asy
|
|
|
21
21
|
await ready(page, "/admin");
|
|
22
22
|
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
|
23
23
|
|
|
24
|
-
const bodies: Promise<string>[] = [];
|
|
25
|
-
page.on("response", (response) => {
|
|
26
|
-
if (new URL(response.url()).pathname.endsWith("/__data.json")) bodies.push(response.text());
|
|
27
|
-
});
|
|
28
24
|
const sidebar = page.locator('[data-sidebar="sidebar"]');
|
|
29
|
-
const stops: Array<[link: string, heading: string]> = [
|
|
30
|
-
["Users", "Users"],
|
|
31
|
-
["Sessions", "User sessions"],
|
|
32
|
-
["Settings", "Settings"],
|
|
33
|
-
["Overview", "Dashboard"],
|
|
25
|
+
const stops: Array<[link: string, path: string, heading: string]> = [
|
|
26
|
+
["Users", "/admin/users", "Users"],
|
|
27
|
+
["Sessions", "/admin/sessions", "User sessions"],
|
|
28
|
+
["Settings", "/admin/settings", "Settings"],
|
|
29
|
+
["Overview", "/admin", "Dashboard"],
|
|
34
30
|
];
|
|
35
|
-
for (const [link, heading] of stops) {
|
|
31
|
+
for (const [link, path, heading] of stops) {
|
|
32
|
+
// Read each body before the next click: once the page navigates on, the
|
|
33
|
+
// previous response body is no longer available.
|
|
34
|
+
const data = page.waitForResponse(
|
|
35
|
+
(response) => new URL(response.url()).pathname === `${path}/__data.json`,
|
|
36
|
+
);
|
|
36
37
|
await sidebar.getByRole("link", { name: link, exact: true }).click();
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
expect(bodies.length).toBeGreaterThanOrEqual(stops.length);
|
|
41
|
-
for (const body of await Promise.all(bodies)) {
|
|
38
|
+
const body = await (await data).text();
|
|
42
39
|
expect(body).not.toContain('"messages"');
|
|
43
40
|
expect(body.length).toBeLessThan(20_000);
|
|
41
|
+
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
|
|
44
42
|
}
|
|
43
|
+
|
|
45
44
|
await expect(sidebar.getByRole("link", { name: "Back to home" })).toBeVisible();
|
|
46
45
|
});
|
|
@@ -20,31 +20,36 @@ test("users toolbar applies filter + search together on Search", async ({ page }
|
|
|
20
20
|
// Selecting a filter stages it; nothing applies until Search is pressed.
|
|
21
21
|
const filtered = page.waitForResponse((response) => {
|
|
22
22
|
const url = new URL(response.url());
|
|
23
|
-
return url.pathname === "/api/
|
|
23
|
+
return url.pathname === "/api/admin/users" && url.searchParams.get("role") === "admin";
|
|
24
24
|
});
|
|
25
25
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
26
|
-
expect(
|
|
26
|
+
expect((await filtered).ok()).toBeTruthy();
|
|
27
27
|
await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
28
28
|
await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test("users toolbar
|
|
31
|
+
test("users toolbar combines the role and status filters", async ({ page }) => {
|
|
32
32
|
await ready(page, "/admin/users");
|
|
33
|
+
const main = page.getByRole("main");
|
|
33
34
|
const roleSelect = page.locator('button[data-slot="select-trigger"]').first();
|
|
34
35
|
const statusSelect = page.locator('button[data-slot="select-trigger"]').nth(1);
|
|
35
36
|
await roleSelect.click();
|
|
36
37
|
await page.getByRole("option", { name: "Admin", exact: true }).click();
|
|
37
38
|
await statusSelect.click();
|
|
38
|
-
await page.getByRole("option", { name: "
|
|
39
|
-
await expect(roleSelect).toContainText("
|
|
40
|
-
await expect(statusSelect).toContainText("
|
|
39
|
+
await page.getByRole("option", { name: "Active", exact: true }).click();
|
|
40
|
+
await expect(roleSelect).toContainText("Admin");
|
|
41
|
+
await expect(statusSelect).toContainText("Active");
|
|
41
42
|
|
|
42
43
|
const filtered = page.waitForResponse((response) => {
|
|
43
44
|
const url = new URL(response.url());
|
|
44
|
-
return url.pathname === "/api/
|
|
45
|
+
return url.pathname === "/api/admin/users" &&
|
|
46
|
+
url.searchParams.get("role") === "admin" &&
|
|
47
|
+
url.searchParams.get("status") === "active";
|
|
45
48
|
});
|
|
46
49
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
47
|
-
expect(
|
|
50
|
+
expect((await filtered).ok()).toBeTruthy();
|
|
51
|
+
await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
52
|
+
await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
test("users toolbar can pick the search field", async ({ page }) => {
|
|
@@ -140,7 +140,7 @@ test("users list shows pagination beyond one page", async ({ page, disposableUse
|
|
|
140
140
|
await expect(nav).toBeVisible();
|
|
141
141
|
const secondPage = page.waitForResponse((response) => {
|
|
142
142
|
const url = new URL(response.url());
|
|
143
|
-
return url.pathname === "/api/
|
|
143
|
+
return url.pathname === "/api/admin/users" && url.searchParams.get("offset") === "15";
|
|
144
144
|
});
|
|
145
145
|
await nav.getByRole("button", { name: "Page 2", exact: true }).first().click();
|
|
146
146
|
expect((await secondPage).ok()).toBeTruthy();
|
|
@@ -44,7 +44,9 @@ test("admin sees the user list and can search", async ({ page }) => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
test("the user list asks the server for one page at a time", async ({ page }) => {
|
|
47
|
-
const isListUsers = (url: string): boolean => new URL(url).pathname === "/api/
|
|
47
|
+
const isListUsers = (url: string): boolean => new URL(url).pathname === "/api/admin/users";
|
|
48
|
+
const requested: string[] = [];
|
|
49
|
+
page.on("request", (request) => requested.push(request.url()));
|
|
48
50
|
const first = page.waitForResponse((response) => isListUsers(response.url()));
|
|
49
51
|
await ready(page, "/admin/users");
|
|
50
52
|
const initial = new URL((await first).url()).searchParams;
|
|
@@ -53,14 +55,16 @@ test("the user list asks the server for one page at a time", async ({ page }) =>
|
|
|
53
55
|
expect(initial.get("sortDirection")).toBe("desc");
|
|
54
56
|
await expect(page.getByRole("columnheader", { name: "Joined" })).toHaveAttribute("aria-sort", "descending");
|
|
55
57
|
|
|
58
|
+
// The server matches case-insensitively, so the search goes as typed.
|
|
56
59
|
const searched = page.waitForResponse((response) => isListUsers(response.url()));
|
|
57
60
|
await page.locator("#toolbar-search").fill("Admin@Example.com");
|
|
58
61
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
59
62
|
const query = new URL((await searched).url()).searchParams;
|
|
60
|
-
expect(query.get("
|
|
63
|
+
expect(query.get("search")).toBe("Admin@Example.com");
|
|
61
64
|
expect(query.get("searchField")).toBe("email");
|
|
62
65
|
expect(query.get("offset")).toBe("0");
|
|
63
66
|
await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
67
|
+
expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
|
|
64
68
|
});
|
|
65
69
|
|
|
66
70
|
test("user list shows the joined column", async ({ page }) => {
|
|
@@ -69,9 +73,16 @@ test("user list shows the joined column", async ({ page }) => {
|
|
|
69
73
|
});
|
|
70
74
|
|
|
71
75
|
test("row menu exposes admin actions", async ({ page }) => {
|
|
72
|
-
|
|
76
|
+
// The list shows one server page, so the search must run before the row exists.
|
|
77
|
+
await ready(page, "/admin/users");
|
|
78
|
+
const searched = page.waitForResponse((response) => {
|
|
79
|
+
const url = new URL(response.url());
|
|
80
|
+
return url.pathname === "/api/admin/users" &&
|
|
81
|
+
url.searchParams.get("search") === "user@example.com";
|
|
82
|
+
});
|
|
73
83
|
await page.locator("#toolbar-search").fill("user@example.com");
|
|
74
84
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
85
|
+
expect((await searched).ok()).toBeTruthy();
|
|
75
86
|
const row = page.getByRole("row", { name: /user@example.com/ });
|
|
76
87
|
await row.getByRole("button").click();
|
|
77
88
|
await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
},
|
|
14
14
|
"ownedGlobs": [
|
|
15
15
|
"apps/web/src/lib/i18n/locales/**",
|
|
16
|
-
"apps/web/src/lib/i18n/catalogs/app/**"
|
|
16
|
+
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
17
|
+
"apps/web/src/lib/server/public-paths.ts",
|
|
18
|
+
"apps/web/src/lib/server/asset-paths.ts"
|
|
17
19
|
],
|
|
18
20
|
"managedOverrides": [
|
|
19
21
|
"apps/web/src/lib/components/ui/input/input.svelte",
|
|
@@ -117,7 +119,7 @@
|
|
|
117
119
|
"file": "AGENTS.md",
|
|
118
120
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
119
121
|
"optional": true,
|
|
120
|
-
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
122
|
+
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
121
123
|
},
|
|
122
124
|
{
|
|
123
125
|
"file": "apps/api/src/app.ts",
|