@podosoft/podokit 1.4.2 → 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 (245) 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 -17
  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 +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  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 +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -11,9 +11,12 @@
11
11
  import { deviceLabel } from "#lib/device-label.js";
12
12
  import { toast } from "svelte-sonner";
13
13
  import { api } from "#lib/api.js";
14
+ import { listAccountSessions, revokeAccountSession, type AccountSession } from "#lib/account-sessions.js";
15
+ import { KeysetPages } from "#lib/admin/sessions.js";
14
16
  import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
15
17
  import { untrack } from "svelte";
16
18
  import type { SessionUser } from "../../app.d.ts";
19
+ import ImpersonationBanner from "./impersonation-banner.svelte";
17
20
  import UserAvatar from "./user-avatar.svelte";
18
21
  import {
19
22
  ApiError,
@@ -31,7 +34,13 @@
31
34
  accountPath = "/account",
32
35
  returnPath = "/",
33
36
  }: {
34
- 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
+ };
35
44
  accountPath?: string;
36
45
  returnPath?: string;
37
46
  } = $props();
@@ -392,28 +401,34 @@
392
401
  await goto("/login", { invalidateAll: true });
393
402
  }
394
403
 
395
- // Sessions — this user's own devices
396
- /** better-auth's code for "this session is older than `session.freshAge`". */
404
+ // Sessions — this user's own devices. The API pages them, because Better Auth's
405
+ // /list-sessions returns at most 100 and could leave out the current one.
406
+ /** Better Auth's code for "this session is older than `session.freshAge`". */
397
407
  const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
398
- type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
408
+ type Session = AccountSession & { current: boolean };
399
409
  const PAGE_SIZE = DEFAULT_PAGE_SIZE;
410
+ // The API pages newest first by keyset, so the order is fixed rather than sortable.
411
+ const sessionsSort: SortState = { key: "createdAt", dir: "desc" };
412
+ const sessionPages = new KeysetPages();
400
413
  let sessions = $state<Session[]>([]);
414
+ let sessionsTotal = $state(0);
415
+ let sessionsPagerTotal = $state(0);
401
416
  let sessionsPage = $state(1);
402
- let sessionsSort = $state<SortState | null>(null);
403
417
  let busy = $state(false);
418
+ let latestSessionsRequest = 0;
404
419
  const sessionsColumns: DataTableColumn<Session>[] = [
405
- { key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
406
- { key: "ipAddress", label: i18n.t.sessions.ip, sortable: true, value: (s) => s.ipAddress ?? "" },
407
- { key: "createdAt", label: i18n.t.sessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
420
+ { key: "userAgent", label: i18n.t.sessions.device },
421
+ { key: "ipAddress", label: i18n.t.sessions.ip },
422
+ { key: "createdAt", label: i18n.t.sessions.since },
408
423
  { key: "status", label: i18n.t.sessions.status },
409
424
  { key: "actions", label: "", class: "w-10" },
410
425
  ];
411
426
  /**
412
427
  * True when the session is too old to list sessions.
413
428
  *
414
- * better-auth guards `/list-sessions` (and `/unlink-account`) with its freshness
415
- * middleware, and `session.freshAge` defaults to 24 hours. So for anyone who signed
416
- * in yesterday — which is most people, most of the time — this call returns 403
429
+ * The API keeps better-auth's freshness rule for listing sessions, and
430
+ * `session.freshAge` defaults to 24 hours. So for anyone who signed in yesterday —
431
+ * which is most people, most of the time — the list answers 403
417
432
  * `SESSION_NOT_FRESH`. It is not a failure to report as one: the tab has to say what
418
433
  * happened and what to do, or it is a blank table under an untranslated toast.
419
434
  */
@@ -423,32 +438,45 @@
423
438
  await api.auth.signOut();
424
439
  await goto("/login", { invalidateAll: true });
425
440
  }
426
- async function loadSessions(): Promise<void> {
427
- const { data: res, error } = await api.auth.listSessions();
428
- if (error?.code === SESSION_NOT_FRESH) {
429
- // Explained in the panel below, not thrown at the corner of the screen.
430
- sessionsStale = true;
431
- sessions = [];
432
- return;
441
+ /** Loads one page; the current session leads the first. A newer request supersedes any in flight. */
442
+ async function loadSessions(target = 1): Promise<void> {
443
+ if (target === 1) sessionPages.reset();
444
+ const cursor = sessionPages.cursorFor(target);
445
+ if (cursor === undefined) return;
446
+ const request = ++latestSessionsRequest;
447
+ try {
448
+ const result = await listAccountSessions(api, { limit: PAGE_SIZE, cursor });
449
+ if (request !== latestSessionsRequest) return;
450
+ // A revoke can empty the last page; show the one before it instead.
451
+ if (result.items.length === 0 && target > 1) return loadSessions(target - 1);
452
+ sessionsStale = false;
453
+ sessionPages.record(target, result.nextCursor);
454
+ const others = result.items.map((s) => ({ ...s, current: false }));
455
+ sessions = target === 1 && result.current ? [{ ...result.current, current: true }, ...others] : others;
456
+ sessionsTotal = result.total;
457
+ sessionsPagerTotal = sessionPages.pagerTotal(result.total - (result.current ? 1 : 0), PAGE_SIZE);
458
+ sessionsPage = target;
459
+ } catch (error) {
460
+ if (request !== latestSessionsRequest) return;
461
+ if (error instanceof ApiError && error.code === SESSION_NOT_FRESH) {
462
+ // Explained in the panel below, not thrown at the corner of the screen.
463
+ sessionsStale = true;
464
+ sessions = [];
465
+ return;
466
+ }
467
+ toast.error(i18n.t.sessions.loadFailed);
433
468
  }
434
- sessionsStale = false;
435
- if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
436
- // Current session first (users expect it on top; also keeps it on page 1),
437
- // then newest to oldest.
438
- sessions = ((res ?? []) as Session[]).sort((a, b) => {
439
- if (a.id === data.currentSessionId) return -1;
440
- if (b.id === data.currentSessionId) return 1;
441
- return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
442
- });
443
469
  }
444
- async function revoke(token: string): Promise<void> {
470
+ async function revoke(id: string): Promise<void> {
445
471
  busy = true;
446
- const { error } = await api.auth.revokeSession({ token });
447
- busy = false;
448
- if (error) toast.error(error.message ?? i18n.t.sessions.revokeFailed);
449
- else {
472
+ try {
473
+ await revokeAccountSession(api, id);
450
474
  toast.success(i18n.t.sessions.revoked);
451
- await loadSessions();
475
+ await loadSessions(sessionsPage);
476
+ } catch {
477
+ toast.error(i18n.t.sessions.revokeFailed);
478
+ } finally {
479
+ busy = false;
452
480
  }
453
481
  }
454
482
  async function signOutOthers(): Promise<void> {
@@ -531,6 +559,9 @@
531
559
  </script>
532
560
 
533
561
  <div class="flex flex-col gap-6">
562
+ {#if data.showImpersonationBanner}
563
+ <ImpersonationBanner email={data.user.email} class="rounded-md" />
564
+ {/if}
534
565
  <h1 class="text-2xl font-semibold">{i18n.t.account.title}</h1>
535
566
 
536
567
  <div class="flex flex-col gap-6 lg:flex-row">
@@ -824,7 +855,7 @@
824
855
  <Card.Root>
825
856
  <Card.Header class="flex flex-row items-center justify-between gap-2">
826
857
  <Card.Title>{i18n.t.sessions.title}</Card.Title>
827
- <Button variant="outline" size="sm" disabled={busy || sessionsStale || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
858
+ <Button variant="outline" size="sm" disabled={busy || sessionsStale || sessionsTotal <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
828
859
  </Card.Header>
829
860
  <Card.Content>
830
861
  {#if sessionsStale}
@@ -843,20 +874,23 @@
843
874
  columns={sessionsColumns}
844
875
  rows={sessions}
845
876
  getKey={(s) => s.id}
846
- bind:sort={sessionsSort}
877
+ sort={sessionsSort}
878
+ manualSort
879
+ manualPagination
847
880
  bind:page={sessionsPage}
848
881
  perPage={PAGE_SIZE}
849
- label={fmt(i18n.t.sessions.total, { count: sessions.length })}
882
+ total={sessionsPagerTotal}
883
+ label={fmt(i18n.t.sessions.total, { count: sessionsTotal })}
850
884
  empty={i18n.t.sessions.empty}
885
+ onChange={(state) => void loadSessions(state.page)}
851
886
  >
852
887
  {#snippet row(s)}
853
- {@const isCurrent = s.id === data.currentSessionId}
854
888
  <Table.Cell class="max-w-xs truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
855
889
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
856
890
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
857
- <Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
891
+ <Table.Cell>{#if s.current}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
858
892
  <Table.Cell>
859
- <Button variant="ghost" size="sm" disabled={busy || isCurrent} onclick={() => revoke(s.token)}>{i18n.t.sessions.revoke}</Button>
893
+ <Button variant="ghost" size="sm" disabled={busy || s.current} onclick={() => revoke(s.id)}>{i18n.t.sessions.revoke}</Button>
860
894
  </Table.Cell>
861
895
  {/snippet}
862
896
  </DataTable>
@@ -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 -->
@@ -86,7 +86,9 @@
86
86
  {:else}
87
87
  <Label class="text-sm" for="toolbar-search">{searchFields[0].label}</Label>
88
88
  {/if}
89
- <Input id="toolbar-search" bind:value={search} placeholder={searchPlaceholder} class="max-w-xs flex-1" />
89
+ <!-- The minimum width makes a narrow row wrap the input to its own line
90
+ instead of squeezing it next to the field select. -->
91
+ <Input id="toolbar-search" bind:value={search} placeholder={searchPlaceholder} class="min-w-48 max-w-xs flex-1" />
90
92
  <Button type="submit">{searchButton}</Button>
91
93
  </form>
92
94
  {/if}
@@ -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
  };