@podosoft/podokit 1.5.0 → 1.6.1
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/README.md +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -18
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +27 -21
- package/dist/deploy-schema.d.ts +60 -2
- package/dist/deploy-schema.js +141 -14
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +74 -4
- package/dist/provider.js +10 -5
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +30 -4
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +72 -4
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
- package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +13 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +5 -2
- package/dist/templates/fullstack/dot-env.example +4 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
- package/dist/templates/fullstack/tests/README.md +50 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
- package/dist/templates/fullstack/tests/package.json +3 -3
- package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
- package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
- package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +53 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +14 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +107 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +174 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
- package/dist/templates/todo/AGENTS.md +5 -1
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type { PlaywrightTestConfig } from "@playwright/test";
|
|
4
|
+
import type { FullConfig, PlaywrightTestConfig } from "@playwright/test";
|
|
5
5
|
|
|
6
6
|
type Projects = NonNullable<PlaywrightTestConfig["projects"]>;
|
|
7
|
+
type Project = Projects[number];
|
|
7
8
|
|
|
8
9
|
const requireFromTests = createRequire(__filename);
|
|
9
10
|
|
|
11
|
+
// A test that changes state the other tests rely on, such as a server-wide
|
|
12
|
+
// setting or the profile of a seeded account, takes the `@exclusive` tag:
|
|
13
|
+
//
|
|
14
|
+
// test("maintenance mode holds visitors back", { tag: "@exclusive" }, async ({ page }) => { ... });
|
|
15
|
+
//
|
|
16
|
+
// Playwright matches project grep patterns against the title path followed by
|
|
17
|
+
// the test's tags, separated by spaces.
|
|
18
|
+
const TAGGED = /(?:^|\s)@exclusive(?=\s|$)/;
|
|
19
|
+
const UNTAGGED = /^(?![\s\S]*(?:^|\s)@exclusive(?=\s|$))/;
|
|
20
|
+
const EVERY_FILE = /.*/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One of the two Playwright runs `run-e2e.ts` makes with more than one worker:
|
|
24
|
+
* `shared` runs the tests not tagged `@exclusive`, `exclusive` the tagged ones.
|
|
25
|
+
*/
|
|
26
|
+
export type E2ePhase = "shared" | "exclusive";
|
|
27
|
+
|
|
10
28
|
function projectName(project: unknown): string | null {
|
|
11
29
|
if (typeof project !== "object" || project === null || !("name" in project)) return null;
|
|
12
30
|
return typeof project.name === "string" && project.name.length > 0 ? project.name : null;
|
|
13
31
|
}
|
|
14
32
|
|
|
33
|
+
function patterns(value: Project["grepInvert"]): RegExp[] {
|
|
34
|
+
if (value === undefined) return [];
|
|
35
|
+
return Array.isArray(value) ? value : [value];
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
/**
|
|
16
39
|
* Merge application-owned Playwright projects into PodoKit's managed config.
|
|
17
40
|
* The optional CommonJS file stays owned so applications can add devices and
|
|
@@ -42,3 +65,128 @@ export function loadPlaywrightProjects(coreProjects: Projects): Projects {
|
|
|
42
65
|
|
|
43
66
|
return loaded as Projects;
|
|
44
67
|
}
|
|
68
|
+
|
|
69
|
+
/** The number of Playwright workers: `E2E_WORKERS`, or 1 when it is unset. */
|
|
70
|
+
export function e2eWorkers(value = process.env.E2E_WORKERS): number {
|
|
71
|
+
if (value === undefined || value.trim() === "") return 1;
|
|
72
|
+
const workers = Number(value);
|
|
73
|
+
if (!Number.isInteger(workers) || workers < 1) {
|
|
74
|
+
throw new Error(`E2E_WORKERS must be a whole number of at least 1, got "${value}".`);
|
|
75
|
+
}
|
|
76
|
+
return workers;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The run `run-e2e.ts` started, from `E2E_PHASE`, or undefined for a single run. */
|
|
80
|
+
export function e2ePhase(value = process.env.E2E_PHASE): E2ePhase | undefined {
|
|
81
|
+
if (value === undefined || value === "") return undefined;
|
|
82
|
+
if (value === "shared" || value === "exclusive") return value;
|
|
83
|
+
throw new Error(`E2E_PHASE must be "shared" or "exclusive", got "${value}".`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Workers and output folders for this Playwright run. The exclusive phase runs
|
|
88
|
+
* on one worker and keeps its results and HTML report in `exclusive/` folders,
|
|
89
|
+
* because each run empties its own.
|
|
90
|
+
*/
|
|
91
|
+
export function e2eRunSettings(
|
|
92
|
+
phase = e2ePhase(),
|
|
93
|
+
workers = e2eWorkers(),
|
|
94
|
+
): { phase: E2ePhase | undefined; workers: number; outputDir: string; reportFolder: string } {
|
|
95
|
+
return phase === "exclusive"
|
|
96
|
+
? { phase, workers: 1, outputDir: "test-results/exclusive", reportFolder: "playwright-report/exclusive" }
|
|
97
|
+
: { phase, workers, outputDir: "test-results", reportFolder: "playwright-report" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Playwright runs for `run-e2e.ts` with these command-line arguments. One
|
|
102
|
+
* worker needs a single run. With more workers, the tests not tagged
|
|
103
|
+
* `@exclusive` run first and the tagged ones second, in a run of their own that
|
|
104
|
+
* starts even when the first one failed. Either run may find no tests: a filter
|
|
105
|
+
* can select tests of one kind only, and an app may have no exclusive tests.
|
|
106
|
+
*/
|
|
107
|
+
export function e2eRuns(args: string[], workers = e2eWorkers()): Array<{ phase?: E2ePhase; args: string[] }> {
|
|
108
|
+
if (workers === 1) return [{ args }];
|
|
109
|
+
return [
|
|
110
|
+
{ phase: "shared", args: args.length > 0 ? ["--pass-with-no-tests", ...args] : args },
|
|
111
|
+
{ phase: "exclusive", args: ["--pass-with-no-tests", ...args] },
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Splits each test project into one that skips tests tagged `@exclusive` and an
|
|
117
|
+
* `<name>-exclusive` twin that runs only them on one worker. Projects that
|
|
118
|
+
* another project depends on, or that tear one down, are setup and cleanup
|
|
119
|
+
* projects and stay as they are.
|
|
120
|
+
*
|
|
121
|
+
* With one worker no ordering is needed. With more, `run-e2e.ts` runs
|
|
122
|
+
* Playwright twice: in the `shared` phase the twins find no tests, and in the
|
|
123
|
+
* `exclusive` phase, on one worker, the other test projects find none. A single
|
|
124
|
+
* run with more than one worker (no phase) makes the twins depend on every
|
|
125
|
+
* other test project, and each twin on the one before it, so an exclusive test
|
|
126
|
+
* still never runs alongside another test; but there a failing test skips every
|
|
127
|
+
* twin after it, and a filter that selects exclusive tests runs all other tests
|
|
128
|
+
* first.
|
|
129
|
+
*/
|
|
130
|
+
export function withExclusivePhase(projects: Projects, workers: number, phase?: E2ePhase): Projects {
|
|
131
|
+
const support = new Set(
|
|
132
|
+
projects.flatMap((project) => [...(project.dependencies ?? []), ...(project.teardown ? [project.teardown] : [])]),
|
|
133
|
+
);
|
|
134
|
+
const tested = projects.filter((project) => !support.has(projectName(project) ?? ""));
|
|
135
|
+
const shared = new Map(
|
|
136
|
+
tested.map((project) => [
|
|
137
|
+
project,
|
|
138
|
+
{
|
|
139
|
+
...project,
|
|
140
|
+
grepInvert: [...patterns(project.grepInvert), TAGGED],
|
|
141
|
+
...(phase === "exclusive" ? { testIgnore: EVERY_FILE } : {}),
|
|
142
|
+
},
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const chained = workers > 1 && phase === undefined;
|
|
147
|
+
let before = [...shared.values()].map((project) => projectName(project) ?? "");
|
|
148
|
+
const twins = tested.map((project) => {
|
|
149
|
+
const name = `${projectName(project) ?? ""}-exclusive`;
|
|
150
|
+
const twin: Project = {
|
|
151
|
+
...project,
|
|
152
|
+
name,
|
|
153
|
+
grepInvert: [...patterns(project.grepInvert), UNTAGGED],
|
|
154
|
+
workers: 1,
|
|
155
|
+
dependencies: chained ? [...(project.dependencies ?? []), ...before] : project.dependencies,
|
|
156
|
+
...(phase === "shared" ? { testIgnore: EVERY_FILE } : {}),
|
|
157
|
+
};
|
|
158
|
+
before = [name];
|
|
159
|
+
return twin;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = [...projects.map((project) => shared.get(project) ?? project), ...twins];
|
|
163
|
+
const names = new Set<string>();
|
|
164
|
+
for (const project of result) {
|
|
165
|
+
const name = projectName(project) ?? "";
|
|
166
|
+
if (names.has(name)) throw new Error(`Duplicate Playwright project name: ${name}`);
|
|
167
|
+
names.add(name);
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Global setup: refuses more workers than `E2E_WORKERS` allows for. `--workers`
|
|
174
|
+
* changes the count after the projects were built, so with it the exclusive
|
|
175
|
+
* twins would run alongside the other tests. Warns when more than one worker
|
|
176
|
+
* runs without `run-e2e.ts`, where a failing test skips the exclusive tests.
|
|
177
|
+
*/
|
|
178
|
+
export default function checkWorkers(config: FullConfig, warn = console.warn): void {
|
|
179
|
+
if (config.workers > 1 && e2eWorkers() === 1) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Set the worker count with E2E_WORKERS=${config.workers} instead of --workers. ` +
|
|
182
|
+
"Tests tagged @exclusive run after the others only when E2E_WORKERS is above 1.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (config.workers > 1 && e2ePhase() === undefined) {
|
|
186
|
+
warn(
|
|
187
|
+
"E2E_WORKERS is above 1 in a single Playwright run: a failing test makes Playwright skip " +
|
|
188
|
+
"the tests tagged @exclusive. Run the suite with `bun run test:e2e` (run-e2e.ts), which " +
|
|
189
|
+
"runs them in a second run instead.",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { e2eRuns } from "./playwright.extensions";
|
|
3
|
+
|
|
4
|
+
// Runs the suite with the arguments it is given, such as `--grep` or a file.
|
|
5
|
+
// With E2E_WORKERS above 1, Playwright runs twice: the tests not tagged
|
|
6
|
+
// @exclusive on that many workers, then the tagged ones on one worker. The
|
|
7
|
+
// second run starts even when the first one failed, so a failing test cannot
|
|
8
|
+
// hide the exclusive tests, and the exit code is that of the first failed run.
|
|
9
|
+
const runs = e2eRuns(process.argv.slice(2));
|
|
10
|
+
let status = 0;
|
|
11
|
+
for (const run of runs) {
|
|
12
|
+
if (run.phase) {
|
|
13
|
+
console.log(
|
|
14
|
+
run.phase === "shared"
|
|
15
|
+
? "\nRunning the tests not tagged @exclusive\n"
|
|
16
|
+
: "\nRunning the tests tagged @exclusive on one worker (report: playwright-report/exclusive)\n",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const result = spawnSync("bunx", ["playwright", "test", ...run.args], {
|
|
20
|
+
stdio: "inherit",
|
|
21
|
+
env: run.phase ? { ...process.env, E2E_PHASE: run.phase } : process.env,
|
|
22
|
+
});
|
|
23
|
+
if (result.error) throw result.error;
|
|
24
|
+
// Playwright exits with 130 when it is interrupted: stop instead of starting the next run.
|
|
25
|
+
if (result.signal !== null || result.status === 130) process.exit(result.status ?? 130);
|
|
26
|
+
if (status === 0) status = result.status ?? 1;
|
|
27
|
+
}
|
|
28
|
+
process.exit(status);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { paths } from "../helpers/paths";
|
|
2
3
|
|
|
3
|
-
// Module-agnostic: the app
|
|
4
|
+
// Module-agnostic: the app's first screen responds (a page or a redirect to it)
|
|
5
|
+
// and renders. Its locator comes from tests/helpers/app-paths.ts.
|
|
4
6
|
test("app is reachable @smoke", async ({ page }) => {
|
|
5
|
-
const res = await page.goto(
|
|
7
|
+
const res = await page.goto(paths.home);
|
|
6
8
|
expect(res?.status()).toBeLessThan(400);
|
|
7
|
-
await expect(
|
|
8
|
-
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
9
|
+
await expect(paths.homeReady(page)).toBeVisible();
|
|
9
10
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ApiError } from "@podosoft/podokit-api-client";
|
|
2
2
|
import type { RequestEvent } from "@sveltejs/kit";
|
|
3
|
+
import type { Handle } from "@sveltejs/kit/hooks";
|
|
3
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
5
|
import { BackendUnavailableError } from "#lib/server/api.js";
|
|
5
6
|
import { handle } from "./hooks.server";
|
|
@@ -14,12 +15,18 @@ const client = vi.hoisted(() => ({
|
|
|
14
15
|
getSession: vi.fn<() => Promise<SessionResult>>(),
|
|
15
16
|
}));
|
|
16
17
|
|
|
18
|
+
// Stands in for the application's own hook in the app-owned app-hooks.ts.
|
|
19
|
+
const appHook = vi.hoisted(() => ({
|
|
20
|
+
handle: vi.fn<Handle>(({ event, resolve }) => resolve(event)),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("#lib/server/app-hooks.js", () => ({ appHandle: appHook.handle }));
|
|
23
|
+
|
|
17
24
|
vi.mock("#lib/server/api.js", async (importOriginal) => ({
|
|
18
25
|
...(await importOriginal<typeof import("#lib/server/api.js")>()),
|
|
19
26
|
serverApiClient: () => ({ get: client.get, auth: { getSession: client.getSession } }),
|
|
20
27
|
}));
|
|
21
28
|
|
|
22
|
-
const resolve = vi.fn(async (): Promise<Response> => new Response("ok"));
|
|
29
|
+
const resolve = vi.fn(async (_event: RequestEvent): Promise<Response> => new Response("ok"));
|
|
23
30
|
|
|
24
31
|
function requestEvent(pathname: string, cookies: Record<string, string> = {}): RequestEvent {
|
|
25
32
|
return {
|
|
@@ -40,6 +47,7 @@ describe("handle", () => {
|
|
|
40
47
|
client.get.mockReset();
|
|
41
48
|
client.getSession.mockReset();
|
|
42
49
|
resolve.mockClear();
|
|
50
|
+
appHook.handle.mockClear();
|
|
43
51
|
});
|
|
44
52
|
|
|
45
53
|
it("requests the session without waiting for site settings", async () => {
|
|
@@ -144,4 +152,32 @@ describe("handle", () => {
|
|
|
144
152
|
expect(client.getSession).not.toHaveBeenCalled();
|
|
145
153
|
expect(resolve).toHaveBeenCalledTimes(1);
|
|
146
154
|
});
|
|
155
|
+
|
|
156
|
+
it("runs the application hook with the loaded runtime context", async () => {
|
|
157
|
+
client.get.mockResolvedValue({ locale: null });
|
|
158
|
+
client.getSession.mockResolvedValue(signedIn);
|
|
159
|
+
let seenUser: unknown;
|
|
160
|
+
appHook.handle.mockImplementationOnce(({ event, resolve: next }) => {
|
|
161
|
+
seenUser = event.locals.user;
|
|
162
|
+
event.locals.user = { id: "local", email: "local@localhost", name: "Local", role: "admin" };
|
|
163
|
+
return next(event);
|
|
164
|
+
});
|
|
165
|
+
const event = requestEvent("/account");
|
|
166
|
+
|
|
167
|
+
const response = await handle({ event, resolve });
|
|
168
|
+
|
|
169
|
+
expect(seenUser).toEqual({ id: "user-1" });
|
|
170
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(resolve.mock.calls[0]?.[0].locals.user).toMatchObject({ id: "local", role: "admin" });
|
|
172
|
+
expect(response.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("lets the application hook answer a request itself", async () => {
|
|
176
|
+
appHook.handle.mockImplementationOnce(() => new Response("teapot", { status: 418 }));
|
|
177
|
+
|
|
178
|
+
const response = await handle({ event: requestEvent("/api/site/settings"), resolve });
|
|
179
|
+
|
|
180
|
+
expect(response.status).toBe(418);
|
|
181
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
147
183
|
});
|
|
@@ -3,6 +3,7 @@ import type { Handle } from "@sveltejs/kit/hooks";
|
|
|
3
3
|
import { building } from "$app/env";
|
|
4
4
|
import { routes } from "$app/manifest";
|
|
5
5
|
import { serverApiClient } from "#lib/server/api.js";
|
|
6
|
+
import { appHandle } from "#lib/server/app-hooks.js";
|
|
6
7
|
import { resolveLocale } from "#lib/i18n/messages.js";
|
|
7
8
|
import { applySearchIndexingHeaders } from "#lib/server/search-indexing.js";
|
|
8
9
|
import { isPublicPath } from "#lib/server/guards.js";
|
|
@@ -91,12 +92,20 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
91
92
|
} else {
|
|
92
93
|
setEmptyRuntimeContext(event);
|
|
93
94
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
// The application's own hook from the app-owned app-hooks.ts runs next, with
|
|
96
|
+
// the runtime context loaded. Its page transform runs before the language one.
|
|
97
|
+
const response = await appHandle({
|
|
98
|
+
event,
|
|
99
|
+
resolve: (appEvent, options) =>
|
|
100
|
+
resolve(appEvent, {
|
|
101
|
+
...options,
|
|
102
|
+
transformPageChunk: async ({ html, done }) => {
|
|
103
|
+
const transformed = options?.transformPageChunk
|
|
104
|
+
? ((await options.transformPageChunk({ html, done })) ?? "")
|
|
105
|
+
: html;
|
|
106
|
+
return transformed.replace(/(<html\b[^>]*?\slang=)(["'])[^"']*\2/i, `$1"${event.locals.locale}"`);
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
100
109
|
});
|
|
101
110
|
return applySearchIndexingHeaders(response, event.url.pathname);
|
|
102
111
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { Messages } from "#lib/i18n/messages.js";
|
|
3
|
+
import { appFlagCards } from "./app-flag-settings";
|
|
4
|
+
|
|
5
|
+
// Stands in for the application's own list in the app-owned app-flags.ts.
|
|
6
|
+
vi.mock("./app-flags", () => ({
|
|
7
|
+
APP_FLAG_SETTINGS: [
|
|
8
|
+
{ flag: "reportsEnabled", label: () => "Reports", description: () => "Show the reports page." },
|
|
9
|
+
{ flag: "betaSearch", label: () => "Beta search" },
|
|
10
|
+
],
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const t = {} as Messages;
|
|
14
|
+
|
|
15
|
+
describe("application flag settings", () => {
|
|
16
|
+
it("shows each listed flag with the value the API reports", () => {
|
|
17
|
+
expect(appFlagCards(t, { appFlags: { reportsEnabled: true, betaSearch: false, hidden: true } })).toEqual([
|
|
18
|
+
{ flag: "reportsEnabled", name: "Reports", description: "Show the reports page.", available: true, on: true },
|
|
19
|
+
{ flag: "betaSearch", name: "Beta search", description: "", available: true, on: false },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("marks a flag the API does not report as unavailable", () => {
|
|
24
|
+
expect(appFlagCards(t, { appFlags: { reportsEnabled: false } })[1]).toMatchObject({
|
|
25
|
+
flag: "betaSearch",
|
|
26
|
+
available: false,
|
|
27
|
+
on: false,
|
|
28
|
+
});
|
|
29
|
+
expect(appFlagCards(t, undefined).every((card) => !card.available)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
2
|
+
import type { Messages } from "#lib/i18n/messages.js";
|
|
3
|
+
import { APP_FLAG_SETTINGS } from "./app-flags";
|
|
4
|
+
import type { AppFlagSetting } from "./registry.svelte.js";
|
|
5
|
+
|
|
6
|
+
export type AppFlagCard = {
|
|
7
|
+
flag: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
/** False when the API does not report the flag, for example a name missing from APP_FLAG_DEFAULTS. */
|
|
11
|
+
available: boolean;
|
|
12
|
+
on: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** One card per listed flag, with the value `GET /account/capabilities` reports in `appFlags`. */
|
|
16
|
+
export function appFlagCards(
|
|
17
|
+
t: Messages,
|
|
18
|
+
capabilities: Pick<Capabilities, "appFlags"> | undefined,
|
|
19
|
+
settings: readonly AppFlagSetting[] = APP_FLAG_SETTINGS,
|
|
20
|
+
): AppFlagCard[] {
|
|
21
|
+
return settings.map(({ flag, label, description }) => {
|
|
22
|
+
const value = capabilities?.appFlags?.[flag];
|
|
23
|
+
return {
|
|
24
|
+
flag,
|
|
25
|
+
name: label(t),
|
|
26
|
+
description: description?.(t) ?? "",
|
|
27
|
+
available: typeof value === "boolean",
|
|
28
|
+
on: value === true,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AppFlagSetting } from "./registry.svelte.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Application flags the admin Settings page shows, one switch each, on an
|
|
5
|
+
* Application tab after PodoKit's tabs. `flag` is a key of `APP_FLAG_DEFAULTS`
|
|
6
|
+
* in the API's owned `apps/api/src/settings/app-flags.ts`; the label and
|
|
7
|
+
* description come from the app-owned `i18n/catalogs/app/*.json`, for example
|
|
8
|
+
* `{ flag: "reportsEnabled", label: (t) => t.reports.flag, description: (t) => t.reports.flagDescription }`.
|
|
9
|
+
* The tab is hidden while the list is empty. PodoKit creates this file once and
|
|
10
|
+
* never overwrites it.
|
|
11
|
+
*/
|
|
12
|
+
export const APP_FLAG_SETTINGS: readonly AppFlagSetting[] = [];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminNavEntry } from "./registry.svelte.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Application-owned admin sidebar entries. The sidebar shows them after PodoKit's
|
|
5
|
+
* own pages and before module entries and Settings, for example
|
|
6
|
+
* `{ href: "/admin/reports", label: (t) => t.reports.nav, icon: ChartColumnIcon, adminOnly: true }`
|
|
7
|
+
* with the label in the app-owned `i18n/catalogs/app/*.json` and the icon from
|
|
8
|
+
* `@lucide/svelte/icons/*`. `adminOnly` hides an entry from other roles.
|
|
9
|
+
* PodoKit creates this file once and never overwrites it.
|
|
10
|
+
*/
|
|
11
|
+
export const APP_ADMIN_NAV: readonly AdminNavEntry[] = [];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import FileTextIcon from "@lucide/svelte/icons/file-text";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { adminNavEntries } from "./nav";
|
|
4
|
+
|
|
5
|
+
// Stands in for the application's own entries in the app-owned app-nav.ts.
|
|
6
|
+
vi.mock("./app-nav", async () => {
|
|
7
|
+
const { default: icon } = await import("@lucide/svelte/icons/file-text");
|
|
8
|
+
return {
|
|
9
|
+
APP_ADMIN_NAV: [
|
|
10
|
+
{ href: "/admin/specs", label: () => "Specs", icon, adminOnly: true },
|
|
11
|
+
{ href: "/admin/help", label: () => "Help", icon },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Stands in for the entries installed modules add to registry.svelte.ts, so the
|
|
17
|
+
// expected order does not depend on which modules an application installs.
|
|
18
|
+
vi.mock("./registry.svelte.js", async () => {
|
|
19
|
+
const { default: icon } = await import("@lucide/svelte/icons/newspaper");
|
|
20
|
+
return {
|
|
21
|
+
moduleNavEntries: [
|
|
22
|
+
{ href: "/admin/blog", label: () => "Blog", icon, adminOnly: true },
|
|
23
|
+
{ href: "/admin/reports", label: () => "Reports", icon },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("admin navigation", () => {
|
|
29
|
+
it("places the application's entries, then module entries, before Settings", () => {
|
|
30
|
+
const hrefs = adminNavEntries({ auditLog: true }, "admin").map((entry) => entry.href);
|
|
31
|
+
|
|
32
|
+
expect(hrefs).toEqual([
|
|
33
|
+
"/admin",
|
|
34
|
+
"/admin/users",
|
|
35
|
+
"/admin/sessions",
|
|
36
|
+
"/admin/audit",
|
|
37
|
+
"/admin/specs",
|
|
38
|
+
"/admin/help",
|
|
39
|
+
"/admin/blog",
|
|
40
|
+
"/admin/reports",
|
|
41
|
+
"/admin/settings",
|
|
42
|
+
]);
|
|
43
|
+
expect(adminNavEntries(undefined, "admin").find((entry) => entry.href === "/admin/specs")?.icon).toBe(
|
|
44
|
+
FileTextIcon,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("hides admin-only application and module entries from other roles", () => {
|
|
49
|
+
const hrefs = adminNavEntries(undefined, "moderator").map((entry) => entry.href);
|
|
50
|
+
|
|
51
|
+
expect(hrefs).toEqual(["/admin", "/admin/help", "/admin/reports"]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
|
|
2
|
+
import UsersIcon from "@lucide/svelte/icons/users";
|
|
3
|
+
import MonitorSmartphoneIcon from "@lucide/svelte/icons/monitor-smartphone";
|
|
4
|
+
import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
|
|
5
|
+
import Building2Icon from "@lucide/svelte/icons/building-2";
|
|
6
|
+
import SettingsIcon from "@lucide/svelte/icons/settings";
|
|
7
|
+
import { APP_ADMIN_NAV } from "./app-nav";
|
|
8
|
+
import { moduleNavEntries, type AdminNavEntry } from "./registry.svelte.js";
|
|
9
|
+
|
|
10
|
+
export type AdminNavCapabilities = { auditLog?: boolean; organization?: boolean };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The admin sidebar entries a user may open: PodoKit's pages, the application's
|
|
14
|
+
* entries from the app-owned app-nav.ts, module entries, then Settings.
|
|
15
|
+
*/
|
|
16
|
+
export function adminNavEntries(
|
|
17
|
+
capabilities: AdminNavCapabilities | undefined,
|
|
18
|
+
role: string | null | undefined,
|
|
19
|
+
): AdminNavEntry[] {
|
|
20
|
+
const entries: AdminNavEntry[] = [
|
|
21
|
+
{ href: "/admin", label: (t) => t.nav.overview, icon: LayoutDashboardIcon },
|
|
22
|
+
{ href: "/admin/users", label: (t) => t.nav.users, icon: UsersIcon, adminOnly: true },
|
|
23
|
+
{ href: "/admin/sessions", label: (t) => t.nav.sessions, icon: MonitorSmartphoneIcon, adminOnly: true },
|
|
24
|
+
...(capabilities?.organization
|
|
25
|
+
? [{ href: "/admin/organizations", label: (t) => t.nav.organizations, icon: Building2Icon, adminOnly: true } satisfies AdminNavEntry]
|
|
26
|
+
: []),
|
|
27
|
+
...(capabilities?.auditLog
|
|
28
|
+
? [{ href: "/admin/audit", label: (t) => t.nav.audit, icon: ScrollTextIcon, adminOnly: true } satisfies AdminNavEntry]
|
|
29
|
+
: []),
|
|
30
|
+
...APP_ADMIN_NAV,
|
|
31
|
+
...moduleNavEntries,
|
|
32
|
+
{ href: "/admin/settings", label: (t) => t.nav.settings, icon: SettingsIcon, adminOnly: true },
|
|
33
|
+
];
|
|
34
|
+
return entries.filter((entry) => !entry.adminOnly || role === "admin");
|
|
35
|
+
}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Component } from "svelte";
|
|
2
|
+
import AppFlagsSettings from "#lib/components/settings/app-flags-settings.svelte";
|
|
2
3
|
import type { Messages } from "#lib/i18n/messages.js";
|
|
4
|
+
import { APP_FLAG_SETTINGS } from "./app-flags";
|
|
3
5
|
// Modules import their nav-entry icons here.
|
|
4
6
|
// podokit:begin:admin-nav-imports
|
|
5
7
|
// podokit:end:admin-nav-imports
|
|
@@ -26,6 +28,15 @@ export type AdminSettingsSection = {
|
|
|
26
28
|
component: Component;
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
/** A switch on the Settings page's Application tab for one of the API's
|
|
32
|
+
* application flags; see the app-owned app-flags.ts. */
|
|
33
|
+
export type AppFlagSetting = {
|
|
34
|
+
/** The key in APP_FLAG_DEFAULTS (apps/api/src/settings/app-flags.ts). */
|
|
35
|
+
flag: string;
|
|
36
|
+
label: (t: Messages) => string;
|
|
37
|
+
description?: (t: Messages) => string;
|
|
38
|
+
};
|
|
39
|
+
|
|
29
40
|
export const moduleNavEntries: AdminNavEntry[] = [
|
|
30
41
|
// podokit:begin:admin-nav-entries
|
|
31
42
|
// podokit:end:admin-nav-entries
|
|
@@ -34,4 +45,8 @@ export const moduleNavEntries: AdminNavEntry[] = [
|
|
|
34
45
|
export const moduleSettingsSections: AdminSettingsSection[] = [
|
|
35
46
|
// podokit:begin:admin-settings
|
|
36
47
|
// podokit:end:admin-settings
|
|
48
|
+
// The application's flags, after every module tab, when app-flags.ts lists any.
|
|
49
|
+
...(APP_FLAG_SETTINGS.length > 0
|
|
50
|
+
? [{ value: "application", label: (t: Messages) => t.settings.appFlags.tab, component: AppFlagsSettings }]
|
|
51
|
+
: []),
|
|
37
52
|
];
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
|
|
17
17
|
import { untrack } from "svelte";
|
|
18
18
|
import type { SessionUser } from "../../app.d.ts";
|
|
19
|
+
import ImpersonationBanner from "./impersonation-banner.svelte";
|
|
19
20
|
import UserAvatar from "./user-avatar.svelte";
|
|
20
21
|
import {
|
|
21
22
|
ApiError,
|
|
@@ -33,7 +34,13 @@
|
|
|
33
34
|
accountPath = "/account",
|
|
34
35
|
returnPath = "/",
|
|
35
36
|
}: {
|
|
36
|
-
data: {
|
|
37
|
+
data: {
|
|
38
|
+
user: SessionUser;
|
|
39
|
+
currentSessionId: string | null;
|
|
40
|
+
capabilities: Capabilities;
|
|
41
|
+
/** Set by the /account load while an administrator acts as this user. */
|
|
42
|
+
showImpersonationBanner?: boolean;
|
|
43
|
+
};
|
|
37
44
|
accountPath?: string;
|
|
38
45
|
returnPath?: string;
|
|
39
46
|
} = $props();
|
|
@@ -552,6 +559,9 @@
|
|
|
552
559
|
</script>
|
|
553
560
|
|
|
554
561
|
<div class="flex flex-col gap-6">
|
|
562
|
+
{#if data.showImpersonationBanner}
|
|
563
|
+
<ImpersonationBanner email={data.user.email} class="rounded-md" />
|
|
564
|
+
{/if}
|
|
555
565
|
<h1 class="text-2xl font-semibold">{i18n.t.account.title}</h1>
|
|
556
566
|
|
|
557
567
|
<div class="flex flex-col gap-6 lg:flex-row">
|
|
@@ -2,36 +2,15 @@
|
|
|
2
2
|
import { page } from "$app/state";
|
|
3
3
|
import * as Sidebar from "#lib/components/ui/sidebar/index.js";
|
|
4
4
|
import SidebarUserMenu from "./sidebar-user-menu.svelte";
|
|
5
|
-
import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
|
|
6
|
-
import UsersIcon from "@lucide/svelte/icons/users";
|
|
7
|
-
import MonitorSmartphoneIcon from "@lucide/svelte/icons/monitor-smartphone";
|
|
8
|
-
import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
|
|
9
|
-
import Building2Icon from "@lucide/svelte/icons/building-2";
|
|
10
|
-
import SettingsIcon from "@lucide/svelte/icons/settings";
|
|
11
5
|
import HouseIcon from "@lucide/svelte/icons/house";
|
|
12
6
|
import { getI18n } from "#lib/i18n/index.js";
|
|
13
|
-
import {
|
|
7
|
+
import { adminNavEntries } from "#lib/admin/nav.js";
|
|
14
8
|
import type { SessionUser } from "../../app.d.ts";
|
|
15
9
|
|
|
16
10
|
let { user, capabilities }: { user: SessionUser; capabilities?: { auditLog?: boolean; organization?: boolean } } = $props();
|
|
17
11
|
const i18n = getI18n();
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
const items = $derived<NavItem[]>([
|
|
21
|
-
{ href: "/admin", label: (t) => t.nav.overview, icon: LayoutDashboardIcon },
|
|
22
|
-
{ href: "/admin/users", label: (t) => t.nav.users, icon: UsersIcon, adminOnly: true },
|
|
23
|
-
{ href: "/admin/sessions", label: (t) => t.nav.sessions, icon: MonitorSmartphoneIcon, adminOnly: true },
|
|
24
|
-
...(capabilities?.organization
|
|
25
|
-
? [{ href: "/admin/organizations", label: (t) => t.nav.organizations, icon: Building2Icon, adminOnly: true } as NavItem]
|
|
26
|
-
: []),
|
|
27
|
-
...(capabilities?.auditLog
|
|
28
|
-
? [{ href: "/admin/audit", label: (t) => t.nav.audit, icon: ScrollTextIcon, adminOnly: true } as NavItem]
|
|
29
|
-
: []),
|
|
30
|
-
// Module-contributed entries (present only for installed modules).
|
|
31
|
-
...moduleNavEntries,
|
|
32
|
-
{ href: "/admin/settings", label: (t) => t.nav.settings, icon: SettingsIcon, adminOnly: true },
|
|
33
|
-
]);
|
|
34
|
-
const visible = $derived(items.filter((item) => !item.adminOnly || user.role === "admin"));
|
|
13
|
+
const visible = $derived(adminNavEntries(capabilities, user.role));
|
|
35
14
|
</script>
|
|
36
15
|
|
|
37
16
|
<Sidebar.Root collapsible="icon">
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
2
3
|
import { cn } from "#lib/utils.js";
|
|
3
4
|
|
|
4
5
|
export type DataTableBreakpoint = "sm" | "md" | "lg" | "xl";
|
|
5
6
|
export type DataTableColumn<Row> = {
|
|
6
7
|
key: string;
|
|
7
8
|
label: string;
|
|
9
|
+
// Header content in place of the label and sort button, such as a
|
|
10
|
+
// select-all checkbox. Give it its own accessible name.
|
|
11
|
+
head?: Snippet;
|
|
8
12
|
sortable?: boolean;
|
|
9
13
|
class?: string;
|
|
10
14
|
hideBelow?: DataTableBreakpoint;
|
|
@@ -37,7 +41,6 @@
|
|
|
37
41
|
</script>
|
|
38
42
|
|
|
39
43
|
<script lang="ts" generics="Row">
|
|
40
|
-
import type { Snippet } from "svelte";
|
|
41
44
|
import { Button } from "#lib/components/ui/button/index.js";
|
|
42
45
|
import * as Table from "#lib/components/ui/table/index.js";
|
|
43
46
|
import TablePagination from "#lib/components/table-pagination.svelte";
|
|
@@ -132,7 +135,9 @@
|
|
|
132
135
|
class={getDataTableColumnClass(col)}
|
|
133
136
|
aria-sort={sort?.key === col.key ? (sort.dir === "asc" ? "ascending" : "descending") : undefined}
|
|
134
137
|
>
|
|
135
|
-
{#if col.
|
|
138
|
+
{#if col.head}
|
|
139
|
+
{@render col.head()}
|
|
140
|
+
{:else if col.sortable}
|
|
136
141
|
<Button variant="ghost" size="sm" class="-mx-2 h-auto gap-1 px-2 py-1 font-medium" onclick={() => toggle(col)}>
|
|
137
142
|
{col.label}
|
|
138
143
|
{#if sort?.key === col.key}
|