@podosoft/podokit 0.8.0 → 0.10.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 (84) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +3 -1
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  16. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  18. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +3 -1
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -680
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +30 -11
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +63 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +23 -7
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +137 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  64. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  65. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
  67. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
  68. package/dist/templates/modules/auth/module.manifest.json +10 -0
  69. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  70. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  71. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  72. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  73. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  74. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  75. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  76. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  77. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
  78. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  79. package/dist/templates/todo/dot-gitignore +6 -0
  80. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  81. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  82. package/dist/update.js +74 -8
  83. package/package.json +1 -1
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
@@ -22,5 +22,14 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
22
22
  } catch {
23
23
  /* leave null — the page shows the DB-config sections as unavailable */
24
24
  }
25
- return { authConfig };
25
+ // The require-two-factor policy lives in the feature-flag store (not the typed
26
+ // Capabilities), so read it from its own endpoint for the Settings toggle.
27
+ let require2fa = false;
28
+ try {
29
+ const res = await fetch("/api/account/require-2fa");
30
+ if (res.ok) require2fa = ((await res.json()) as { require2fa?: boolean }).require2fa === true;
31
+ } catch {
32
+ /* default off */
33
+ }
34
+ return { authConfig, require2fa };
26
35
  };
@@ -7,8 +7,10 @@
7
7
  import { Button } from "$lib/components/ui/button";
8
8
  import * as Card from "$lib/components/ui/card";
9
9
  import * as Dialog from "$lib/components/ui/dialog";
10
+ import * as Select from "$lib/components/ui/select";
10
11
  import * as Tabs from "$lib/components/ui/tabs";
11
12
  import GeneralSettings from "./general-settings.svelte";
13
+ import AppearanceCard from "$lib/components/settings/appearance-card.svelte";
12
14
  import { moduleSettingsSections } from "$lib/admin/registry.svelte";
13
15
  import { toast } from "svelte-sonner";
14
16
  import { api } from "$lib/api";
@@ -16,12 +18,15 @@
16
18
  import type { Capabilities } from "@podosoft/podokit-api-client";
17
19
  import type { AuthConfigView, SocialProviderView } from "./+page.server";
18
20
 
19
- let { data }: { data: { capabilities: Capabilities; authConfig: AuthConfigView | null } } = $props();
21
+ let { data }: { data: { capabilities: Capabilities; authConfig: AuthConfigView | null; require2fa: boolean } } = $props();
20
22
  const i18n = getI18n();
21
23
  const caps = $derived(data.capabilities);
22
24
  const ac = $derived(data.authConfig);
23
25
 
24
26
  type EditableFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
27
+ // require2fa is a policy flag stored alongside the feature flags but not part of
28
+ // the typed Capabilities, so it is toggled but never read from `caps`.
29
+ type ToggleFlag = EditableFlag | "require2fa";
25
30
  type ServerKey = "requireEmailVerification" | "hibp" | "allowDelete" | "auditLog";
26
31
 
27
32
  // Every card renders from this one shape, so they share size and structure: a
@@ -54,7 +59,7 @@
54
59
  return ok;
55
60
  }
56
61
  // Feature flags live in a separate store (app_setting) with their own endpoint.
57
- async function toggleFlag(flag: EditableFlag, next: boolean): Promise<void> {
62
+ async function toggleFlag(flag: ToggleFlag, next: boolean): Promise<void> {
58
63
  saving = true;
59
64
  try {
60
65
  await api.put("/account/settings", { [flag]: next });
@@ -174,6 +179,14 @@
174
179
  ]);
175
180
  const security = $derived<Item[]>([
176
181
  flagCard("twoFactor", i18n.t.settings.twoFactor, i18n.t.settings.twoFactorDesc),
182
+ {
183
+ key: "flag-require2fa",
184
+ name: i18n.t.settings.require2fa,
185
+ desc: i18n.t.settings.require2faDesc,
186
+ status: { on: data.require2fa, label: data.require2fa ? i18n.t.settings.enabled : i18n.t.settings.disabled },
187
+ // Only meaningful when 2FA itself is on; disabled otherwise.
188
+ toggle: { checked: data.require2fa, disabled: saving || !caps.twoFactor, onChange: (v) => toggleFlag("require2fa", v) },
189
+ },
177
190
  serverCard("hibp", i18n.t.settings.breachCheck, i18n.t.settings.breachCheckDesc, caps.passwordBreachCheck),
178
191
  serverCard("allowDelete", i18n.t.settings.accountDeletion, i18n.t.settings.accountDeletionDesc, caps.deleteAccount),
179
192
  serverCard("auditLog", i18n.t.settings.auditLog, i18n.t.settings.auditLogDesc, caps.auditLog),
@@ -226,6 +239,7 @@
226
239
  <Tabs.Root value="general">
227
240
  <Tabs.List>
228
241
  <Tabs.Trigger value="general">{i18n.t.general.tab}</Tabs.Trigger>
242
+ <Tabs.Trigger value="appearance">{i18n.t.general.appearanceTitle}</Tabs.Trigger>
229
243
  <Tabs.Trigger value="auth">{i18n.t.settings.tab}</Tabs.Trigger>
230
244
  {#each moduleSettingsSections as s (s.value)}
231
245
  <Tabs.Trigger value={s.value}>{s.label(i18n.t)}</Tabs.Trigger>
@@ -236,6 +250,10 @@
236
250
  <GeneralSettings />
237
251
  </Tabs.Content>
238
252
 
253
+ <Tabs.Content value="appearance" class="mt-6">
254
+ <AppearanceCard />
255
+ </Tabs.Content>
256
+
239
257
  <Tabs.Content value="auth" class="mt-6 flex flex-col gap-6">
240
258
  {#each sections as section (section.title)}
241
259
  <section class="flex flex-col gap-3">
@@ -295,15 +313,16 @@
295
313
  {#if socialForm.adding}
296
314
  <div class="flex flex-col gap-1">
297
315
  <Label for="social-provider">{i18n.t.settings.provider}</Label>
298
- <select
299
- id="social-provider"
300
- bind:value={socialForm.id}
301
- class="border-input bg-background ring-offset-background focus-visible:ring-ring h-9 rounded-md border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
302
- >
303
- {#each addable as p (p.id)}
304
- <option value={p.id}>{p.label}</option>
305
- {/each}
306
- </select>
316
+ <Select.Root type="single" bind:value={socialForm.id}>
317
+ <Select.Trigger id="social-provider" class="w-full">
318
+ {addable.find((p) => p.id === socialForm.id)?.label ?? i18n.t.settings.provider}
319
+ </Select.Trigger>
320
+ <Select.Content>
321
+ {#each addable as p (p.id)}
322
+ <Select.Item value={p.id} label={p.label}>{p.label}</Select.Item>
323
+ {/each}
324
+ </Select.Content>
325
+ </Select.Root>
307
326
  </div>
308
327
  {/if}
309
328
  <label class="flex items-center justify-between gap-3 text-sm font-medium">{i18n.t.settings.smtpEnabled}<Switch aria-label={i18n.t.settings.smtpEnabled} bind:checked={socialForm.enabled} /></label>
@@ -19,7 +19,6 @@
19
19
  description: "",
20
20
  supportEmail: "",
21
21
  footerText: "",
22
- brandColor: "",
23
22
  termsUrl: "",
24
23
  privacyUrl: "",
25
24
  locale: "",
@@ -35,7 +34,6 @@
35
34
  description: s.description ?? "",
36
35
  supportEmail: s.supportEmail ?? "",
37
36
  footerText: s.footerText ?? "",
38
- brandColor: s.brandColor ?? "",
39
37
  termsUrl: s.termsUrl ?? "",
40
38
  privacyUrl: s.privacyUrl ?? "",
41
39
  locale: s.locale ?? "",
@@ -127,11 +125,6 @@
127
125
  </div>
128
126
  </div>
129
127
  </div>
130
-
131
- <div class="flex flex-col gap-1.5">
132
- <Label for="brand-color">{t.brandColor}</Label>
133
- <Input id="brand-color" bind:value={form.brandColor} placeholder="#6d28d9" />
134
- </div>
135
128
  </Card.Content>
136
129
  </Card.Root>
137
130
 
@@ -22,6 +22,49 @@
22
22
  let unverified = $state(false);
23
23
  let loading = $state(false);
24
24
 
25
+ // Second-factor step: a password/OTP sign-in with 2FA enabled returns
26
+ // `twoFactorRedirect` and no session; the user then verifies with an
27
+ // authenticator code or a backup code to complete the login.
28
+ let twoFaRequired = $state(false);
29
+ let twoFaCode = $state("");
30
+ let useBackupCode = $state(false);
31
+ let twoFaLoading = $state(false);
32
+ const redirectTo = (): string => page.url.searchParams.get("redirect") ?? "/admin";
33
+
34
+ // Returns true when 2FA is still owed (caller shows the step instead of leaving).
35
+ function needsTwoFactor(data: unknown): boolean {
36
+ return Boolean(data && typeof data === "object" && (data as { twoFactorRedirect?: boolean }).twoFactorRedirect);
37
+ }
38
+
39
+ // Map better-auth's (English) two-factor error codes to localized copy — the
40
+ // backend returns a stable `code`, never a translated message (see i18n policy).
41
+ function twoFactorError(code: string | undefined): string {
42
+ switch (code) {
43
+ case "INVALID_CODE":
44
+ case "INVALID_BACKUP_CODE":
45
+ return i18n.t.auth.twoFactorInvalidCode;
46
+ case "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE":
47
+ case "ACCOUNT_TEMPORARILY_LOCKED":
48
+ return i18n.t.auth.twoFactorLocked;
49
+ default:
50
+ return i18n.t.auth.signInFailed;
51
+ }
52
+ }
53
+
54
+ async function verifyTwoFactor(): Promise<void> {
55
+ twoFaLoading = true;
56
+ error = null;
57
+ const { error: authError } = useBackupCode
58
+ ? await api.auth.twoFactor.verifyBackupCode({ code: twoFaCode.trim() })
59
+ : await api.auth.twoFactor.verifyTotp({ code: twoFaCode.trim() });
60
+ twoFaLoading = false;
61
+ if (authError) {
62
+ error = twoFactorError(authError.code);
63
+ return;
64
+ }
65
+ await goto(redirectTo(), { invalidateAll: true });
66
+ }
67
+
25
68
  // Passwordless sign-in, shown only when the server enabled the magic-link plugin.
26
69
  const magicLinkEnabled = $derived(data.capabilities?.magicLink ?? false);
27
70
  let magicLoading = $state(false);
@@ -69,13 +112,17 @@
69
112
  async function verifyOtp(): Promise<void> {
70
113
  otpLoading = true;
71
114
  error = null;
72
- const { error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
115
+ const { data, error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
73
116
  otpLoading = false;
74
117
  if (authError) {
75
118
  error = authError.message ?? i18n.t.auth.signInFailed;
76
119
  return;
77
120
  }
78
- await goto(page.url.searchParams.get("redirect") ?? "/admin", { invalidateAll: true });
121
+ if (needsTwoFactor(data)) {
122
+ twoFaRequired = true;
123
+ return;
124
+ }
125
+ await goto(redirectTo(), { invalidateAll: true });
79
126
  }
80
127
 
81
128
  // Passwordless sign-in with a registered passkey (WebAuthn), shown when enabled.
@@ -100,7 +147,7 @@
100
147
  unverified = false;
101
148
  // With the username plugin on, an identifier without "@" is treated as a username.
102
149
  const asUsername = usernameEnabled && !email.includes("@");
103
- const { error: authError } = asUsername
150
+ const { data, error: authError } = asUsername
104
151
  ? await api.auth.signIn.username({ username: email, password })
105
152
  : await api.auth.signIn.email({ email, password });
106
153
  loading = false;
@@ -110,7 +157,11 @@
110
157
  error = unverified ? i18n.t.auth.emailNotVerified : (authError.message ?? i18n.t.auth.signInFailed);
111
158
  return;
112
159
  }
113
- await goto(page.url.searchParams.get("redirect") ?? "/admin", { invalidateAll: true });
160
+ if (needsTwoFactor(data)) {
161
+ twoFaRequired = true;
162
+ return;
163
+ }
164
+ await goto(redirectTo(), { invalidateAll: true });
114
165
  }
115
166
  </script>
116
167
 
@@ -120,17 +171,47 @@
120
171
  <Card.Description>{i18n.t.auth.signInDesc}</Card.Description>
121
172
  </Card.Header>
122
173
  <Card.Content>
174
+ {#if error}
175
+ <Alert.Root variant="destructive" class="mb-4">
176
+ <Alert.Description>
177
+ {error}
178
+ {#if unverified}
179
+ <a href="/verify-email?email={encodeURIComponent(email)}" class="font-medium underline">{i18n.t.auth.resendVerification}</a>
180
+ {/if}
181
+ </Alert.Description>
182
+ </Alert.Root>
183
+ {/if}
184
+ {#if twoFaRequired}
185
+ <div class="flex flex-col gap-4" data-testid="two-factor-step">
186
+ <p class="text-muted-foreground text-sm">{useBackupCode ? i18n.t.auth.twoFactorBackupPrompt : i18n.t.auth.twoFactorPrompt}</p>
187
+ <div class="flex flex-col gap-2">
188
+ <Label for="twofa">{useBackupCode ? i18n.t.auth.backupCodeLabel : i18n.t.auth.twoFactorCodeLabel}</Label>
189
+ <Input
190
+ id="twofa"
191
+ bind:value={twoFaCode}
192
+ inputmode={useBackupCode ? "text" : "numeric"}
193
+ autocomplete="one-time-code"
194
+ autofocus
195
+ />
196
+ </div>
197
+ <Button type="button" disabled={twoFaLoading || !twoFaCode} onclick={verifyTwoFactor}>
198
+ {twoFaLoading ? i18n.t.auth.signingIn : i18n.t.auth.verify}
199
+ </Button>
200
+ <Button
201
+ type="button"
202
+ variant="link"
203
+ class="h-auto justify-start p-0 text-xs"
204
+ onclick={() => {
205
+ useBackupCode = !useBackupCode;
206
+ twoFaCode = "";
207
+ error = null;
208
+ }}
209
+ >
210
+ {useBackupCode ? i18n.t.auth.twoFactorUseAuthenticator : i18n.t.auth.twoFactorUseBackup}
211
+ </Button>
212
+ </div>
213
+ {:else}
123
214
  <form class="flex flex-col gap-4" onsubmit={submit}>
124
- {#if error}
125
- <Alert.Root variant="destructive">
126
- <Alert.Description>
127
- {error}
128
- {#if unverified}
129
- <a href="/verify-email?email={encodeURIComponent(email)}" class="font-medium underline">{i18n.t.auth.resendVerification}</a>
130
- {/if}
131
- </Alert.Description>
132
- </Alert.Root>
133
- {/if}
134
215
  <div class="flex flex-col gap-2">
135
216
  <Label for="email">{usernameEnabled ? i18n.t.auth.emailOrUsername : i18n.t.auth.email}</Label>
136
217
  <Input id="email" type={usernameEnabled ? "text" : "email"} bind:value={email} required autocomplete="username" />
@@ -177,6 +258,7 @@
177
258
  {/if}
178
259
  </div>
179
260
  {/if}
261
+ {/if}
180
262
  </Card.Content>
181
263
  {#if signupOpen}
182
264
  <Card.Footer class="justify-center">
@@ -10,6 +10,7 @@ const PUBLIC_PATHS = [
10
10
  "/signup",
11
11
  "/forgot-password",
12
12
  "/reset-password",
13
+ "/verify-email",
13
14
  "/maintenance",
14
15
  // Modules add their public (no-session) page prefixes here.
15
16
  // podokit:begin:public-paths
@@ -0,0 +1,4 @@
1
+ import { loadAccountData } from "$lib/account-data.server";
2
+ import type { PageServerLoad } from "./$types";
3
+
4
+ export const load: PageServerLoad = ({ locals, fetch }) => loadAccountData(locals, fetch);
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import AccountPage from "$lib/components/account-page.svelte";
3
+ import LanguageSwitch from "$lib/components/language-switch.svelte";
4
+ import SiteFooter from "$lib/components/site-footer.svelte";
5
+ import ThemeToggle from "$lib/components/theme-toggle.svelte";
6
+ import { Button } from "$lib/components/ui/button";
7
+ import { Toaster } from "$lib/components/ui/sonner";
8
+ import { getI18n } from "$lib/i18n";
9
+ import type { Capabilities } from "@podosoft/podokit-api-client";
10
+ import type { SessionUser } from "../../app.d.ts";
11
+
12
+ let {
13
+ data,
14
+ }: {
15
+ data: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities };
16
+ } = $props();
17
+ const i18n = getI18n();
18
+ </script>
19
+
20
+ <div class="flex min-h-svh flex-col">
21
+ <header class="flex h-14 items-center gap-2 border-b px-4">
22
+ <Button href="/" variant="ghost">{i18n.t.nav.home}</Button>
23
+ <div class="flex-1"></div>
24
+ {#if data.user.role === "admin"}
25
+ <Button href="/admin" variant="outline">{i18n.t.common.appName}</Button>
26
+ {/if}
27
+ <LanguageSwitch />
28
+ <ThemeToggle />
29
+ </header>
30
+ <main class="mx-auto w-full max-w-6xl flex-1 p-4 sm:p-6">
31
+ <AccountPage {data} />
32
+ </main>
33
+ <SiteFooter />
34
+ </div>
35
+ <Toaster />
@@ -0,0 +1,11 @@
1
+ import { redirect } from "@sveltejs/kit";
2
+ import type { PageServerLoad } from "./$types";
3
+
4
+ // The mandatory two-factor enrolment page (reached via the require-2fa gate).
5
+ // Must be signed in; already-enrolled users have nothing to do here.
6
+ export const load: PageServerLoad = ({ locals }) => {
7
+ if (!locals.user) redirect(302, "/login");
8
+ const user = locals.user as App.Locals["user"] & { twoFactorEnabled?: boolean };
9
+ if (user.twoFactorEnabled) redirect(302, "/admin");
10
+ return { user: locals.user };
11
+ };
@@ -0,0 +1,122 @@
1
+ <script lang="ts">
2
+ import { goto } from "$app/navigation";
3
+ import QRCode from "qrcode";
4
+ import { Button } from "$lib/components/ui/button";
5
+ import { Input } from "$lib/components/ui/input";
6
+ import { Label } from "$lib/components/ui/label";
7
+ import { Checkbox } from "$lib/components/ui/checkbox";
8
+ import * as Card from "$lib/components/ui/card";
9
+ import * as Alert from "$lib/components/ui/alert";
10
+ import { api } from "$lib/api";
11
+ import { getI18n } from "$lib/i18n";
12
+
13
+ const i18n = getI18n();
14
+ let password = $state("");
15
+ let code = $state("");
16
+ let busy = $state(false);
17
+ let error = $state<string | null>(null);
18
+ let saved = $state(false);
19
+ let setup = $state<{ totpURI: string; backupCodes: string[] } | null>(null);
20
+ let qrDataUrl = $state("");
21
+
22
+ $effect(() => {
23
+ const uri = setup?.totpURI;
24
+ if (!uri) {
25
+ qrDataUrl = "";
26
+ return;
27
+ }
28
+ QRCode.toDataURL(uri, { margin: 1, width: 176 })
29
+ .then((url) => (qrDataUrl = url))
30
+ .catch(() => (qrDataUrl = ""));
31
+ });
32
+
33
+ async function begin(): Promise<void> {
34
+ busy = true;
35
+ error = null;
36
+ const { data, error: e } = await api.auth.twoFactor.enable({ password });
37
+ busy = false;
38
+ if (e) {
39
+ error = e.code === "INVALID_PASSWORD" ? i18n.t.setup2fa.wrongPassword : i18n.t.setup2fa.enableFailed;
40
+ return;
41
+ }
42
+ setup = { totpURI: data?.totpURI ?? "", backupCodes: (data?.backupCodes ?? []) as string[] };
43
+ }
44
+
45
+ async function activate(): Promise<void> {
46
+ busy = true;
47
+ error = null;
48
+ const { error: e } = await api.auth.twoFactor.verifyTotp({ code: code.trim() });
49
+ busy = false;
50
+ if (e) {
51
+ error = i18n.t.auth.twoFactorInvalidCode;
52
+ return;
53
+ }
54
+ await goto("/admin", { invalidateAll: true });
55
+ }
56
+
57
+ function downloadCodes(): void {
58
+ if (!setup) return;
59
+ const blob = new Blob([setup.backupCodes.join("\n") + "\n"], { type: "text/plain" });
60
+ const url = URL.createObjectURL(blob);
61
+ const a = document.createElement("a");
62
+ a.href = url;
63
+ a.download = "backup-codes.txt";
64
+ a.click();
65
+ URL.revokeObjectURL(url);
66
+ }
67
+
68
+ async function signOut(): Promise<void> {
69
+ await api.auth.signOut();
70
+ await goto("/login", { invalidateAll: true });
71
+ }
72
+ </script>
73
+
74
+ <div class="flex min-h-svh items-center justify-center p-4">
75
+ <Card.Root class="w-full max-w-md">
76
+ <Card.Header>
77
+ <Card.Title>{i18n.t.setup2fa.title}</Card.Title>
78
+ <Card.Description>{i18n.t.setup2fa.subtitle}</Card.Description>
79
+ </Card.Header>
80
+ <Card.Content class="flex flex-col gap-4">
81
+ {#if error}
82
+ <Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>
83
+ {/if}
84
+
85
+ {#if !setup}
86
+ <div class="flex flex-col gap-2">
87
+ <Label for="pw">{i18n.t.setup2fa.passwordLabel}</Label>
88
+ <Input id="pw" type="password" bind:value={password} autocomplete="current-password" />
89
+ </div>
90
+ <Button disabled={busy || !password} onclick={begin}>{i18n.t.setup2fa.begin}</Button>
91
+ {:else}
92
+ <div class="flex flex-col gap-1">
93
+ <Label>{i18n.t.setup2fa.step1}</Label>
94
+ {#if qrDataUrl}
95
+ <img src={qrDataUrl} alt="TOTP QR code" width="176" height="176" class="rounded border bg-white p-2" />
96
+ {/if}
97
+ <code class="bg-muted overflow-x-auto rounded px-2 py-1 text-xs">{setup.totpURI}</code>
98
+ </div>
99
+
100
+ <div class="flex flex-col gap-1">
101
+ <Label>{i18n.t.setup2fa.step2}</Label>
102
+ <p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
103
+ <div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
104
+ {#each setup.backupCodes as c (c)}<span>{c}</span>{/each}
105
+ </div>
106
+ <Button variant="outline" size="sm" class="w-fit" onclick={downloadCodes}>{i18n.t.account.downloadBackupCodes}</Button>
107
+ <label class="mt-1 flex items-center gap-2 text-sm">
108
+ <Checkbox bind:checked={saved} /> {i18n.t.setup2fa.savedConfirm}
109
+ </label>
110
+ </div>
111
+
112
+ <div class="flex flex-col gap-2">
113
+ <Label for="code">{i18n.t.setup2fa.step3}</Label>
114
+ <Input id="code" bind:value={code} inputmode="numeric" autocomplete="one-time-code" />
115
+ </div>
116
+ <Button disabled={busy || !saved || !code} onclick={activate}>{i18n.t.setup2fa.activate}</Button>
117
+ {/if}
118
+
119
+ <Button variant="link" class="h-auto justify-start p-0 text-xs" onclick={signOut}>{i18n.t.setup2fa.signOut}</Button>
120
+ </Card.Content>
121
+ </Card.Root>
122
+ </div>
@@ -74,6 +74,112 @@ test("two-factor: enable with a real TOTP code and require it at sign-in @smoke"
74
74
  await ctx2.dispose();
75
75
  });
76
76
 
77
+ test("two-factor: a backup code completes sign-in and is single-use @smoke", async ({ playwright }) => {
78
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
79
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
80
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
81
+ const email = `tf-bc-${Date.now()}@example.com`;
82
+ const pw = "Podokit3e-Str0ng!pw";
83
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "BC" } });
84
+
85
+ // Enable 2FA; the enable response includes the one-time backup codes.
86
+ const enable = await (await ctx.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
87
+ await ctx.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
88
+ const backupCodes = enable.backupCodes as string[];
89
+ expect(Array.isArray(backupCodes)).toBe(true);
90
+ expect(backupCodes.length).toBeGreaterThan(0);
91
+ const code = backupCodes[0]!;
92
+
93
+ // A backup code completes a challenged sign-in (no authenticator needed).
94
+ const ctx2 = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
95
+ const signin = await (await ctx2.post("/api/auth/sign-in/email", { data: { email, password: pw } })).json();
96
+ expect(signin.twoFactorRedirect).toBe(true);
97
+ const used = await ctx2.post("/api/auth/two-factor/verify-backup-code", { data: { code } });
98
+ expect(used.ok()).toBeTruthy();
99
+ const session = await (await ctx2.get("/api/auth/get-session")).json();
100
+ expect(session?.user?.email).toBe(email);
101
+
102
+ // The same code is single-use: a new challenge cannot reuse it.
103
+ const ctx3 = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
104
+ await ctx3.post("/api/auth/sign-in/email", { data: { email, password: pw } });
105
+ const reuse = await ctx3.post("/api/auth/two-factor/verify-backup-code", { data: { code } });
106
+ expect(reuse.ok()).toBeFalsy();
107
+
108
+ await ctx.dispose();
109
+ await ctx2.dispose();
110
+ await ctx3.dispose();
111
+ });
112
+
113
+ test("two-factor: regenerating backup codes invalidates the old set @smoke", async ({ playwright }) => {
114
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
115
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
116
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
117
+ const email = `tf-rg-${Date.now()}@example.com`;
118
+ const pw = "Podokit3e-Str0ng!pw";
119
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RG" } });
120
+ const enable = await (await ctx.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
121
+ await ctx.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
122
+ const oldCode = (enable.backupCodes as string[])[0]!;
123
+
124
+ // Regenerating returns a fresh set; the previous codes are replaced.
125
+ const regen = await (await ctx.post("/api/auth/two-factor/generate-backup-codes", { data: { password: pw } })).json();
126
+ const newCodes = regen.backupCodes as string[];
127
+ expect(Array.isArray(newCodes)).toBe(true);
128
+ expect(newCodes.length).toBeGreaterThan(0);
129
+ expect(newCodes).not.toContain(oldCode);
130
+
131
+ // An old code no longer completes a challenge; a new one does.
132
+ const ctxOld = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
133
+ await ctxOld.post("/api/auth/sign-in/email", { data: { email, password: pw } });
134
+ expect((await ctxOld.post("/api/auth/two-factor/verify-backup-code", { data: { code: oldCode } })).ok()).toBeFalsy();
135
+
136
+ const ctxNew = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
137
+ await ctxNew.post("/api/auth/sign-in/email", { data: { email, password: pw } });
138
+ expect((await ctxNew.post("/api/auth/two-factor/verify-backup-code", { data: { code: newCodes[0]! } })).ok()).toBeTruthy();
139
+
140
+ await ctx.dispose();
141
+ await ctxOld.dispose();
142
+ await ctxNew.dispose();
143
+ });
144
+
145
+ test("require-2fa: gates an un-enrolled user until they enrol @smoke", async ({ playwright }) => {
146
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
147
+ const caps = await (await admin.get("/api/account/capabilities")).json();
148
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
149
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
150
+
151
+ const user = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
152
+ const email = `rq-${Date.now()}@example.com`;
153
+ const pw = "Podokit3e-Str0ng!pw";
154
+ await user.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQ" } });
155
+
156
+ try {
157
+ // Turn the policy on (admin; the toggle endpoint is allow-listed by the guard).
158
+ await admin.put("/api/account/settings", { data: { require2fa: true } });
159
+ // The guard caches for a few seconds — poll until the un-enrolled user is gated.
160
+ await expect(async () => {
161
+ expect((await user.get("/api/account/me")).status()).toBe(403);
162
+ }).toPass({ timeout: 8000 });
163
+
164
+ // Enrolling restores access.
165
+ const enable = await (await user.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
166
+ await user.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
167
+ expect((await user.get("/api/account/me")).status()).toBe(200);
168
+ } finally {
169
+ // Always turn it back off so the shared (un-enrolled) sessions aren't gated.
170
+ await admin.put("/api/account/settings", { data: { require2fa: false } });
171
+ await expect(async () => {
172
+ const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
173
+ await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
174
+ const status = (await probe.get("/api/account/me")).status();
175
+ await probe.dispose();
176
+ expect(status).toBe(200);
177
+ }).toPass({ timeout: 8000 });
178
+ await admin.dispose();
179
+ await user.dispose();
180
+ }
181
+ });
182
+
77
183
  test("multi-session holds several accounts and switches between them @smoke", async ({ playwright }) => {
78
184
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
79
185
  const caps = await (await ctx.get("/api/account/capabilities")).json();
@@ -0,0 +1,56 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ADMIN } from "../helpers/accounts";
3
+
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+ const origin = { origin: base };
6
+
7
+ // Fresh admin context so these never rotate the seeded admin session.
8
+ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
9
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
10
+ await ctx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
11
+ return ctx;
12
+ }
13
+
14
+ test("theme settings: valid preset/radius/accent round-trip @smoke", async ({ playwright }) => {
15
+ const admin = await adminCtx(playwright);
16
+ const put = await admin.put("/api/site/settings", {
17
+ data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
18
+ });
19
+ expect(put.ok()).toBeTruthy();
20
+
21
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
22
+ const settings = await (await anon.get("/api/site/settings")).json();
23
+ expect(settings.themePreset).toBe("slate");
24
+ expect(settings.themeRadius).toBe("0.75");
25
+ expect(settings.brandColor).toBe("#2563eb");
26
+
27
+ // restore
28
+ await admin.put("/api/site/settings", { data: { themePreset: "", themeRadius: "", brandColor: "" } });
29
+ await anon.dispose();
30
+ });
31
+
32
+ test("theme settings: valid per-token overrides accepted @smoke", async ({ playwright }) => {
33
+ const admin = await adminCtx(playwright);
34
+ const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
35
+ const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
36
+ expect(put.ok()).toBeTruthy();
37
+ await admin.put("/api/site/settings", { data: { themeOverrides: "" } }); // restore
38
+ });
39
+
40
+ test("theme settings: invalid values are rejected (no CSS injection)", async ({ playwright }) => {
41
+ const admin = await adminCtx(playwright);
42
+ const bad = [
43
+ { brandColor: "red; } body{display:none" },
44
+ { brandColor: "notacolor" },
45
+ { themeRadius: "9" },
46
+ { themeRadius: "-1" },
47
+ { themePreset: "bad preset!" },
48
+ { themeOverrides: "{not json" },
49
+ { themeOverrides: JSON.stringify({ light: { primary: "nothex" } }) },
50
+ { themeOverrides: JSON.stringify({ light: { bogusToken: "#ffffff" } }) },
51
+ { unknownKey: "x" },
52
+ ];
53
+ for (const data of bad) {
54
+ expect((await admin.put("/api/site/settings", { data })).status(), JSON.stringify(data)).toBe(400);
55
+ }
56
+ });