@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.
Files changed (215) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -18
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +27 -21
  18. package/dist/deploy-schema.d.ts +60 -2
  19. package/dist/deploy-schema.js +141 -14
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +30 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +7 -2
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +72 -4
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +13 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +50 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  93. package/dist/templates/fullstack/tests/package.json +3 -3
  94. package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
  95. package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
  96. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  97. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +53 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  127. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  132. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  133. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  134. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  138. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  139. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  140. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  141. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  142. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +14 -6
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -0
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  168. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  169. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  170. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  171. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  172. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  179. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  180. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  183. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  184. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  185. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  186. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  187. package/dist/templates/modules/auth/module.manifest.json +8 -2
  188. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +107 -1
  189. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -9
  190. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  191. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  192. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  193. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  194. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  195. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  196. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +174 -1
  197. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -3
  198. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  199. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  200. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  201. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  202. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  203. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  204. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  205. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  206. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  207. package/dist/templates/todo/AGENTS.md +5 -1
  208. package/dist/templates/todo/README.md +6 -4
  209. package/dist/text-diff.d.ts +12 -0
  210. package/dist/text-diff.js +111 -0
  211. package/dist/update.d.ts +61 -3
  212. package/dist/update.js +152 -27
  213. package/package.json +2 -2
  214. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  215. 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 responds at "/" (a page or a redirect to it).
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(page.locator("main")).toBeVisible();
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
- const response = await resolve(event, {
95
- transformPageChunk: ({ html }) =>
96
- html.replace(
97
- /(<html\b[^>]*?\slang=)(["'])[^"']*\2/i,
98
- `$1"${event.locals.locale}"`,
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
+ });
@@ -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
+ }
@@ -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
  ];
@@ -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: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities };
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 { moduleNavEntries, type AdminNavEntry } from "#lib/admin/registry.svelte.js";
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
- type NavItem = AdminNavEntry;
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">
@@ -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.sortable}
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}