@podosoft/podokit 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) 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 +56 -2
  19. package/dist/deploy-schema.js +130 -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 +28 -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 +5 -1
  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 +118 -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 +29 -0
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  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 +12 -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 +40 -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/package.json +2 -2
  93. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  94. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  95. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  96. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  97. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  124. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  125. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  126. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  127. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  133. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  134. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  138. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  139. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  140. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  141. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  164. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  165. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  166. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  167. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  168. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  169. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  170. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  171. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  172. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  179. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  180. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  183. package/dist/templates/modules/auth/module.manifest.json +8 -2
  184. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  185. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  186. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  187. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  188. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  189. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  190. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  191. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  192. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  193. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  194. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  195. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  196. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  197. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  198. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  199. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  200. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  201. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  202. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  203. package/dist/templates/todo/AGENTS.md +5 -1
  204. package/dist/templates/todo/README.md +6 -4
  205. package/dist/text-diff.d.ts +12 -0
  206. package/dist/text-diff.js +111 -0
  207. package/dist/update.d.ts +61 -3
  208. package/dist/update.js +152 -27
  209. package/package.json +2 -2
  210. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  211. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -0,0 +1,39 @@
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
+ describe("admin navigation", () => {
17
+ it("places the application's entries before Settings", () => {
18
+ const hrefs = adminNavEntries({ auditLog: true }, "admin").map((entry) => entry.href);
19
+
20
+ expect(hrefs).toEqual([
21
+ "/admin",
22
+ "/admin/users",
23
+ "/admin/sessions",
24
+ "/admin/audit",
25
+ "/admin/specs",
26
+ "/admin/help",
27
+ "/admin/settings",
28
+ ]);
29
+ expect(adminNavEntries(undefined, "admin").find((entry) => entry.href === "/admin/specs")?.icon).toBe(
30
+ FileTextIcon,
31
+ );
32
+ });
33
+
34
+ it("hides admin-only application entries from other roles", () => {
35
+ const hrefs = adminNavEntries(undefined, "moderator").map((entry) => entry.href);
36
+
37
+ expect(hrefs).toEqual(["/admin", "/admin/help"]);
38
+ });
39
+ });
@@ -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}
@@ -0,0 +1,30 @@
1
+ import { createRawSnippet } from "svelte";
2
+ import { render } from "svelte/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import DataTable, { type DataTableColumn, type DataTableRowContext } from "./data-table.svelte";
5
+
6
+ type Row = { name: string };
7
+
8
+ const row = createRawSnippet<[Row, DataTableRowContext]>((item) => ({
9
+ render: () => `<td>${item().name}</td><td></td>`,
10
+ }));
11
+
12
+ describe("DataTable", () => {
13
+ it("renders a column's header snippet in place of its label and sort button", () => {
14
+ const head = createRawSnippet(() => ({ render: () => '<input type="checkbox" aria-label="Select all rows">' }));
15
+ const columns: DataTableColumn<Row>[] = [
16
+ { key: "name", label: "Name", sortable: true },
17
+ { key: "selected", label: "Selected", head, sortable: true },
18
+ ];
19
+
20
+ const { body } = render(DataTable<Row>, { props: { columns, rows: [{ name: "Ada" }], row, perPage: 0 } });
21
+
22
+ const headers = body.match(/<th\b[\s\S]*?<\/th>/g) ?? [];
23
+ expect(headers).toHaveLength(2);
24
+ expect(headers[0]).toContain("Name");
25
+ expect(headers[0]).toContain("<button");
26
+ expect(headers[1]).toContain('aria-label="Select all rows"');
27
+ expect(headers[1]).not.toContain("Selected");
28
+ expect(headers[1]).not.toContain("<button");
29
+ });
30
+ });
@@ -3,9 +3,10 @@
3
3
  import { api } from "#lib/api.js";
4
4
  import { Button } from "#lib/components/ui/button/index.js";
5
5
  import { getI18n } from "#lib/i18n/index.js";
6
+ import { cn } from "#lib/utils.js";
6
7
  import { toast } from "svelte-sonner";
7
8
 
8
- let { email }: { email: string } = $props();
9
+ let { email, class: className }: { email: string; class?: string } = $props();
9
10
  const i18n = getI18n();
10
11
 
11
12
  async function stopImpersonating(): Promise<void> {
@@ -18,7 +19,7 @@
18
19
  }
19
20
  </script>
20
21
 
21
- <div class="bg-primary text-primary-foreground flex items-center justify-between gap-2 px-4 py-2 text-sm">
22
+ <div class={cn("bg-primary text-primary-foreground flex items-center justify-between gap-2 px-4 py-2 text-sm", className)}>
22
23
  <span>{i18n.t.users.impersonatingAs.replace("{email}", email)}</span>
23
24
  <Button variant="secondary" size="sm" onclick={stopImpersonating}>{i18n.t.users.stopImpersonating}</Button>
24
25
  </div>
@@ -0,0 +1,65 @@
1
+ <script lang="ts">
2
+ import { invalidateAll } from "$app/navigation";
3
+ import { page } from "$app/state";
4
+ import type { Capabilities } from "@podosoft/podokit-api-client";
5
+ import { toast } from "svelte-sonner";
6
+ import { appFlagCards } from "#lib/admin/app-flag-settings.js";
7
+ import { api } from "#lib/api.js";
8
+ import { Badge } from "#lib/components/ui/badge/index.js";
9
+ import * as Card from "#lib/components/ui/card/index.js";
10
+ import { Switch } from "#lib/components/ui/switch/index.js";
11
+ import { getI18n } from "#lib/i18n/index.js";
12
+
13
+ // The application's flags from app-flags.ts. They share the feature-flag
14
+ // endpoint, so saving needs an administrator and applies at once, like the
15
+ // switches on the Authentication tab.
16
+ const i18n = getI18n();
17
+ const cards = $derived(appFlagCards(i18n.t, page.data.capabilities as Capabilities | undefined));
18
+ let saving = $state(false);
19
+
20
+ async function toggle(flag: string, next: boolean): Promise<void> {
21
+ saving = true;
22
+ try {
23
+ await api.put("/account/settings", { [flag]: next });
24
+ await invalidateAll();
25
+ toast.success(i18n.t.settings.saved);
26
+ } catch {
27
+ toast.error(i18n.t.settings.saveFailed);
28
+ }
29
+ saving = false;
30
+ }
31
+ </script>
32
+
33
+ <section class="flex flex-col gap-3">
34
+ <div>
35
+ <h2 class="text-lg font-semibold">{i18n.t.settings.appFlags.title}</h2>
36
+ <p class="text-muted-foreground text-sm">{i18n.t.settings.appFlags.subtitle}</p>
37
+ </div>
38
+ <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
39
+ {#each cards as card (card.flag)}
40
+ <Card.Root class="flex min-h-[8.5rem] flex-col gap-2 p-4" data-app-flag={card.flag}>
41
+ <div class="flex items-start justify-between gap-3">
42
+ <span class="font-medium break-words">{card.name}</span>
43
+ <Switch
44
+ aria-label={card.name}
45
+ checked={card.on}
46
+ disabled={saving || !card.available}
47
+ onCheckedChange={(value) => toggle(card.flag, value === true)}
48
+ class="shrink-0"
49
+ />
50
+ </div>
51
+ <p class="text-muted-foreground flex-1 text-sm">{card.description}</p>
52
+ <div class="mt-auto flex items-center gap-2 pt-1">
53
+ <Badge variant={card.on ? "secondary" : "outline"} class="max-w-full gap-1.5">
54
+ <span class={`size-1.5 shrink-0 rounded-full ${card.on ? "bg-green-500" : "bg-muted-foreground/40"}`}></span>
55
+ <span class="truncate">
56
+ {card.available
57
+ ? card.on ? i18n.t.settings.enabled : i18n.t.settings.disabled
58
+ : i18n.t.settings.appFlags.unavailable}
59
+ </span>
60
+ </Badge>
61
+ </div>
62
+ </Card.Root>
63
+ {/each}
64
+ </div>
65
+ </section>
@@ -2,6 +2,7 @@
2
2
  import { goto } from "$app/navigation";
3
3
  import * as DropdownMenu from "#lib/components/ui/dropdown-menu/index.js";
4
4
  import * as Sidebar from "#lib/components/ui/sidebar/index.js";
5
+ import AccountMenu from "./account-menu.svelte";
5
6
  import UserAvatar from "./user-avatar.svelte";
6
7
  import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
7
8
  import LogOutIcon from "@lucide/svelte/icons/log-out";
@@ -12,6 +13,10 @@
12
13
 
13
14
  let { user }: { user: SessionUser } = $props();
14
15
  const i18n = getI18n();
16
+ // Outside a Sidebar.Provider, such as in an application's own shell, the
17
+ // sidebar menu parts have no state to read, so the same identity block and
18
+ // actions come from the shell-independent AccountMenu instead.
19
+ const inSidebar = (Sidebar.useSidebar() as ReturnType<typeof Sidebar.useSidebar> | undefined) !== undefined;
15
20
 
16
21
  async function signOut(): Promise<void> {
17
22
  await api.auth.signOut();
@@ -19,26 +24,30 @@
19
24
  }
20
25
  </script>
21
26
 
22
- <Sidebar.Menu>
23
- <Sidebar.MenuItem>
24
- <DropdownMenu.Root>
25
- <DropdownMenu.Trigger data-testid="sidebar-user-menu">
26
- {#snippet child({ props })}
27
- <Sidebar.MenuButton {...props} size="lg" class="data-[state=open]:bg-sidebar-accent">
28
- <UserAvatar {user} class="size-8" />
29
- <div class="flex flex-col text-left text-sm leading-tight">
30
- <span class="truncate font-medium">{user.name}</span>
31
- <span class="text-muted-foreground truncate text-xs">{user.email}</span>
32
- </div>
33
- <ChevronsUpDownIcon class="ml-auto size-4" />
34
- </Sidebar.MenuButton>
35
- {/snippet}
36
- </DropdownMenu.Trigger>
37
- <DropdownMenu.Content align="end" side="top" class="w-56">
38
- <DropdownMenu.Item onSelect={() => goto("/account")}><UserIcon class="mr-2 size-4" /> {i18n.t.nav.account}</DropdownMenu.Item>
39
- <DropdownMenu.Separator />
40
- <DropdownMenu.Item onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
41
- </DropdownMenu.Content>
42
- </DropdownMenu.Root>
43
- </Sidebar.MenuItem>
44
- </Sidebar.Menu>
27
+ {#if inSidebar}
28
+ <Sidebar.Menu>
29
+ <Sidebar.MenuItem>
30
+ <DropdownMenu.Root>
31
+ <DropdownMenu.Trigger data-testid="sidebar-user-menu">
32
+ {#snippet child({ props })}
33
+ <Sidebar.MenuButton {...props} size="lg" class="data-[state=open]:bg-sidebar-accent">
34
+ <UserAvatar {user} class="size-8" />
35
+ <div class="flex flex-col text-left text-sm leading-tight">
36
+ <span class="truncate font-medium">{user.name}</span>
37
+ <span class="text-muted-foreground truncate text-xs">{user.email}</span>
38
+ </div>
39
+ <ChevronsUpDownIcon class="ml-auto size-4" />
40
+ </Sidebar.MenuButton>
41
+ {/snippet}
42
+ </DropdownMenu.Trigger>
43
+ <DropdownMenu.Content align="end" side="top" class="w-56">
44
+ <DropdownMenu.Item onSelect={() => goto("/account")}><UserIcon class="mr-2 size-4" /> {i18n.t.nav.account}</DropdownMenu.Item>
45
+ <DropdownMenu.Separator />
46
+ <DropdownMenu.Item onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
47
+ </DropdownMenu.Content>
48
+ </DropdownMenu.Root>
49
+ </Sidebar.MenuItem>
50
+ </Sidebar.Menu>
51
+ {:else}
52
+ <AccountMenu {user} variant="identity" side="top" />
53
+ {/if}
@@ -0,0 +1,27 @@
1
+ import { render } from "svelte/server";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import SidebarUserMenu from "./sidebar-user-menu.svelte";
4
+
5
+ vi.mock("$app/state", () => ({
6
+ page: {
7
+ url: new URL("http://localhost/projects"),
8
+ data: {
9
+ locale: "en",
10
+ messages: { nav: { account: "Account", signOut: "Sign out" }, common: { appName: "Admin" } },
11
+ },
12
+ },
13
+ }));
14
+ vi.mock("$app/navigation", () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
15
+
16
+ describe("SidebarUserMenu", () => {
17
+ it("renders the account menu in a shell without a sidebar", () => {
18
+ const user = { id: "user-1", name: "Ada Lovelace", email: "ada@example.com", role: "user" };
19
+
20
+ const { body } = render(SidebarUserMenu, { props: { user } });
21
+
22
+ expect(body).toContain('data-testid="account-menu"');
23
+ expect(body).not.toContain('data-testid="sidebar-user-menu"');
24
+ expect(body).toContain("Ada Lovelace");
25
+ expect(body).toContain("ada@example.com");
26
+ });
27
+ });
@@ -11,7 +11,10 @@
11
11
  </script>
12
12
 
13
13
  {#if s.footerText || hasLinks}
14
- <footer class="text-muted-foreground flex flex-col items-center gap-1 px-4 py-6 text-center text-sm">
14
+ <footer
15
+ class="text-muted-foreground flex flex-col items-center gap-1 px-4 py-6 text-center text-sm"
16
+ data-testid="site-footer"
17
+ >
15
18
  {#if s.footerText}
16
19
  <p>{s.footerText}</p>
17
20
  {/if}
@@ -44,7 +44,9 @@
44
44
  {/if}
45
45
  </svelte:head>
46
46
 
47
- <ModeWatcher />
47
+ <!-- `.light` on <html> in light mode lets component libraries that follow a `.dark`
48
+ or `.light` class, and fall back to the OS setting otherwise, match the theme. -->
49
+ <ModeWatcher lightClassNames={["light"]} />
48
50
  <SessionIdleTimeout timeoutMinutes={sessionIdleTimeoutMinutes} />
49
51
  <!-- podokit:begin:site-runtime-components -->
50
52
  <!-- podokit:end:site-runtime-components -->
@@ -293,6 +293,12 @@
293
293
  "manage": "Manage",
294
294
  "saved": "Setting updated",
295
295
  "saveFailed": "Failed to update setting",
296
+ "appFlags": {
297
+ "tab": "Application",
298
+ "title": "Application settings",
299
+ "subtitle": "Turn this application's own features on or off. Changes apply immediately.",
300
+ "unavailable": "Not available"
301
+ },
296
302
  "emailPassword": "Email & password",
297
303
  "emailPasswordDesc": "Sign in with an email address and password.",
298
304
  "emailVerification": "Email verification",
@@ -293,6 +293,12 @@
293
293
  "manage": "관리",
294
294
  "saved": "설정을 변경했습니다",
295
295
  "saveFailed": "설정 변경에 실패했습니다",
296
+ "appFlags": {
297
+ "tab": "애플리케이션",
298
+ "title": "애플리케이션 설정",
299
+ "subtitle": "이 애플리케이션의 기능을 켜거나 끕니다. 변경 사항은 바로 적용됩니다.",
300
+ "unavailable": "사용할 수 없음"
301
+ },
296
302
  "emailPassword": "이메일·비밀번호",
297
303
  "emailPasswordDesc": "이메일과 비밀번호로 로그인.",
298
304
  "emailVerification": "이메일 인증",
@@ -0,0 +1,11 @@
1
+ import type { Handle } from "@sveltejs/kit/hooks";
2
+
3
+ /**
4
+ * Application-owned request hook. The managed hooks.server.ts runs it for every
5
+ * request after PodoKit has loaded the runtime context, so `event.locals` already
6
+ * holds the site settings, the signed-in user, the session, and the locale. Change
7
+ * `event.locals`, add response headers, or answer a request yourself here instead
8
+ * of editing hooks.server.ts. Combine several handles with `sequence` from
9
+ * "@sveltejs/kit/hooks". PodoKit creates this file once and never overwrites it.
10
+ */
11
+ export const appHandle: Handle = ({ event, resolve }) => resolve(event);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Application-owned path prefixes whose responses carry
3
+ * `X-Robots-Tag: noindex, nofollow`, in addition to the admin, account, sign-in,
4
+ * and API paths PodoKit already lists in search-indexing.ts. A listed path also
5
+ * covers its subpaths ("/app" covers "/app/projects/1"); "/" covers only the
6
+ * landing page. PodoKit creates this file once and never overwrites it.
7
+ */
8
+ export const APP_NON_INDEXABLE_PATHS: readonly string[] = [];
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { applySearchIndexingHeaders, shouldPreventSearchIndexing } from "./search-indexing";
3
+
4
+ // Stands in for the application's own list in the app-owned non-indexable-paths.ts.
5
+ vi.mock("./non-indexable-paths", () => ({ APP_NON_INDEXABLE_PATHS: ["/", "/projects"] }));
6
+
7
+ describe("search indexing", () => {
8
+ it("keeps PodoKit's private paths out of search results", () => {
9
+ expect(shouldPreventSearchIndexing("/admin/users")).toBe(true);
10
+ expect(shouldPreventSearchIndexing("/account")).toBe(true);
11
+ expect(shouldPreventSearchIndexing("/api/site/settings")).toBe(true);
12
+ });
13
+
14
+ it("adds the application's prefixes and their subpaths", () => {
15
+ expect(shouldPreventSearchIndexing("/projects")).toBe(true);
16
+ expect(shouldPreventSearchIndexing("/projects/a/b")).toBe(true);
17
+ expect(shouldPreventSearchIndexing("/projects-archive")).toBe(false);
18
+ });
19
+
20
+ it("treats a listed root as the landing page only", () => {
21
+ expect(shouldPreventSearchIndexing("/")).toBe(true);
22
+ expect(shouldPreventSearchIndexing("/pricing")).toBe(false);
23
+ });
24
+
25
+ it("sets the header only on non-indexable responses", () => {
26
+ expect(applySearchIndexingHeaders(new Response("ok"), "/projects/1").headers.get("x-robots-tag")).toBe(
27
+ "noindex, nofollow",
28
+ );
29
+ expect(applySearchIndexingHeaders(new Response("ok"), "/pricing").headers.has("x-robots-tag")).toBe(false);
30
+ });
31
+ });
@@ -1,3 +1,5 @@
1
+ import { APP_NON_INDEXABLE_PATHS } from "./non-indexable-paths";
2
+
1
3
  const NON_INDEXABLE_PATH_PREFIXES = [
2
4
  "/api",
3
5
  "/admin",
@@ -12,7 +14,9 @@ const NON_INDEXABLE_PATH_PREFIXES = [
12
14
  "/oauth2",
13
15
  "/accept-invitation",
14
16
  "/maintenance",
15
- ] as const;
17
+ // Application prefixes from the app-owned non-indexable-paths.ts.
18
+ ...APP_NON_INDEXABLE_PATHS,
19
+ ];
16
20
 
17
21
  function matchesPathPrefix(pathname: string, prefix: string): boolean {
18
22
  return pathname === prefix || pathname.startsWith(`${prefix}/`);
@@ -294,7 +294,7 @@
294
294
  </div>
295
295
 
296
296
  <Tabs.Root value="general">
297
- <Tabs.List>
297
+ <Tabs.List class="h-auto max-w-full flex-wrap justify-start">
298
298
  <Tabs.Trigger value="general">{i18n.t.general.tab}</Tabs.Trigger>
299
299
  <Tabs.Trigger value="appearance">{i18n.t.general.appearanceTitle}</Tabs.Trigger>
300
300
  <Tabs.Trigger value="auth">{i18n.t.settings.tab}</Tabs.Trigger>
@@ -6,6 +6,7 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
6
6
  await loadProtectedLayout({ locals, fetch });
7
7
  return {
8
8
  ...(await loadAccountData(locals, fetch)),
9
- impersonating: Boolean(locals.session?.impersonatedBy),
9
+ // Rendered by the managed AccountPage, so an owned page never has to change for it.
10
+ showImpersonationBanner: Boolean(locals.session?.impersonatedBy),
10
11
  };
11
12
  };
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import AccountPage from "#lib/components/account-page.svelte";
3
- import ImpersonationBanner from "#lib/components/impersonation-banner.svelte";
4
3
  import LanguageSwitch from "#lib/components/language-switch.svelte";
5
4
  import SiteFooter from "#lib/components/site-footer.svelte";
6
5
  import ThemeToggle from "#lib/components/theme-toggle.svelte";
@@ -13,15 +12,12 @@
13
12
  let {
14
13
  data,
15
14
  }: {
16
- data: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities; impersonating: boolean };
15
+ data: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities };
17
16
  } = $props();
18
17
  const i18n = getI18n();
19
18
  </script>
20
19
 
21
20
  <div class="flex min-h-svh flex-col">
22
- {#if data.impersonating}
23
- <ImpersonationBanner email={data.user.email} />
24
- {/if}
25
21
  <header class="flex h-14 items-center gap-2 border-b px-4">
26
22
  <Button href="/" variant="ghost">{i18n.t.nav.home}</Button>
27
23
  <div class="flex-1"></div>
@@ -38,6 +38,8 @@ put pagination outside the table.
38
38
  - Responsive columns: set `hideBelow` to `sm`, `md`, `lg`, or `xl`, then apply
39
39
  `cellClass(columnKey)` to the matching `Table.Cell`. Never repeat the
40
40
  responsive class by hand; the shared class keeps headers and cells aligned.
41
+ - Header controls such as a select-all checkbox: pass a `head` snippet on the
42
+ column; it replaces the label and sort button. Give the control an aria-label.
41
43
  - Give each table an `ariaLabel` and scope Playwright locators to that table.
42
44
  - No footer needed: `perPage={0}`.
43
45
  - **Search & filters**: put the shared `table-toolbar.svelte` (`TableToolbar`)
@@ -128,7 +128,7 @@ test("the audit log rejects bad paging parameters", async ({ playwright }) => {
128
128
 
129
129
  // Audit logging is a DB-backed on/off toggle (auth_config `server`, applied live).
130
130
  // Runs after the recording tests and restores the flag so nothing else is affected.
131
- test("audit logging honours the DB on/off toggle @smoke", async ({ playwright, disposableUsers }) => {
131
+ test("audit logging honours the DB on/off toggle @smoke", { tag: "@exclusive" }, async ({ playwright, disposableUsers }) => {
132
132
  const admin = await signedIn(playwright, ADMIN);
133
133
  test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
134
134
  const auditLog = async () => (await (await admin.get("/api/account/capabilities")).json()).auditLog as boolean;