@podosoft/podokit 0.8.0 → 0.9.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 (25) hide show
  1. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +58 -0
  2. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
  3. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +39 -2
  4. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
  5. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +13 -2
  6. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
  7. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
  8. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
  9. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
  11. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
  12. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
  13. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
  14. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
  15. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +61 -0
  18. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +9 -0
  19. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +129 -0
  20. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
  21. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
  22. package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
  24. package/dist/templates/modules/auth/module.manifest.json +10 -0
  25. package/package.json +1 -1
@@ -16,6 +16,20 @@ const en = {
16
16
  language: { label: "Language" },
17
17
  toolbar: { filter: "Filter", search: "Search", searchButton: "Search", all: "All" },
18
18
  theme: { toggle: "Toggle theme", light: "Light", dark: "Dark", system: "System" },
19
+ setup2fa: {
20
+ title: "Set up two-factor authentication",
21
+ subtitle: "This site requires two-factor authentication. Add an authenticator app to continue.",
22
+ passwordLabel: "Confirm your password",
23
+ begin: "Continue",
24
+ step1: "1. Scan with your authenticator app",
25
+ step2: "2. Save your backup codes",
26
+ step3: "3. Enter the 6-digit code",
27
+ savedConfirm: "I've saved my backup codes",
28
+ activate: "Activate and continue",
29
+ signOut: "Sign out",
30
+ wrongPassword: "That password is incorrect.",
31
+ enableFailed: "Couldn't start setup. Please try again.",
32
+ },
19
33
  auth: {
20
34
  signInTitle: "Sign in",
21
35
  signInDesc: "Enter your credentials to continue.",
@@ -30,6 +44,15 @@ const en = {
30
44
  magicLinkSent: "Check your email for a sign-in link.",
31
45
  magicLinkNeedsEmail: "Enter your email first.",
32
46
  otpButton: "Email me a code", otpCode: "One-time code", otpVerify: "Verify code",
47
+ verify: "Verify",
48
+ twoFactorPrompt: "Enter the 6-digit code from your authenticator app.",
49
+ twoFactorBackupPrompt: "Enter one of your backup codes.",
50
+ twoFactorCodeLabel: "Authentication code",
51
+ backupCodeLabel: "Backup code",
52
+ twoFactorUseBackup: "Use a backup code instead",
53
+ twoFactorUseAuthenticator: "Use your authenticator app instead",
54
+ twoFactorInvalidCode: "That code isn't valid. Check it and try again.",
55
+ twoFactorLocked: "Too many attempts. Please wait a moment and try again.",
33
56
  noAccount: "No account?",
34
57
  signUp: "Sign up",
35
58
  signInFailed: "Sign in failed",
@@ -132,6 +155,7 @@ const en = {
132
155
  organization: "Organizations", organizationDesc: "Multi-tenant organizations with members and email invitations.",
133
156
  oidcProvider: "OIDC provider", oidcProviderDesc: "Act as an identity provider so other apps can sign in with this one.",
134
157
  twoFactor: "Two-factor authentication", twoFactorDesc: "TOTP second factor with backup codes.",
158
+ require2fa: "Require two-factor", require2faDesc: "Every user must enrol in 2FA after signing up before using the app.",
135
159
  socialProviders: "Social providers", socialProvidersDesc: "OAuth sign-in (Google, GitHub).",
136
160
  breachCheck: "Breached-password check", breachCheckDesc: "Reject passwords found in known breaches (Have I Been Pwned).",
137
161
  accountDeletion: "Account deletion", accountDeletionDesc: "Let users delete their own account.",
@@ -281,6 +305,11 @@ const en = {
281
305
  scanHint: "Add this to your authenticator app, then enter the 6-digit code.",
282
306
  setupKey: "Setup key (otpauth URI)",
283
307
  backupCodes: "Backup codes — store them somewhere safe",
308
+ backupCodesHint: "Each code works once if you lose your authenticator. Save them now — they won't be shown again.",
309
+ downloadBackupCodes: "Download",
310
+ regenerateBackupCodes: "Regenerate backup codes",
311
+ regenerateBackupCodesHint: "Generating a new set invalidates your previous backup codes.",
312
+ backupCodesRegenerated: "New backup codes generated",
284
313
  code: "Verification code",
285
314
  verify: "Verify",
286
315
  twoFactorEnabled: "Two-factor enabled",
@@ -322,6 +351,20 @@ const ko: Messages = {
322
351
  language: { label: "언어" },
323
352
  toolbar: { filter: "필터", search: "검색", searchButton: "검색", all: "전체" },
324
353
  theme: { toggle: "테마 전환", light: "라이트", dark: "다크", system: "시스템" },
354
+ setup2fa: {
355
+ title: "2단계 인증 설정",
356
+ subtitle: "이 사이트는 2단계 인증이 필수입니다. 계속하려면 인증 앱을 등록하세요.",
357
+ passwordLabel: "비밀번호 확인",
358
+ begin: "계속",
359
+ step1: "1. 인증 앱으로 스캔",
360
+ step2: "2. 백업 코드 저장",
361
+ step3: "3. 6자리 코드 입력",
362
+ savedConfirm: "백업 코드를 저장했습니다",
363
+ activate: "활성화하고 계속",
364
+ signOut: "로그아웃",
365
+ wrongPassword: "비밀번호가 올바르지 않습니다.",
366
+ enableFailed: "설정을 시작할 수 없습니다. 다시 시도해 주세요.",
367
+ },
325
368
  auth: {
326
369
  signInTitle: "로그인",
327
370
  signInDesc: "계속하려면 로그인하세요.",
@@ -336,6 +379,15 @@ const ko: Messages = {
336
379
  magicLinkSent: "이메일의 로그인 링크를 확인하세요.",
337
380
  magicLinkNeedsEmail: "먼저 이메일을 입력하세요.",
338
381
  otpButton: "코드 이메일로 받기", otpCode: "일회용 코드", otpVerify: "코드 확인",
382
+ verify: "확인",
383
+ twoFactorPrompt: "인증 앱의 6자리 코드를 입력하세요.",
384
+ twoFactorBackupPrompt: "백업 코드 중 하나를 입력하세요.",
385
+ twoFactorCodeLabel: "인증 코드",
386
+ backupCodeLabel: "백업 코드",
387
+ twoFactorUseBackup: "백업 코드로 대신 인증",
388
+ twoFactorUseAuthenticator: "인증 앱으로 대신 인증",
389
+ twoFactorInvalidCode: "코드가 올바르지 않습니다. 다시 확인해 주세요.",
390
+ twoFactorLocked: "시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.",
339
391
  noAccount: "계정이 없으신가요?",
340
392
  signUp: "회원가입",
341
393
  signInFailed: "로그인에 실패했습니다",
@@ -438,6 +490,7 @@ const ko: Messages = {
438
490
  organization: "조직", organizationDesc: "멤버와 이메일 초대를 갖춘 멀티테넌트 조직.",
439
491
  oidcProvider: "OIDC 제공자", oidcProviderDesc: "다른 앱이 이 앱으로 로그인하도록 신원 제공자 역할.",
440
492
  twoFactor: "2단계 인증", twoFactorDesc: "TOTP 2차 인증 + 백업 코드.",
493
+ require2fa: "2FA 필수화", require2faDesc: "모든 사용자가 가입 후 2FA를 등록해야 앱을 사용할 수 있습니다.",
441
494
  socialProviders: "소셜 로그인", socialProvidersDesc: "OAuth 로그인 (Google, GitHub).",
442
495
  breachCheck: "유출 비밀번호 검사", breachCheckDesc: "유출된 비밀번호 거부 (Have I Been Pwned).",
443
496
  accountDeletion: "계정 삭제", accountDeletionDesc: "사용자가 자신의 계정을 삭제.",
@@ -587,6 +640,11 @@ const ko: Messages = {
587
640
  scanHint: "인증 앱에 등록한 뒤 6자리 코드를 입력하세요.",
588
641
  setupKey: "설정 키 (otpauth URI)",
589
642
  backupCodes: "백업 코드 — 안전한 곳에 보관하세요",
643
+ backupCodesHint: "인증 앱을 분실했을 때 각 코드는 한 번씩 사용할 수 있습니다. 지금 저장하세요 — 다시 표시되지 않습니다.",
644
+ downloadBackupCodes: "다운로드",
645
+ regenerateBackupCodes: "백업 코드 재발급",
646
+ regenerateBackupCodesHint: "새로 발급하면 이전 백업 코드는 무효화됩니다.",
647
+ backupCodesRegenerated: "새 백업 코드를 발급했습니다",
590
648
  code: "인증 코드",
591
649
  verify: "확인",
592
650
  twoFactorEnabled: "2단계 인증을 사용합니다",
@@ -1,3 +1,4 @@
1
+ import { redirect } from "@sveltejs/kit";
1
2
  import type { LayoutServerLoad } from "./$types";
2
3
  import type { Capabilities } from "@podosoft/podokit-api-client";
3
4
 
@@ -26,5 +27,21 @@ export const load: LayoutServerLoad = async ({ locals, fetch }) => {
26
27
  } catch {
27
28
  /* keep defaults */
28
29
  }
30
+
31
+ // Require-two-factor policy: a signed-in user who hasn't enrolled is sent to the
32
+ // enrolment page until they do (the API's TwoFactorRequiredGuard is the real
33
+ // boundary; this is the UX). /setup-2fa lives outside this layout, so no loop.
34
+ const user = locals.user as (App.Locals["user"] & { twoFactorEnabled?: boolean }) | null;
35
+ let mustEnrol = false;
36
+ if (user && !user.twoFactorEnabled) {
37
+ try {
38
+ const res = await fetch("/api/account/require-2fa");
39
+ mustEnrol = res.ok && ((await res.json()) as { require2fa?: boolean }).require2fa === true;
40
+ } catch {
41
+ /* don't block the app if the policy check fails */
42
+ }
43
+ }
44
+ if (mustEnrol) redirect(302, "/setup-2fa");
45
+
29
46
  return { user: locals.user, impersonating: !!locals.session?.impersonatedBy, capabilities };
30
47
  };
@@ -162,8 +162,29 @@
162
162
  if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
163
163
  twoFaOn = false;
164
164
  twoFaPassword = "";
165
+ regenCodes = null;
165
166
  toast.success(i18n.t.account.twoFactorDisabled);
166
167
  }
168
+ // Backup codes: regenerate a fresh set (invalidating the old ones) and download.
169
+ let regenCodes = $state<string[] | null>(null);
170
+ async function regenerateBackupCodes(): Promise<void> {
171
+ twoFaBusy = true;
172
+ const { data: res, error } = await api.auth.twoFactor.generateBackupCodes({ password: twoFaPassword });
173
+ twoFaBusy = false;
174
+ if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
175
+ regenCodes = (res?.backupCodes ?? []) as string[];
176
+ twoFaPassword = "";
177
+ toast.success(i18n.t.account.backupCodesRegenerated);
178
+ }
179
+ function downloadBackupCodes(codes: string[]): void {
180
+ const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
181
+ const url = URL.createObjectURL(blob);
182
+ const a = document.createElement("a");
183
+ a.href = url;
184
+ a.download = "backup-codes.txt";
185
+ a.click();
186
+ URL.revokeObjectURL(url);
187
+ }
167
188
 
168
189
  // Passkeys (WebAuthn) — passwordless credentials bound to this device/browser.
169
190
  type Passkey = { id: string; name: string | null; createdAt: string | Date };
@@ -470,7 +491,21 @@
470
491
  <Label for="tf-off-pw">{i18n.t.account.password}</Label>
471
492
  <Input id="tf-off-pw" type="password" bind:value={twoFaPassword} autocomplete="current-password" />
472
493
  </div>
473
- <Button variant="destructive" class="w-fit" disabled={twoFaBusy || !twoFaPassword} onclick={disable2fa}>{i18n.t.account.disable}</Button>
494
+ <div class="flex flex-wrap gap-2">
495
+ <Button disabled={twoFaBusy || !twoFaPassword} onclick={regenerateBackupCodes}>{i18n.t.account.regenerateBackupCodes}</Button>
496
+ <Button variant="destructive" disabled={twoFaBusy || !twoFaPassword} onclick={disable2fa}>{i18n.t.account.disable}</Button>
497
+ </div>
498
+ <p class="text-muted-foreground text-xs">{i18n.t.account.regenerateBackupCodesHint}</p>
499
+ {#if regenCodes}
500
+ <div class="flex flex-col gap-1">
501
+ <Label>{i18n.t.account.backupCodes}</Label>
502
+ <p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
503
+ <div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
504
+ {#each regenCodes as c (c)}<span>{c}</span>{/each}
505
+ </div>
506
+ <Button variant="outline" size="sm" class="w-fit" onclick={() => downloadBackupCodes(regenCodes!)}>{i18n.t.account.downloadBackupCodes}</Button>
507
+ </div>
508
+ {/if}
474
509
  {:else if setup}
475
510
  <p class="text-muted-foreground text-sm">{i18n.t.account.scanHint}</p>
476
511
  {#if qrDataUrl}
@@ -482,9 +517,11 @@
482
517
  </div>
483
518
  <div class="flex flex-col gap-1">
484
519
  <Label>{i18n.t.account.backupCodes}</Label>
485
- <div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs">
520
+ <p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
521
+ <div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
486
522
  {#each setup.backupCodes as c (c)}<span>{c}</span>{/each}
487
523
  </div>
524
+ <Button variant="outline" size="sm" class="w-fit" onclick={() => downloadBackupCodes(setup!.backupCodes)}>{i18n.t.account.downloadBackupCodes}</Button>
488
525
  </div>
489
526
  <div class="flex flex-col gap-2">
490
527
  <Label for="tf-code">{i18n.t.account.code}</Label>
@@ -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
  };
@@ -16,12 +16,15 @@
16
16
  import type { Capabilities } from "@podosoft/podokit-api-client";
17
17
  import type { AuthConfigView, SocialProviderView } from "./+page.server";
18
18
 
19
- let { data }: { data: { capabilities: Capabilities; authConfig: AuthConfigView | null } } = $props();
19
+ let { data }: { data: { capabilities: Capabilities; authConfig: AuthConfigView | null; require2fa: boolean } } = $props();
20
20
  const i18n = getI18n();
21
21
  const caps = $derived(data.capabilities);
22
22
  const ac = $derived(data.authConfig);
23
23
 
24
24
  type EditableFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
25
+ // require2fa is a policy flag stored alongside the feature flags but not part of
26
+ // the typed Capabilities, so it is toggled but never read from `caps`.
27
+ type ToggleFlag = EditableFlag | "require2fa";
25
28
  type ServerKey = "requireEmailVerification" | "hibp" | "allowDelete" | "auditLog";
26
29
 
27
30
  // Every card renders from this one shape, so they share size and structure: a
@@ -54,7 +57,7 @@
54
57
  return ok;
55
58
  }
56
59
  // Feature flags live in a separate store (app_setting) with their own endpoint.
57
- async function toggleFlag(flag: EditableFlag, next: boolean): Promise<void> {
60
+ async function toggleFlag(flag: ToggleFlag, next: boolean): Promise<void> {
58
61
  saving = true;
59
62
  try {
60
63
  await api.put("/account/settings", { [flag]: next });
@@ -174,6 +177,14 @@
174
177
  ]);
175
178
  const security = $derived<Item[]>([
176
179
  flagCard("twoFactor", i18n.t.settings.twoFactor, i18n.t.settings.twoFactorDesc),
180
+ {
181
+ key: "flag-require2fa",
182
+ name: i18n.t.settings.require2fa,
183
+ desc: i18n.t.settings.require2faDesc,
184
+ status: { on: data.require2fa, label: data.require2fa ? i18n.t.settings.enabled : i18n.t.settings.disabled },
185
+ // Only meaningful when 2FA itself is on; disabled otherwise.
186
+ toggle: { checked: data.require2fa, disabled: saving || !caps.twoFactor, onChange: (v) => toggleFlag("require2fa", v) },
187
+ },
177
188
  serverCard("hibp", i18n.t.settings.breachCheck, i18n.t.settings.breachCheckDesc, caps.passwordBreachCheck),
178
189
  serverCard("allowDelete", i18n.t.settings.accountDeletion, i18n.t.settings.accountDeletionDesc, caps.deleteAccount),
179
190
  serverCard("auditLog", i18n.t.settings.auditLog, i18n.t.settings.auditLogDesc, caps.auditLog),
@@ -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,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,74 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { ready } from "../helpers/hydration";
4
+ import { smsSinkReachable, clearSms, waitForSmsOtp } from "../helpers/sms";
5
+
6
+ // Each test drives a fresh disposable account (via a real UI login) so the shared
7
+ // admin/user sessions are never mutated.
8
+ test.use({ storageState: anonState });
9
+
10
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
11
+ const origin = { origin: base };
12
+ const PW = "Podokit3e-Str0ng!pw";
13
+
14
+ async function loginFresh(page: import("@playwright/test").Page, playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"], email: string): Promise<void> {
15
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
16
+ await api.post("/api/auth/sign-up/email", { data: { email, password: PW, name: "Acc" } });
17
+ await api.dispose();
18
+ await ready(page, `/login?redirect=${encodeURIComponent("/admin/account")}`);
19
+ await page.locator("#email").fill(email);
20
+ await page.locator("#password").fill(PW);
21
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
22
+ await expect(page).toHaveURL(/\/admin\/account/);
23
+ }
24
+
25
+ test("account: register and verify a phone number @smoke", async ({ page, playwright }) => {
26
+ test.skip(!(await smsSinkReachable()), "sms sink unreachable");
27
+ await loginFresh(page, playwright, `acc-ph-${Date.now()}@example.com`);
28
+ const phoneInput = page.locator("#phone");
29
+ test.skip((await phoneInput.count()) === 0, "phone number feature disabled");
30
+
31
+ const phone = `+1555${String(Date.now()).slice(-7)}`;
32
+ await clearSms();
33
+ await phoneInput.fill(phone);
34
+ await page.getByRole("button", { name: "Send code" }).click();
35
+ const code = await waitForSmsOtp(phone);
36
+ await page.getByPlaceholder("Verification code").fill(code);
37
+ await page.getByRole("button", { name: "Verify", exact: true }).click();
38
+ await expect(page.getByText("Phone verified")).toBeVisible();
39
+ });
40
+
41
+ test("account: change email address @smoke", async ({ page, playwright }) => {
42
+ await loginFresh(page, playwright, `acc-em-${Date.now()}@example.com`);
43
+ await page.locator("#email").fill(`changed-${Date.now()}@example.com`);
44
+ await page.getByRole("button", { name: "Change email" }).click();
45
+ await expect(page.getByText(/Email address updated|approve the change/)).toBeVisible();
46
+ });
47
+
48
+ test("account: delete my own account @smoke", async ({ page, playwright }) => {
49
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
50
+ await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: PW } });
51
+ try {
52
+ // Account deletion is a server toggle (off by default) — enable it, then poll
53
+ // until the capability is live (config store has a short TTL).
54
+ await admin.put("/api/account/auth-config", { data: { server: { allowDelete: true } } });
55
+ await expect(async () => {
56
+ const caps = await (await admin.get("/api/account/capabilities")).json();
57
+ expect(caps.deleteAccount).toBe(true);
58
+ }).toPass({ timeout: 8000 });
59
+
60
+ await loginFresh(page, playwright, `acc-del-${Date.now()}@example.com`);
61
+ await page.getByRole("button", { name: "Danger zone" }).click();
62
+ await page.getByRole("button", { name: "Delete account" }).first().click();
63
+ await page.locator("#del-pw").fill(PW);
64
+ // The auth instance rebuilds a few seconds after allowDelete is enabled, so
65
+ // the first delete can be rejected — retry the confirm until it goes through.
66
+ await expect(async () => {
67
+ await page.getByRole("dialog").getByRole("button", { name: "Delete account" }).click();
68
+ await expect(page).toHaveURL(/\/login/, { timeout: 3000 });
69
+ }).toPass({ timeout: 15000 });
70
+ } finally {
71
+ await admin.put("/api/account/auth-config", { data: { server: { allowDelete: false } } });
72
+ await admin.dispose();
73
+ }
74
+ });
@@ -43,3 +43,14 @@ test("reset-password without a token is disabled", async ({ page }) => {
43
43
  await expect(page.getByRole("alert")).toBeVisible();
44
44
  await expect(page.getByRole("button", { name: "Update password" })).toBeDisabled();
45
45
  });
46
+
47
+ test("signup creates an account and enters the app @smoke", async ({ page }) => {
48
+ await ready(page, "/signup");
49
+ const email = `signup-${Date.now()}@example.com`;
50
+ await page.getByLabel("Name").fill("New User");
51
+ await page.getByLabel("Email").fill(email);
52
+ await page.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
53
+ await page.getByRole("button", { name: "Create account" }).click();
54
+ // Verification off → straight into the app; on → the verify-email page.
55
+ await expect(page).toHaveURL(/\/admin|\/verify-email/);
56
+ });
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+ import { USER } from "../helpers/accounts";
4
+
5
+ // admin storageState (project default)
6
+
7
+ test("admin can impersonate a user and stop @smoke", async ({ page }) => {
8
+ await ready(page, "/admin/users");
9
+ await page.locator("#toolbar-search").fill(USER.email);
10
+ await page.getByRole("button", { name: "Search", exact: true }).click();
11
+
12
+ const row = page.getByRole("row", { name: new RegExp(USER.email) });
13
+ await row.getByRole("button").click();
14
+ await page.getByRole("menuitem", { name: "Impersonate" }).click();
15
+
16
+ // Impersonating drops us into the app with a banner offering to stop.
17
+ await expect(page).toHaveURL(/\/admin(\/|$)/);
18
+ const stop = page.getByRole("button", { name: "Stop impersonating" });
19
+ await expect(stop).toBeVisible();
20
+
21
+ await stop.click();
22
+ await expect(page.getByRole("button", { name: "Stop impersonating" })).toHaveCount(0);
23
+ });
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { ready } from "../helpers/hydration";
4
+ import { mailpitReachable, clearMailpit, waitForOtp } from "../helpers/mailpit";
5
+
6
+ test.use({ storageState: anonState });
7
+
8
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
9
+ const origin = { origin: base };
10
+
11
+ test("login: magic link shows a sent confirmation @smoke", async ({ page, playwright }) => {
12
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
13
+ const caps = await (await api.get("/api/account/capabilities")).json();
14
+ await api.dispose();
15
+ test.skip(!caps?.magicLink, "magic link disabled");
16
+
17
+ await ready(page, "/login");
18
+ await page.locator("#email").fill(`ml-${Date.now()}@example.com`);
19
+ await page.getByRole("button", { name: /sign-in link/i }).click();
20
+ await expect(page.getByTestId("magic-link-sent")).toBeVisible();
21
+ });
22
+
23
+ test("login: an emailed one-time code signs a user in @smoke", async ({ page, playwright }) => {
24
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
25
+ const caps = await (await api.get("/api/account/capabilities")).json();
26
+ test.skip(!caps?.emailOtp, "email otp disabled");
27
+ test.skip(!(await mailpitReachable()), "mailpit unreachable");
28
+
29
+ const email = `otp-${Date.now()}@example.com`;
30
+ await api.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "OTP" } });
31
+ await api.dispose();
32
+ await clearMailpit();
33
+
34
+ await ready(page, "/login");
35
+ await page.locator("#email").fill(email);
36
+ await page.getByRole("button", { name: "Email me a code" }).click();
37
+ const otp = await waitForOtp(email);
38
+ await page.getByLabel("One-time code").fill(otp);
39
+ await page.getByRole("button", { name: "Verify code" }).click();
40
+ await expect(page).toHaveURL(/\/admin/);
41
+ });
@@ -26,14 +26,18 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff @sm
26
26
  const jwks = await (await page.request.get(disc.jwks_uri)).json();
27
27
  expect(Array.isArray(jwks.keys) && jwks.keys.length).toBeTruthy();
28
28
 
29
- // Dynamic client registration returns usable credentials.
30
- const reg = await (
31
- await page.request.post("/api/auth/oauth2/create-client", {
32
- data: { client_name: "E2E RP", redirect_uris: [REDIRECT] },
33
- headers,
34
- })
35
- ).json();
36
- expect(reg.client_id).toBeTruthy();
29
+ // Dynamic client registration returns usable credentials. Retry: right after
30
+ // the provider is enabled the auth instance may still be rebuilding.
31
+ let reg: { client_id?: string; client_secret?: string } = {};
32
+ await expect(async () => {
33
+ reg = await (
34
+ await page.request.post("/api/auth/oauth2/create-client", {
35
+ data: { client_name: "E2E RP", redirect_uris: [REDIRECT] },
36
+ headers,
37
+ })
38
+ ).json();
39
+ expect(reg.client_id).toBeTruthy();
40
+ }).toPass({ timeout: 8000 });
37
41
  expect(reg.client_secret).toBeTruthy();
38
42
 
39
43
  // Authorize validates the client + PKCE and hands off to the consent page with
@@ -53,3 +53,38 @@ test("create an org with a non-ascii name, a parent, and a manager @smoke", asyn
53
53
  await page.getByRole("row", { name: new RegExp(parent) }).getByRole("button", { name: "Delete" }).first().click();
54
54
  await expect(page.getByText("Organization deleted").first()).toBeVisible();
55
55
  });
56
+
57
+ test("organization manage: remove a member, invite one, and cancel the invite @smoke", async ({ page }) => {
58
+ await ready(page, "/admin/organizations");
59
+ const newBtn = page.getByRole("button", { name: "New organization" });
60
+ test.skip((await newBtn.count()) === 0, "organizations not enabled");
61
+
62
+ const name = `Mng ${Date.now()}`;
63
+ await newBtn.click();
64
+ await page.getByLabel("Name", { exact: true }).fill(name);
65
+ // Seed a specific non-owner member (the admin is always the owner, so pick the
66
+ // seeded user — .first() is order-dependent and could hit the owner).
67
+ await page.locator("label", { hasText: "user@example.com" }).getByRole("checkbox").click();
68
+ await page.getByRole("dialog").getByRole("button", { name: "Create", exact: true }).click();
69
+ await expect(page.getByText("Organization created").first()).toBeVisible();
70
+
71
+ try {
72
+ await page.getByRole("row", { name: new RegExp(name) }).getByRole("button", { name: "Manage" }).click();
73
+ const dialog = page.getByRole("dialog");
74
+
75
+ // The seeded non-owner member is removable.
76
+ const remove = dialog.getByRole("button", { name: "Remove" }).first();
77
+ await expect(remove).toBeVisible();
78
+ await remove.click();
79
+
80
+ // Invite a member by email → pending invitation appears → cancel it.
81
+ await dialog.locator("#invite-email").fill(`invitee-${Date.now()}@example.com`);
82
+ await dialog.getByRole("button", { name: "Invite", exact: true }).click();
83
+ await expect(page.getByText("Invitation sent").first()).toBeVisible();
84
+ await dialog.getByRole("button", { name: "Cancel", exact: true }).first().click();
85
+ } finally {
86
+ await page.keyboard.press("Escape").catch(() => undefined);
87
+ await page.getByRole("row", { name: new RegExp(name) }).getByRole("button", { name: "Delete" }).first().click();
88
+ await expect(page.getByText("Organization deleted").first()).toBeVisible();
89
+ }
90
+ });
@@ -0,0 +1,61 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // admin storageState (project default)
5
+
6
+ test("settings: add and remove a social login provider @smoke", async ({ page }) => {
7
+ const dialog = page.getByRole("dialog");
8
+ const addBtn = dialog.getByRole("button", { name: "Add provider" });
9
+ const openConfigure = async (): Promise<void> => {
10
+ await page.getByRole("tab", { name: "Authentication" }).click();
11
+ await page.getByRole("button", { name: "Configure" }).click();
12
+ };
13
+
14
+ await ready(page, "/admin/settings");
15
+ // The auth-config catalog can be momentarily unavailable right after another
16
+ // spec toggles auth config (the auth instance rebuilds). Reload until the
17
+ // catalog is loaded (Add provider becomes enabled).
18
+ await expect(async () => {
19
+ await page.reload();
20
+ await openConfigure();
21
+ await expect(addBtn).toBeEnabled({ timeout: 3000 });
22
+ }).toPass({ timeout: 25000 });
23
+
24
+ await addBtn.click();
25
+ const select = dialog.locator("#social-provider");
26
+ await expect(select.locator("option").first()).toBeAttached();
27
+ const providerId = await select.inputValue(); // first addable provider (not hard-coded)
28
+ try {
29
+ await dialog.locator("#social-id").fill("test-client-id");
30
+ await dialog.locator("#social-secret").fill("test-secret");
31
+ await dialog.getByRole("button", { name: "Save", exact: true }).click();
32
+ // Save returns to the list view, where the new provider row has an enable switch.
33
+ await expect(dialog.getByRole("switch").first()).toBeVisible();
34
+ } finally {
35
+ if (providerId) {
36
+ await page.request
37
+ .put("/api/account/auth-config", { data: { social: { [providerId]: { delete: true } } } })
38
+ .catch(() => undefined);
39
+ }
40
+ }
41
+ });
42
+
43
+ test("settings: open and save the SMTP dialog @smoke", async ({ page }) => {
44
+ await ready(page, "/admin/settings");
45
+ await page.getByRole("tab", { name: "Authentication" }).click();
46
+ await page.getByRole("button", { name: "SMTP settings" }).click();
47
+ const dialog = page.getByRole("dialog");
48
+ try {
49
+ // Point at the same Mailpit the env already uses, so saving can't re-route mail
50
+ // for the other email specs.
51
+ await dialog.locator("#smtp-host").fill("localhost");
52
+ await dialog.locator("#smtp-port").fill("1125");
53
+ await dialog.getByRole("button", { name: "Save", exact: true }).click();
54
+ await expect(page.getByText("Setting updated").first()).toBeVisible();
55
+ } finally {
56
+ // Reset SMTP so the config store falls back to environment for later specs.
57
+ await page.request
58
+ .put("/api/account/auth-config", { data: { smtp: { enabled: false, host: "", port: 587, user: "", from: "", secure: false } } })
59
+ .catch(() => undefined);
60
+ }
61
+ });
@@ -73,3 +73,12 @@ test("admin can toggle a feature flag and it applies live @smoke", async ({ page
73
73
  await toggle.click();
74
74
  await expect(toggle).toBeChecked();
75
75
  });
76
+
77
+ test("settings: the require-two-factor toggle is available @smoke", async ({ page }) => {
78
+ await ready(page, "/admin/settings");
79
+ await page.getByRole("tab", { name: "Authentication" }).click();
80
+ // The policy toggle renders under Authentication; its enforcement is covered by
81
+ // the require-2fa api/ui specs. Not toggled here — turning it on would gate this
82
+ // admin session.
83
+ await expect(page.getByRole("switch", { name: "Require two-factor" })).toBeVisible();
84
+ });
@@ -0,0 +1,129 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { totpCode } from "../helpers/totp";
4
+
5
+ test.use({ storageState: anonState });
6
+
7
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
+ const origin = { origin: base };
9
+
10
+ // A disposable account (never the shared admin/user) so enabling 2FA here can't
11
+ // invalidate the seeded sessions the other auth specs rely on.
12
+ test("two-factor: sign in with a backup code from the login page @smoke", async ({ page, playwright }) => {
13
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
14
+ const caps = await (await api.get("/api/account/capabilities")).json();
15
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
16
+
17
+ const email = `tf-ui-${Date.now()}@example.com`;
18
+ const pw = "Podokit3e-Str0ng!pw";
19
+ await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "UI" } });
20
+ const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
21
+ await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
22
+ const backupCode = (enable.backupCodes as string[])[0]!;
23
+ await api.dispose();
24
+
25
+ // Password sign-in hands off to the second-factor step (no session yet).
26
+ await page.goto(`/login?redirect=${encodeURIComponent("/admin/account")}`);
27
+ await page.getByLabel("Email").fill(email);
28
+ await page.getByLabel("Password").fill(pw);
29
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
30
+
31
+ const step = page.getByTestId("two-factor-step");
32
+ await expect(step).toBeVisible();
33
+
34
+ // Switch to the backup-code input.
35
+ await page.getByRole("button", { name: "Use a backup code instead" }).click();
36
+
37
+ // A wrong code surfaces a localized, mapped message — not better-auth's raw
38
+ // English string ("Invalid backup code").
39
+ await page.getByLabel("Backup code").fill("00000000");
40
+ await page.getByRole("button", { name: "Verify", exact: true }).click();
41
+ await expect(page.getByRole("alert")).toContainText("isn't valid");
42
+ await expect(page.getByRole("alert")).not.toContainText("Invalid backup code");
43
+
44
+ // The real code completes the login.
45
+ await page.getByLabel("Backup code").fill(backupCode);
46
+ await page.getByRole("button", { name: "Verify", exact: true }).click();
47
+ await expect(page).toHaveURL(/\/admin\/account/);
48
+ });
49
+
50
+ test("require-2fa: a new sign-up is forced through the enrolment page @smoke", async ({ page, playwright }) => {
51
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
52
+ const caps = await (await admin.get("/api/account/capabilities")).json();
53
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
54
+ await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: "Podokit3e-Str0ng!pw" } });
55
+
56
+ const email = `rq-ui-${Date.now()}@example.com`;
57
+ const pw = "Podokit3e-Str0ng!pw";
58
+ try {
59
+ await admin.put("/api/account/settings", { data: { require2fa: true } });
60
+ // A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
61
+ const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
62
+ await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
63
+ await expect(async () => {
64
+ expect((await probe.get("/api/account/me")).status()).toBe(403);
65
+ }).toPass({ timeout: 8000 });
66
+ await probe.dispose();
67
+
68
+ // Signing in through the UI lands on the mandatory enrolment page.
69
+ await page.goto("/login");
70
+ await page.getByLabel("Email").fill(email);
71
+ await page.getByLabel("Password").fill(pw);
72
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
73
+ await expect(page).toHaveURL(/\/setup-2fa/);
74
+
75
+ // Complete enrolment entirely through the UI: password → QR + backup codes →
76
+ // authenticator code → activate. The code is computed from the on-page URI.
77
+ await page.getByLabel("Confirm your password").fill(pw);
78
+ await page.getByRole("button", { name: "Continue" }).click();
79
+ await expect(page.getByTestId("backup-codes")).toBeVisible();
80
+ await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
81
+ const uri = (await page.locator("code").first().innerText()).trim();
82
+ await page.getByRole("checkbox").click();
83
+ await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
84
+ await page.getByRole("button", { name: "Activate and continue" }).click();
85
+
86
+ // Enrolled → the gate lets them into the app (no longer bounced to /setup-2fa).
87
+ await expect(page).toHaveURL(/\/admin/);
88
+ } finally {
89
+ await admin.put("/api/account/settings", { data: { require2fa: false } });
90
+ await expect(async () => {
91
+ const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
92
+ await probe.post("/api/auth/sign-in/email", { data: { email: "user@example.com", password: "Podokit3e-Str0ng!pw" } });
93
+ const status = (await probe.get("/api/account/me")).status();
94
+ await probe.dispose();
95
+ expect(status).toBe(200);
96
+ }).toPass({ timeout: 8000 });
97
+ await admin.dispose();
98
+ }
99
+ });
100
+
101
+ test("account: regenerate backup codes shows a fresh set @smoke", async ({ page, playwright }) => {
102
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
103
+ const caps = await (await api.get("/api/account/capabilities")).json();
104
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
105
+ const email = `regen-ui-${Date.now()}@example.com`;
106
+ const pw = "Podokit3e-Str0ng!pw";
107
+ await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RG" } });
108
+ const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
109
+ await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
110
+ const backupCode = (enable.backupCodes as string[])[0]!;
111
+ await api.dispose();
112
+
113
+ // Sign in (backup-code path) straight to the account page.
114
+ await page.goto(`/login?redirect=${encodeURIComponent("/admin/account")}`);
115
+ await page.getByLabel("Email").fill(email);
116
+ await page.getByLabel("Password").fill(pw);
117
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
118
+ await page.getByRole("button", { name: "Use a backup code instead" }).click();
119
+ await page.getByLabel("Backup code").fill(backupCode);
120
+ await page.getByRole("button", { name: "Verify", exact: true }).click();
121
+ await expect(page).toHaveURL(/\/admin\/account/);
122
+
123
+ // Security → regenerate backup codes → a fresh set is shown.
124
+ await page.getByRole("button", { name: "Security" }).click();
125
+ await page.locator("#tf-off-pw").fill(pw);
126
+ await page.getByRole("button", { name: "Regenerate backup codes" }).click();
127
+ await expect(page.getByTestId("backup-codes")).toBeVisible();
128
+ await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
129
+ });
@@ -0,0 +1,17 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { ready } from "../helpers/hydration";
4
+
5
+ test.use({ storageState: anonState });
6
+
7
+ test("verify-email renders the resend form with the address prefilled @smoke", async ({ page }) => {
8
+ await ready(page, "/verify-email?email=someone%40example.com");
9
+ await expect(page.getByLabel("Email")).toHaveValue("someone@example.com");
10
+ await expect(page.getByRole("button", { name: "Resend verification email" })).toBeVisible();
11
+ await expect(page.getByRole("link", { name: /sign in/i })).toBeVisible();
12
+ });
13
+
14
+ test("verify-email shows the link-error alert on a bad link", async ({ page }) => {
15
+ await ready(page, "/verify-email?error=invalid");
16
+ await expect(page.getByRole("alert")).toBeVisible();
17
+ });
@@ -29,6 +29,14 @@ export class AccountController {
29
29
  return session.user;
30
30
  }
31
31
 
32
+ // The "require two-factor" policy, for the web enrolment gate. Kept out of the
33
+ // typed Capabilities (that lives in a published package); allow-listed in
34
+ // TwoFactorRequiredGuard so a not-yet-enrolled user can still read it.
35
+ @Get("require-2fa")
36
+ requireTwoFactor(): { require2fa: boolean } {
37
+ return { require2fa: this.settings.getBool("require2fa") };
38
+ }
39
+
32
40
  // Which optional auth features are enabled, so the UI can show/hide sections.
33
41
  // Public so the login page (unauthenticated) can offer available sign-in methods.
34
42
  @Public()
@@ -0,0 +1,68 @@
1
+ import { type CanActivate, type ExecutionContext, Injectable } from "@nestjs/common";
2
+ import type { Request } from "express";
3
+ import { fromNodeHeaders } from "better-auth/node";
4
+ import { AppException } from "@podosoft/podokit-contracts";
5
+ import { FLAG_DEFAULTS } from "../settings/flag-defaults";
6
+ import { pool } from "./db";
7
+ import { auth } from "./auth";
8
+
9
+ /**
10
+ * Enforces the "require two-factor" policy (admin Settings): while it is on, a
11
+ * signed-in user who has not enrolled in 2FA is blocked from every app route
12
+ * until they do — the security boundary behind the web enrolment gate (the web
13
+ * redirect is only UX).
14
+ *
15
+ * Registered after the better-auth AuthGuard. Requests with no user session
16
+ * (unauthenticated, or machine api-key/bearer calls) are not gated here — the
17
+ * auth guard owns authentication. A short allow-list keeps the endpoints needed
18
+ * to read the policy and enrol reachable; the 2FA enrol/verify endpoints live
19
+ * under /api/auth/two-factor (better-auth), never behind this guard.
20
+ *
21
+ * `require2fa` is read from app_setting through a small TTL cache (self-contained,
22
+ * so this global guard needs no DI), the same way auth/feature-gate.ts reads flags.
23
+ */
24
+ @Injectable()
25
+ export class TwoFactorRequiredGuard implements CanActivate {
26
+ // Reachable while gated: read the policy/capabilities, and — break-glass — the
27
+ // admin-only flag toggle, so an admin who turned the policy on (and isn't yet
28
+ // enrolled) can still turn it back off instead of being hard-locked.
29
+ private static readonly ALLOW = [
30
+ "/account/capabilities",
31
+ "/account/require-2fa",
32
+ "/account/settings",
33
+ "/health",
34
+ ];
35
+ private static readonly TTL_MS = 3_000;
36
+ private cached = FLAG_DEFAULTS.require2fa;
37
+ private fetchedAt = 0;
38
+
39
+ private async enforced(): Promise<boolean> {
40
+ if (Date.now() - this.fetchedAt < TwoFactorRequiredGuard.TTL_MS) return this.cached;
41
+ try {
42
+ const res = await pool.query<{ value: string }>(
43
+ 'SELECT "value" FROM "app_setting" WHERE "key" = $1',
44
+ ["require2fa"],
45
+ );
46
+ this.cached = res.rows[0]?.value === "true";
47
+ } catch {
48
+ this.cached = FLAG_DEFAULTS.require2fa;
49
+ }
50
+ this.fetchedAt = Date.now();
51
+ return this.cached;
52
+ }
53
+
54
+ async canActivate(context: ExecutionContext): Promise<boolean> {
55
+ if (context.getType() !== "http") return true;
56
+ const req = context.switchToHttp().getRequest<Request>();
57
+ const path = req.path || req.url || "";
58
+ if (TwoFactorRequiredGuard.ALLOW.some((p) => path === p || path.startsWith(`${p}/`))) return true;
59
+ if (!(await this.enforced())) return true;
60
+
61
+ const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
62
+ const user = session?.user as { twoFactorEnabled?: boolean } | undefined;
63
+ if (!user) return true; // unauthenticated or non-session (machine) request
64
+ if (user.twoFactorEnabled) return true;
65
+
66
+ throw new AppException("TWO_FACTOR_REQUIRED", "Two-factor enrolment is required.", 403);
67
+ }
68
+ }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Must match the rows seeded by the InitAppSettings migration. phoneNumber is
8
8
  * off by default because real delivery needs an SMS provider. */
9
- export type FeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
9
+ export type FeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider" | "require2fa";
10
10
 
11
11
  export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
12
12
  twoFactor: true,
@@ -20,6 +20,10 @@ export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
20
20
  organization: true,
21
21
  // Acting as an OIDC identity provider is an enterprise opt-in; off by default.
22
22
  oidcProvider: false,
23
+ // Policy (not a feature endpoint): when on, every signed-in user must enrol in
24
+ // two-factor before using the app. Enforced by TwoFactorRequiredGuard + the web
25
+ // enrolment gate — the feature gate ignores it (no endpoint prefix). Off by default.
26
+ require2fa: false,
23
27
  };
24
28
 
25
29
  export const FEATURE_FLAGS = Object.keys(FLAG_DEFAULTS) as FeatureFlag[];
@@ -67,6 +67,11 @@
67
67
  "marker": "// podokit:end:imports",
68
68
  "text": "import { AuthConfigModule } from \"./auth-config/auth-config.module\";"
69
69
  },
70
+ {
71
+ "file": "apps/api/src/app.module.ts",
72
+ "marker": "// podokit:end:imports",
73
+ "text": "import { TwoFactorRequiredGuard } from \"./auth/two-factor-required.guard\";"
74
+ },
70
75
  {
71
76
  "file": "apps/api/src/app.module.ts",
72
77
  "marker": "// podokit:end:module-imports",
@@ -87,6 +92,11 @@
87
92
  "marker": "// podokit:end:providers",
88
93
  "text": "{ provide: APP_GUARD, useClass: AuthGuard },"
89
94
  },
95
+ {
96
+ "file": "apps/api/src/app.module.ts",
97
+ "marker": "// podokit:end:providers",
98
+ "text": "{ provide: APP_GUARD, useClass: TwoFactorRequiredGuard },"
99
+ },
90
100
  {
91
101
  "file": "AGENTS.md",
92
102
  "marker": "<!-- podokit:end:agents-modules -->",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {