@podosoft/podokit 0.11.1 → 0.12.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 (94) hide show
  1. package/README.md +7 -3
  2. package/dist/add.d.ts +16 -0
  3. package/dist/add.js +63 -20
  4. package/dist/assemble.js +9 -9
  5. package/dist/dev.d.ts +26 -0
  6. package/dist/dev.js +297 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +82 -2
  9. package/dist/lib.d.ts +2 -0
  10. package/dist/lib.js +7 -1
  11. package/dist/locale.d.ts +22 -0
  12. package/dist/locale.js +184 -0
  13. package/dist/lockfile.d.ts +5 -3
  14. package/dist/lockfile.js +21 -5
  15. package/dist/remove.js +26 -23
  16. package/dist/templates/fullstack-nest-svelte/README.md +42 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +1 -0
  18. package/dist/templates/fullstack-nest-svelte/apps/api/scripts/.keep +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  21. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  22. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
  25. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +8 -11
  26. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  27. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  28. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  29. package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
  30. package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
  31. package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +187 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +14 -3
  64. package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
  65. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
  67. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +15 -2
  68. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +2 -2
  69. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +51 -0
  70. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
  71. package/dist/templates/modules/auth/module.manifest.json +3 -0
  72. package/dist/templates/todo/README.md +42 -0
  73. package/dist/templates/todo/apps/api/Dockerfile +1 -0
  74. package/dist/templates/todo/apps/api/scripts/.keep +1 -0
  75. package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
  76. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  77. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  78. package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
  79. package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
  80. package/dist/templates/todo/apps/web/vite.config.ts +3 -9
  81. package/dist/templates/todo/compose.dev.yaml +8 -11
  82. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  83. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  84. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  85. package/dist/templates/todo/dot-env.docker +2 -2
  86. package/dist/templates/todo/dot-gitignore +3 -0
  87. package/dist/templates/todo/dot-podokit/dev.json +4 -0
  88. package/dist/templates/todo/tests/README.md +10 -4
  89. package/dist/update.js +48 -7
  90. package/package.json +1 -1
  91. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
  92. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
  93. package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
  94. package/dist/templates/todo/apps/web/src/lib/i18n/ko.ts +0 -11
@@ -2,15 +2,10 @@ import { sveltekit } from "@sveltejs/kit/vite";
2
2
  import tailwindcss from "@tailwindcss/vite";
3
3
  import { defineConfig } from "vite";
4
4
 
5
- // Inside the dev container (compose.dev.yaml sets VITE_DOCKER=1): bind to all
6
- // interfaces, allow the Traefik hostname, and route HMR's WebSocket back through
7
- // Traefik. The browser reaches the app at Traefik's published host port, so the
8
- // HMR client must target that same port — otherwise the ws connects to :80,
9
- // fails, and Vite silently falls back to a full page reload on every change
10
- // (which wipes in-progress form input). compose.dev.yaml injects
11
- // VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
5
+ // Inside the dev container, bind to all interfaces and accept local gateway or
6
+ // HTTPS tunnel hostnames. Vite derives HMR's protocol, host, and port from the
7
+ // browser origin so the same configuration works for both entry points.
12
8
  const inDocker = process.env.VITE_DOCKER === "1";
13
- const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
14
9
 
15
10
  export default defineConfig({
16
11
  plugins: [tailwindcss(), sveltekit()],
@@ -19,7 +14,6 @@ export default defineConfig({
19
14
  host: true,
20
15
  port: 5001,
21
16
  allowedHosts: true,
22
- hmr: { clientPort: hmrClientPort },
23
17
  }
24
18
  : undefined,
25
19
  });
@@ -1,13 +1,11 @@
1
1
  # Containerized development environment.
2
2
  #
3
- # docker compose -f compose.dev.yaml watch # core stack
4
- # docker compose -f compose.dev.yaml --profile cache \
5
- # --profile storage --profile queue watch # with modules
3
+ # podo dev watch # core stack
4
+ # podo dev watch --profile cache --profile storage \
5
+ # --profile queue # with modules
6
6
  #
7
- # Only Traefik binds a host port (default 80). The app is at http://app.localhost.
8
- # Set TRAEFIK_PORT to publish on another port (e.g. TRAEFIK_PORT=8001) so several
9
- # containerized stacks can run at once without colliding — the web container reads
10
- # the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
7
+ # A user-level PodoKit gateway owns loopback :80 and routes each *.localhost host
8
+ # to this stack. The legacy per-project Traefik service is opt-in only.
11
9
  # api/postgres/redis/minio have NO published ports — they talk by service name
12
10
  # on the internal network, so this stack never collides with other projects.
13
11
  #
@@ -24,6 +22,7 @@ name: {{projectName}}-dev
24
22
 
25
23
  services:
26
24
  traefik:
25
+ profiles: [podokit-legacy-proxy]
27
26
  image: traefik:v3.5
28
27
  command:
29
28
  - --providers.file.filename=/etc/traefik/dynamic.yml
@@ -52,10 +51,6 @@ services:
52
51
  # getClientAddress() tied to the visitor instead of the proxy container.
53
52
  - ADDRESS_HEADER=x-forwarded-for
54
53
  - XFF_DEPTH=1
55
- # Match Traefik's published port so HMR's WebSocket targets the right port
56
- # (default 80). Without this, HMR connects to :80 and a non-80 stack falls
57
- # back to a full page reload on every edit.
58
- - VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
59
54
  depends_on: [api]
60
55
  networks: [podokit]
61
56
  develop:
@@ -83,6 +78,7 @@ services:
83
78
  # sync+restart is the stable, recommended approach; the web keeps instant
84
79
  # HMR via plain sync.
85
80
  - { action: sync+restart, path: ./apps/api/src, target: /app/apps/api/src }
81
+ - { action: sync, path: ./apps/api/scripts, target: /app/apps/api/scripts }
86
82
  - { action: rebuild, path: ./apps/api/package.json }
87
83
 
88
84
  worker:
@@ -105,6 +101,7 @@ services:
105
101
  # sync+restart is the stable, recommended approach; the web keeps instant
106
102
  # HMR via plain sync.
107
103
  - { action: sync+restart, path: ./apps/api/src, target: /app/apps/api/src }
104
+ - { action: sync, path: ./apps/api/scripts, target: /app/apps/api/scripts }
108
105
  - { action: rebuild, path: ./apps/api/package.json }
109
106
 
110
107
  postgres:
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: podokit-add-locale
3
+ description: Add, translate, validate, activate, or troubleshoot a JSON locale in a PodoKit-generated SvelteKit app. Use when a user asks to add a language, split or edit locale JSON, check translation coverage or placeholders, configure fallback behavior, or verify locale changes through Vite HMR.
4
+ ---
5
+
6
+ # PodoKit Add Locale
7
+
8
+ Add languages through the PodoKit locale commands and the app-owned JSON catalog. Keep module catalogs managed so `podo update` can continue to update them.
9
+
10
+ ## Workflow
11
+
12
+ 1. Inspect the existing locale state.
13
+
14
+ ```sh
15
+ podo locale list
16
+ ```
17
+
18
+ 2. Add a canonical BCP 47 language tag. Set `--direction rtl` only for right-to-left languages.
19
+
20
+ ```sh
21
+ podo locale add pt-BR --name "Português (Brasil)"
22
+ ```
23
+
24
+ 3. Read [references/catalog-format.md](references/catalog-format.md), then translate the new app-owned file at `apps/web/src/lib/i18n/catalogs/app/<locale>.json`.
25
+
26
+ 4. Validate key coverage, JSON value types, and interpolation placeholders.
27
+
28
+ ```sh
29
+ podo locale validate pt-BR
30
+ ```
31
+
32
+ 5. Activate the locale. Partial catalogs are permitted because runtime fallback fills missing keys, but report the coverage percentage to the user.
33
+
34
+ ```sh
35
+ podo locale activate pt-BR
36
+ ```
37
+
38
+ 6. Run the app with its documented container-based development command. Verify the language switch, one complete key, one fallback key, and locale persistence. Editing an existing JSON file must update through Vite HMR without restarting the container. Adding a new locale file may trigger an automatic full-page reload.
39
+
40
+ ## Rules
41
+
42
+ - Treat `en` as the stable final fallback and never deactivate it.
43
+ - Preserve placeholders exactly. For example, translate `Hello {name}` without renaming or removing `{name}`.
44
+ - Put product-specific copy only in `catalogs/app/`. Do not edit `catalogs/admin-dashboard/` or other module catalogs directly.
45
+ - Keep locale definition filenames identical to their canonical codes, including case such as `pt-BR.json`.
46
+ - Do not activate a locale definition before its JSON parses and `podo locale validate` succeeds.
47
+ - Do not use the UI message catalogs for user-authored records. Dynamic content needs locale-aware records and a separate publication/fallback policy.
48
+ - When deactivating a locale, preserve its files so it can be reactivated without losing translations.
49
+
50
+ ## Fallback And Site Default
51
+
52
+ The message runtime resolves missing keys in this order: selected locale, configured site default, then English. The site default is an administrator choice, not a build-time constant. Changing it must not delete or rewrite catalogs.
53
+
54
+ If a requested language is unsupported or inactive, resolve it to the configured site default. Keep localized URL and SEO behavior in the consuming app; the catalogs provide translated strings but do not define routing policy.
55
+
56
+ ## Completion Checklist
57
+
58
+ - `podo locale validate` succeeds for every active locale.
59
+ - The new locale definition is active and has the intended display name and direction.
60
+ - App-owned JSON contains the new product copy; managed module catalogs remain untouched.
61
+ - Development edits update without a container restart.
62
+ - Automated tests cover selection, fallback, placeholders, and at least one incomplete catalog.
63
+ - User-facing locale setup or routing documentation is updated when behavior changes.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "PodoKit Locale Setup"
3
+ short_description: "Add and validate JSON locale catalogs safely"
4
+ default_prompt: "Use $podokit-add-locale to add, validate, activate, and test a new JSON locale in this PodoKit app."
@@ -0,0 +1,49 @@
1
+ # JSON Catalog Format
2
+
3
+ ## Directory Layout
4
+
5
+ ```text
6
+ apps/web/src/lib/i18n/
7
+ ├── locales/
8
+ │ ├── en.json
9
+ │ ├── ko.json
10
+ │ └── pt-BR.json
11
+ └── catalogs/
12
+ ├── admin-dashboard/ # PodoKit-managed module messages
13
+ ├── blog/ # module-managed messages when installed
14
+ └── app/ # app-owned product messages and overrides
15
+ ```
16
+
17
+ Each locale definition has this shape:
18
+
19
+ ```json
20
+ {
21
+ "code": "pt-BR",
22
+ "name": "Português (Brasil)",
23
+ "direction": "ltr",
24
+ "enabled": false
25
+ }
26
+ ```
27
+
28
+ `code` must be a canonical BCP 47 tag and must match the filename. `direction` is `ltr` or `rtl`. Newly added locales stay inactive until explicitly activated.
29
+
30
+ ## Catalog Composition
31
+
32
+ Catalogs are nested JSON objects with strings at their leaves:
33
+
34
+ ```json
35
+ {
36
+ "common": {
37
+ "save": "Save",
38
+ "welcome": "Welcome, {name}"
39
+ }
40
+ }
41
+ ```
42
+
43
+ The runtime lazily loads every `catalogs/*/<locale>.json` file. Module catalogs are merged first in stable name order; the `app` catalog is merged last, so the consuming app may intentionally override module copy without modifying managed files.
44
+
45
+ English defines the complete key and value-type contract. Other locales may omit keys, but translated values must retain the English type and all `{placeholder}` names. JSON objects and arrays are supported; executable expressions are not.
46
+
47
+ ## Development Reloading
48
+
49
+ Catalog imports use Vite's `import.meta.glob`. Changes to an existing JSON dependency participate in Vite HMR and must appear without restarting the development container. A new file changes the glob membership and may cause Vite to reload the page automatically. If neither occurs, confirm that the app source directory, including `apps/web/src`, is mounted or synced by the development compose configuration.
@@ -6,11 +6,11 @@ POSTGRES_USER=podokit
6
6
  POSTGRES_PASSWORD=podokit
7
7
  POSTGRES_DB=podokit
8
8
  BACKEND_INTERNAL_URL=http://api:5002
9
- CORS_ORIGIN=http://app.localhost
9
+ CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
10
10
  REDIS_HOST=redis
11
11
  REDIS_PORT=6379
12
12
  BETTER_AUTH_SECRET=change-me-in-container-min-32-characters
13
- BETTER_AUTH_URL=http://app.localhost
13
+ BETTER_AUTH_URL=http://{{projectName}}.localhost
14
14
  ADMIN_EMAILS=admin@example.com
15
15
  AUDIT_LOG_ENABLED=true
16
16
  RATE_LIMIT_TTL=60
@@ -18,3 +18,6 @@ test-results/
18
18
 
19
19
  # local performance reports
20
20
  output/lighthouse/
21
+
22
+ # podo dev generated compose overlay
23
+ .podokit/runtime/
@@ -0,0 +1,4 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "hostname": "{{projectName}}.localhost"
4
+ }
@@ -9,7 +9,13 @@ npm run test:e2e:ui # pages (chromium)
9
9
  npm run test:e2e:report # open the HTML report
10
10
  ```
11
11
 
12
- The suite runs against a live stack (web on `E2E_BASE_URL`, default
13
- `http://localhost:5001`, proxying `/api/*` to the API). Start PostgreSQL, run the
14
- auth migration, and start the API + web first. Files ending in `*.api.spec.ts` run
15
- in the request-only `api` project; `*.ui.spec.ts` run in the chromium `ui` project.
12
+ The suite runs against a live stack (web on `E2E_BASE_URL`, proxying `/api/*` to
13
+ the API). With the recommended containerized loop, start `podo dev watch` and run:
14
+
15
+ ```bash
16
+ E2E_BASE_URL=http://{{projectName}}.localhost npm run test:e2e
17
+ ```
18
+
19
+ The default `http://localhost:5001` is for the alternative `npm run dev`
20
+ host-process loop. Files ending in `*.api.spec.ts` run in the request-only `api`
21
+ project; `*.ui.spec.ts` run in the chromium `ui` project.
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const args = process.argv.slice(2);
6
+
7
+ function has(flag) {
8
+ return args.includes(flag);
9
+ }
10
+
11
+ function required(name, value) {
12
+ if (!value?.trim()) throw new Error(`${name} is required.`);
13
+ return value.trim();
14
+ }
15
+
16
+ function usage() {
17
+ console.log(`Create or verify the initial PodoKit administrator.
18
+
19
+ Usage:
20
+ npm run admin:bootstrap -w <app>-api
21
+ npm run admin:bootstrap -w <app>-api -- --dry-run
22
+ npm run admin:bootstrap -w <app>-api -- --check-only
23
+
24
+ Required environment:
25
+ ADMIN_EMAILS Allowed administrator emails (comma-separated)
26
+ ADMIN_BOOTSTRAP_EMAIL Initial administrator email
27
+ ADMIN_BOOTSTRAP_PASSWORD Initial administrator password
28
+
29
+ Optional environment:
30
+ ADMIN_BOOTSTRAP_NAME Display name (default: Administrator)
31
+
32
+ AUTH_SETUP_ADMIN_EMAIL and AUTH_SETUP_ADMIN_PASSWORD work as fallbacks so the
33
+ same ephemeral credentials can be reused by auth:configure.
34
+
35
+ Options:
36
+ --dry-run Validate and print a redacted plan without writing
37
+ --check-only Verify an existing administrator without creating
38
+ `);
39
+ }
40
+
41
+ export function resolveBootstrapInput(env = process.env) {
42
+ const email = required(
43
+ "ADMIN_BOOTSTRAP_EMAIL",
44
+ env.ADMIN_BOOTSTRAP_EMAIL ?? env.AUTH_SETUP_ADMIN_EMAIL,
45
+ ).toLowerCase();
46
+ const password = required(
47
+ "ADMIN_BOOTSTRAP_PASSWORD",
48
+ env.ADMIN_BOOTSTRAP_PASSWORD ?? env.AUTH_SETUP_ADMIN_PASSWORD,
49
+ );
50
+ const name = required("ADMIN_BOOTSTRAP_NAME", env.ADMIN_BOOTSTRAP_NAME ?? "Administrator");
51
+ const adminEmails = new Set(
52
+ required("ADMIN_EMAILS", env.ADMIN_EMAILS)
53
+ .split(",")
54
+ .map((value) => value.trim().toLowerCase())
55
+ .filter(Boolean),
56
+ );
57
+
58
+ if (!adminEmails.has(email)) {
59
+ throw new Error("ADMIN_BOOTSTRAP_EMAIL must be listed in ADMIN_EMAILS.");
60
+ }
61
+ if (password.length < 8 || password.length > 128) {
62
+ throw new Error("ADMIN_BOOTSTRAP_PASSWORD must be between 8 and 128 characters.");
63
+ }
64
+
65
+ return { email, password, name };
66
+ }
67
+
68
+ function roles(value) {
69
+ return (value ?? "")
70
+ .split(",")
71
+ .map((role) => role.trim())
72
+ .filter(Boolean);
73
+ }
74
+
75
+ async function verifyExisting(dependencies, input, user) {
76
+ if (!roles(user.role).includes("admin")) {
77
+ throw new Error("The administrator email already exists without the admin role; no changes were made.");
78
+ }
79
+ if (user.signupApproved !== true) {
80
+ throw new Error("The administrator account is not approved; no changes were made.");
81
+ }
82
+ if (user.emailVerified !== true) {
83
+ throw new Error("The administrator email is not verified; no changes were made.");
84
+ }
85
+ if (!user.passwordHash || !(await dependencies.verifyPassword(user.passwordHash, input.password))) {
86
+ throw new Error("The administrator account already exists but the supplied password does not match; no changes were made.");
87
+ }
88
+ }
89
+
90
+ export async function bootstrapAdmin(dependencies, input, options = {}) {
91
+ const existing = await dependencies.findUser(input.email);
92
+
93
+ if (options.dryRun) {
94
+ return {
95
+ status: "planned",
96
+ action: existing ? "verify" : "create",
97
+ email: input.email,
98
+ name: input.name,
99
+ password: "<redacted>",
100
+ };
101
+ }
102
+
103
+ if (existing) {
104
+ await verifyExisting(dependencies, input, existing);
105
+ return { status: "existing", email: input.email };
106
+ }
107
+
108
+ if (options.checkOnly) {
109
+ throw new Error("The administrator account does not exist; no changes were made.");
110
+ }
111
+
112
+ await dependencies.createUser({
113
+ email: input.email,
114
+ password: input.password,
115
+ name: input.name,
116
+ role: "admin",
117
+ data: { emailVerified: true, signupApproved: true },
118
+ });
119
+
120
+ const created = await dependencies.findUser(input.email);
121
+ if (!created) throw new Error("The administrator account was not found after creation.");
122
+ await verifyExisting(dependencies, input, created);
123
+ return { status: "created", email: input.email };
124
+ }
125
+
126
+ async function main() {
127
+ if (has("--help") || has("-h")) {
128
+ usage();
129
+ return;
130
+ }
131
+
132
+ const input = resolveBootstrapInput();
133
+ // Load the auth graph before importing one of its dependencies directly.
134
+ // Concurrent ESM imports can ask Node to link the same Better Auth submodule
135
+ // through two graph roots and fail before the command reaches the database.
136
+ const { auth } = await import("../dist/auth/auth.js");
137
+ const { pool } = await import("../dist/auth/db.js");
138
+ const { verifyPassword } = await import("better-auth/crypto");
139
+
140
+ try {
141
+ const result = await bootstrapAdmin(
142
+ {
143
+ findUser: async (email) => {
144
+ const query = await pool.query(
145
+ `SELECT u.id,
146
+ u.email,
147
+ u.role,
148
+ u."emailVerified" AS "emailVerified",
149
+ u."signupApproved" AS "signupApproved",
150
+ a.password AS "passwordHash"
151
+ FROM "user" AS u
152
+ LEFT JOIN "account" AS a
153
+ ON a."userId" = u.id
154
+ AND a."providerId" = 'credential'
155
+ WHERE lower(u.email) = lower($1)
156
+ LIMIT 1`,
157
+ [email],
158
+ );
159
+ return query.rows[0] ?? null;
160
+ },
161
+ createUser: async (user) => auth.api.createUser({ body: user }),
162
+ verifyPassword: async (hash, password) => verifyPassword({ hash, password }),
163
+ },
164
+ input,
165
+ { dryRun: has("--dry-run"), checkOnly: has("--check-only") },
166
+ );
167
+
168
+ if (result.status === "planned") {
169
+ console.log("Validated administrator bootstrap plan.");
170
+ console.log(JSON.stringify(result, null, 2));
171
+ } else if (result.status === "created") {
172
+ console.log(`Created and verified administrator account ${result.email}.`);
173
+ } else {
174
+ console.log(`Verified existing administrator account ${result.email}.`);
175
+ }
176
+ } finally {
177
+ await pool.end();
178
+ }
179
+ }
180
+
181
+ const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
182
+ if (invokedPath === import.meta.url) {
183
+ main().catch((error) => {
184
+ console.error(error instanceof Error ? error.message : String(error));
185
+ process.exitCode = 1;
186
+ });
187
+ }
@@ -31,6 +31,7 @@ const ICON_TYPES = new Set(["image/svg+xml", "image/png", "image/x-icon", "image
31
31
  // preset a slug, and overrides a JSON map of hex colors under known token keys.
32
32
  const HEX = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
33
33
  const PRESET = /^[a-z0-9-]{1,32}$/;
34
+ const LOCALE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
34
35
  const THEME_TOKEN_KEYS = new Set([
35
36
  "background",
36
37
  "card",
@@ -46,6 +47,14 @@ const THEME_TOKEN_KEYS = new Set([
46
47
  /** Validate/normalize a single theme setting value. Empty string clears it. */
47
48
  function validateThemeValue(key: string, value: string): string {
48
49
  if (value === "") return "";
50
+ if (key === "locale") {
51
+ if (!LOCALE.test(value)) throw new BadRequestException("locale must be a valid BCP 47 language tag");
52
+ try {
53
+ return Intl.getCanonicalLocales(value)[0] ?? value;
54
+ } catch {
55
+ throw new BadRequestException("locale must be a valid BCP 47 language tag");
56
+ }
57
+ }
49
58
  if (key === "brandColor") {
50
59
  if (!HEX.test(value)) throw new BadRequestException("brandColor must be a hex color");
51
60
  return value;
@@ -35,7 +35,11 @@ export const handle: Handle = async ({ event, resolve }) => {
35
35
  event.locals.session = null;
36
36
  }
37
37
  const response = await resolve(event, {
38
- transformPageChunk: ({ html }) => html.replace('lang="en"', `lang="${event.locals.locale}"`),
38
+ transformPageChunk: ({ html }) =>
39
+ html.replace(
40
+ /(<html\b[^>]*?\slang=)(["'])[^"']*\2/i,
41
+ `$1"${event.locals.locale}"`,
42
+ ),
39
43
  });
40
44
  return applySearchIndexingHeaders(response, event.url.pathname);
41
45
  };
@@ -0,0 +1,3 @@
1
+ /** Stable registration-policy errors used by Better Auth callbacks before the
2
+ * shared API client is necessarily upgraded in an existing generated app. */
3
+ export const PUBLIC_SIGNUP_DISABLED = "PUBLIC_SIGNUP_DISABLED" as const;
@@ -0,0 +1,27 @@
1
+ const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password"]);
2
+
3
+ /** Accept only same-site absolute paths and keep authentication redirects from
4
+ * becoming open redirects or loops back into an auth screen. */
5
+ export function safeAuthRedirect(value: string | null | undefined, fallback = "/"): string {
6
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) {
7
+ return fallback;
8
+ }
9
+ try {
10
+ const parsed = new URL(value, "http://podokit.local");
11
+ if (parsed.origin !== "http://podokit.local" || AUTH_PATHS.has(parsed.pathname)) return fallback;
12
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
13
+ } catch {
14
+ return fallback;
15
+ }
16
+ }
17
+
18
+ /** Current same-site path, suitable for a login return target. */
19
+ export function currentPath(url: URL): string {
20
+ return safeAuthRedirect(`${url.pathname}${url.search}${url.hash}`);
21
+ }
22
+
23
+ /** Add a validated return target to an authentication-page link. */
24
+ export function withAuthRedirect(authPath: "/login" | "/signup", target: string): string {
25
+ const params = new URLSearchParams({ redirect: safeAuthRedirect(target) });
26
+ return `${authPath}?${params.toString()}`;
27
+ }
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { goto } from "$app/navigation";
3
+ import { page } from "$app/state";
4
+ import { currentPath, withAuthRedirect } from "$lib/auth-redirect";
3
5
  import { Button } from "$lib/components/ui/button";
4
6
  import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
5
7
  import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
@@ -12,16 +14,19 @@
12
14
 
13
15
  let { user }: { user: SessionUser | null } = $props();
14
16
  const i18n = getI18n();
17
+ const loginHref = $derived(withAuthRedirect("/login", currentPath(page.url)));
15
18
 
16
19
  async function signOut(): Promise<void> {
20
+ const returnTo = currentPath(page.url);
17
21
  await api.auth.signOut();
18
- await goto("/login", { invalidateAll: true });
22
+ await goto(returnTo, { invalidateAll: true });
19
23
  }
20
24
  </script>
21
25
 
22
26
  {#if user}
23
27
  <DropdownMenu.Root>
24
28
  <DropdownMenu.Trigger
29
+ data-testid="account-menu"
25
30
  class="hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 inline-flex size-8 items-center justify-center rounded-full outline-none focus-visible:ring-3"
26
31
  aria-label={i18n.t.nav.account}
27
32
  >
@@ -38,9 +43,9 @@
38
43
  <DropdownMenu.Item onSelect={() => goto("/admin")}><LayoutDashboardIcon class="mr-2 size-4" /> {i18n.t.common.appName}</DropdownMenu.Item>
39
44
  {/if}
40
45
  <DropdownMenu.Separator />
41
- <DropdownMenu.Item onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
46
+ <DropdownMenu.Item data-testid="sign-out" onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
42
47
  </DropdownMenu.Content>
43
48
  </DropdownMenu.Root>
44
49
  {:else}
45
- <Button href="/login" variant="ghost">{i18n.t.auth.signIn}</Button>
50
+ <Button data-testid="sign-in-link" href={loginHref} variant="ghost">{i18n.t.auth.signIn}</Button>
46
51
  {/if}
@@ -15,7 +15,7 @@
15
15
 
16
16
  async function signOut(): Promise<void> {
17
17
  await api.auth.signOut();
18
- await goto("/login", { invalidateAll: true });
18
+ await goto("/", { invalidateAll: true });
19
19
  }
20
20
  </script>
21
21