@podosoft/podokit 0.10.2 → 0.11.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 (31) hide show
  1. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +1 -0
  2. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +1 -0
  3. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +16 -0
  4. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +1 -0
  5. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +1 -0
  6. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +1 -1
  7. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +7 -1
  8. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +36 -3
  9. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +7 -4
  10. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +66 -3
  11. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/pending-approval/+page.svelte +18 -0
  12. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.server.ts +12 -0
  13. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +11 -0
  14. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
  15. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +79 -0
  16. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +1 -0
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +44 -0
  18. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +1 -0
  19. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +43 -0
  20. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -4
  21. package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +187 -0
  22. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +1 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +62 -0
  24. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +13 -4
  25. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +93 -0
  26. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +4 -0
  27. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/apple.md +18 -0
  28. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +19 -0
  29. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/smtp.md +22 -0
  30. package/dist/templates/modules/auth/module.manifest.json +7 -4
  31. package/package.json +1 -1
@@ -8,6 +8,7 @@ export type SessionUser = {
8
8
  role?: string | null;
9
9
  image?: string | null;
10
10
  emailVerified?: boolean;
11
+ signupApproved?: boolean | null;
11
12
  twoFactorEnabled?: boolean | null;
12
13
  username?: string | null;
13
14
  phoneNumber?: string | null;
@@ -8,6 +8,7 @@ const defaultCapabilities: Capabilities = {
8
8
  deleteAccount: false,
9
9
  auditLog: false,
10
10
  emailVerification: false,
11
+ signupApprovalRequired: false,
11
12
  passwordBreachCheck: false,
12
13
  magicLink: false,
13
14
  emailOtp: false,
@@ -83,6 +83,10 @@ const en = {
83
83
  verifyLinkError: "That verification link is invalid or has expired. Request a new one below.",
84
84
  resendVerification: "Resend verification email",
85
85
  emailNotVerified: "Verify your email before signing in.",
86
+ approvalPendingTitle: "Waiting for approval",
87
+ approvalPendingDesc: "Your account was created and is waiting for an administrator to approve it. Try signing in again after approval.",
88
+ approvalPending: "Your account is waiting for administrator approval.",
89
+ continueWith: "Continue with {provider}",
86
90
  },
87
91
  nav: { overview: "Overview", users: "Users", sessions: "Sessions", organizations: "Organizations", audit: "Audit log", account: "Account", settings: "Settings", home: "Back to home", signOut: "Sign out" },
88
92
  passkeys: {
@@ -161,6 +165,7 @@ const en = {
161
165
  saved: "Setting updated", saveFailed: "Failed to update setting",
162
166
  emailPassword: "Email & password", emailPasswordDesc: "Sign in with an email address and password.",
163
167
  emailVerification: "Email verification", emailVerificationDesc: "Require new sign-ups to confirm their address.",
168
+ signupApproval: "Sign-up approval", signupApprovalDesc: "Require an administrator to approve new self-registered users. Disabling this only affects future registrations.",
164
169
  magicLink: "Magic link", magicLinkDesc: "Passwordless sign-in via an emailed link.",
165
170
  emailOtp: "Email one-time code", emailOtpDesc: "Passwordless sign-in with an emailed code.",
166
171
  username: "Username sign-in", usernameDesc: "Let users sign in with a username instead of an email.",
@@ -259,6 +264,9 @@ const en = {
259
264
  sendResetEmail: "Send password reset email",
260
265
  resetEmailSent: "Password reset email sent",
261
266
  sendVerificationOnCreate: "Send verification email",
267
+ pendingApproval: "Pending approval",
268
+ approve: "Approve sign-up",
269
+ approved: "Sign-up approved",
262
270
  },
263
271
  sessions: {
264
272
  title: "Active sessions",
@@ -433,6 +441,10 @@ const ko: Messages = {
433
441
  verifyLinkError: "인증 링크가 유효하지 않거나 만료되었습니다. 아래에서 다시 요청하세요.",
434
442
  resendVerification: "인증 메일 다시 보내기",
435
443
  emailNotVerified: "로그인 전에 이메일을 인증해 주세요.",
444
+ approvalPendingTitle: "승인 대기 중",
445
+ approvalPendingDesc: "계정이 생성되었으며 관리자의 승인을 기다리고 있습니다. 승인 후 다시 로그인해 주세요.",
446
+ approvalPending: "관리자의 계정 승인을 기다리고 있습니다.",
447
+ continueWith: "{provider}(으)로 계속",
436
448
  resetFailed: "재설정에 실패했습니다",
437
449
  },
438
450
  nav: { overview: "개요", users: "회원", sessions: "세션", organizations: "조직", audit: "감사 로그", account: "계정", settings: "설정", home: "홈으로 돌아가기", signOut: "로그아웃" },
@@ -512,6 +524,7 @@ const ko: Messages = {
512
524
  saved: "설정을 변경했습니다", saveFailed: "설정 변경에 실패했습니다",
513
525
  emailPassword: "이메일·비밀번호", emailPasswordDesc: "이메일과 비밀번호로 로그인.",
514
526
  emailVerification: "이메일 인증", emailVerificationDesc: "신규 가입 시 이메일 확인 요구.",
527
+ signupApproval: "회원가입 승인", signupApprovalDesc: "신규 자가 가입 사용자를 관리자가 승인해야 로그인할 수 있습니다. 끄면 이후 가입부터 자동 승인됩니다.",
515
528
  magicLink: "매직 링크", magicLinkDesc: "이메일 링크로 비밀번호 없이 로그인.",
516
529
  emailOtp: "이메일 일회용 코드", emailOtpDesc: "이메일로 받은 코드로 비밀번호 없이 로그인.",
517
530
  username: "사용자명 로그인", usernameDesc: "이메일 대신 사용자명으로 로그인.",
@@ -610,6 +623,9 @@ const ko: Messages = {
610
623
  sendResetEmail: "비밀번호 재설정 메일 보내기",
611
624
  resetEmailSent: "재설정 메일을 보냈습니다",
612
625
  sendVerificationOnCreate: "인증 메일 보내기",
626
+ pendingApproval: "승인 대기",
627
+ approve: "회원가입 승인",
628
+ approved: "회원가입을 승인했습니다",
613
629
  },
614
630
  sessions: {
615
631
  title: "활성 세션",
@@ -7,6 +7,7 @@ const NON_INDEXABLE_PATH_PREFIXES = [
7
7
  "/forgot-password",
8
8
  "/reset-password",
9
9
  "/verify-email",
10
+ "/pending-approval",
10
11
  "/setup-2fa",
11
12
  "/oauth2",
12
13
  "/accept-invitation",
@@ -9,6 +9,7 @@ export const load: LayoutServerLoad = async ({ locals, fetch }) => {
9
9
  deleteAccount: false,
10
10
  auditLog: false,
11
11
  emailVerification: false,
12
+ signupApprovalRequired: false,
12
13
  passwordBreachCheck: false,
13
14
  magicLink: false,
14
15
  emailOtp: false,
@@ -10,7 +10,7 @@ export type AuthConfigView = {
10
10
  social: Record<string, SocialProviderView>;
11
11
  catalog: ReadonlyArray<{ id: string; label: string }>;
12
12
  smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; from: string; hasSecret: boolean };
13
- server: { requireEmailVerification: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
13
+ server: { requireEmailVerification: boolean; requireSignupApproval: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
14
14
  };
15
15
 
16
16
  export const load: PageServerLoad = async ({ locals, fetch }) => {
@@ -27,7 +27,7 @@
27
27
  // require2fa is a policy flag stored alongside the feature flags but not part of
28
28
  // the typed Capabilities, so it is toggled but never read from `caps`.
29
29
  type ToggleFlag = EditableFlag | "require2fa";
30
- type ServerKey = "requireEmailVerification" | "hibp" | "allowDelete" | "auditLog";
30
+ type ServerKey = "requireEmailVerification" | "requireSignupApproval" | "hibp" | "allowDelete" | "auditLog";
31
31
 
32
32
  // Every card renders from this one shape, so they share size and structure: a
33
33
  // title (+ optional enable switch), a description, and a footer with a status
@@ -178,6 +178,12 @@
178
178
  },
179
179
  ]);
180
180
  const security = $derived<Item[]>([
181
+ serverCard(
182
+ "requireSignupApproval",
183
+ i18n.t.settings.signupApproval,
184
+ i18n.t.settings.signupApprovalDesc,
185
+ caps.signupApprovalRequired,
186
+ ),
181
187
  flagCard("twoFactor", i18n.t.settings.twoFactor, i18n.t.settings.twoFactorDesc),
182
188
  {
183
189
  key: "flag-require2fa",
@@ -32,6 +32,7 @@
32
32
  email: string;
33
33
  role?: string | null;
34
34
  banned?: boolean | null;
35
+ signupApproved?: boolean | null;
35
36
  emailVerified?: boolean | null;
36
37
  createdAt?: string | Date | null;
37
38
  banReason?: string | null;
@@ -74,6 +75,7 @@
74
75
  options: [
75
76
  { value: "", label: i18n.t.toolbar.all },
76
77
  { value: "active", label: i18n.t.users.active },
78
+ { value: "pending", label: i18n.t.users.pendingApproval },
77
79
  { value: "banned", label: i18n.t.users.banned },
78
80
  ],
79
81
  },
@@ -91,7 +93,8 @@
91
93
  }
92
94
  if (appliedFilters.role && (u.role ?? "user") !== appliedFilters.role) return false;
93
95
  if (appliedFilters.status === "banned" && !u.banned) return false;
94
- if (appliedFilters.status === "active" && u.banned) return false;
96
+ if (appliedFilters.status === "pending" && u.signupApproved !== false) return false;
97
+ if (appliedFilters.status === "active" && (u.banned || u.signupApproved === false)) return false;
95
98
  return true;
96
99
  }),
97
100
  );
@@ -117,6 +120,19 @@
117
120
  await goto("/admin", { invalidateAll: true });
118
121
  }
119
122
 
123
+ async function approve(u: Row): Promise<void> {
124
+ busy = true;
125
+ const { error } = await api.auth.admin.updateUser({
126
+ userId: u.id,
127
+ data: { signupApproved: true },
128
+ });
129
+ busy = false;
130
+ if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
131
+ if (mUser?.id === u.id) mUser = { ...mUser, signupApproved: true };
132
+ toast.success(i18n.t.users.approved);
133
+ await load();
134
+ }
135
+
120
136
  // Create user
121
137
  let createOpen = $state(false);
122
138
  let form = $state({ name: "", email: "", password: "", confirm: "", role: "user", sendVerify: true });
@@ -372,7 +388,13 @@
372
388
  <Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
373
389
  <Table.Cell>
374
390
  <div class="flex flex-wrap gap-1">
375
- {#if user.banned}<Badge variant="destructive">{i18n.t.users.banned}</Badge>{:else}<Badge variant="outline">{i18n.t.users.active}</Badge>{/if}
391
+ {#if user.signupApproved === false}
392
+ <Badge variant="secondary">{i18n.t.users.pendingApproval}</Badge>
393
+ {:else if user.banned}
394
+ <Badge variant="destructive">{i18n.t.users.banned}</Badge>
395
+ {:else}
396
+ <Badge variant="outline">{i18n.t.users.active}</Badge>
397
+ {/if}
376
398
  {#if emailVerificationEnabled}
377
399
  {#if user.emailVerified}<Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.users.verified}</Badge>{:else}<Badge variant="secondary">{i18n.t.users.unverified}</Badge>{/if}
378
400
  {/if}
@@ -387,8 +409,11 @@
387
409
  {/snippet}
388
410
  </DropdownMenu.Trigger>
389
411
  <DropdownMenu.Content align="end">
412
+ {#if user.signupApproved === false}
413
+ <DropdownMenu.Item onSelect={() => approve(user)}>{i18n.t.users.approve}</DropdownMenu.Item>
414
+ {/if}
390
415
  <DropdownMenu.Item onSelect={() => openManage(user)}>{i18n.t.users.manage}</DropdownMenu.Item>
391
- <DropdownMenu.Item disabled={user.id === data.currentUserId} onSelect={() => impersonate(user)}>
416
+ <DropdownMenu.Item disabled={user.id === data.currentUserId || user.signupApproved === false} onSelect={() => impersonate(user)}>
392
417
  {i18n.t.users.impersonate}
393
418
  </DropdownMenu.Item>
394
419
  </DropdownMenu.Content>
@@ -461,6 +486,14 @@
461
486
  </Select.Root>
462
487
  </div>
463
488
  {#if mUser?.banned}<Badge variant="destructive" class="w-fit">{i18n.t.users.banned}</Badge>{/if}
489
+ {#if mUser?.signupApproved === false}
490
+ <div class="flex items-center gap-2">
491
+ <Badge variant="secondary">{i18n.t.users.pendingApproval}</Badge>
492
+ <Button type="button" variant="outline" size="sm" disabled={busy} onclick={() => { if (mUser) void approve(mUser); }}>
493
+ {i18n.t.users.approve}
494
+ </Button>
495
+ </div>
496
+ {/if}
464
497
  {#if emailVerificationEnabled}
465
498
  <div class="flex items-center gap-2">
466
499
  {#if mUser?.emailVerified}<Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.users.verified}</Badge>
@@ -1,14 +1,17 @@
1
+ import { redirect } from "@sveltejs/kit";
1
2
  import type { PageServerLoad } from "./$types";
2
- import type { Capabilities } from "@podosoft/podokit-api-client";
3
+ import { SIGNUP_APPROVAL_REQUIRED, type Capabilities } from "@podosoft/podokit-api-client";
3
4
 
4
5
  // Capabilities is public so the login page can offer whichever sign-in methods
5
6
  // (magic link, ...) the server actually enabled.
6
- export const load: PageServerLoad = async ({ fetch }) => {
7
+ export const load: PageServerLoad = async ({ fetch, url }) => {
8
+ const oauthError = url.searchParams.get("error");
9
+ if (oauthError === SIGNUP_APPROVAL_REQUIRED) redirect(302, "/pending-approval");
7
10
  try {
8
11
  const res = await fetch("/api/account/capabilities");
9
- if (res.ok) return { capabilities: (await res.json()) as Capabilities };
12
+ if (res.ok) return { capabilities: (await res.json()) as Capabilities, oauthError };
10
13
  } catch {
11
14
  /* fall through to defaults */
12
15
  }
13
- return { capabilities: null };
16
+ return { capabilities: null, oauthError };
14
17
  };
@@ -7,7 +7,8 @@
7
7
  import * as Card from "$lib/components/ui/card";
8
8
  import * as Alert from "$lib/components/ui/alert";
9
9
  import { api } from "$lib/api";
10
- import { getI18n } from "$lib/i18n";
10
+ import { getI18n, fmt } from "$lib/i18n";
11
+ import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
11
12
  import { site } from "$lib/site.svelte";
12
13
 
13
14
  import type { PageData } from "./$types";
@@ -22,6 +23,10 @@
22
23
  let unverified = $state(false);
23
24
  let loading = $state(false);
24
25
 
26
+ $effect(() => {
27
+ if (data.oauthError) error = i18n.t.auth.signInFailed;
28
+ });
29
+
25
30
  // Second-factor step: a password/OTP sign-in with 2FA enabled returns
26
31
  // `twoFactorRedirect` and no session; the user then verifies with an
27
32
  // authenticator code or a backup code to complete the login.
@@ -30,6 +35,34 @@
30
35
  let useBackupCode = $state(false);
31
36
  let twoFaLoading = $state(false);
32
37
  const redirectTo = (): string => page.url.searchParams.get("redirect") ?? "/admin";
38
+ const socialProviders = $derived(data.capabilities?.providers ?? []);
39
+ const providerLabels: Record<string, string> = {
40
+ github: "GitHub",
41
+ google: "Google",
42
+ apple: "Apple",
43
+ microsoft: "Microsoft",
44
+ };
45
+ const providerLabel = (provider: string): string =>
46
+ providerLabels[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
47
+
48
+ async function signInSocial(provider: string): Promise<void> {
49
+ error = null;
50
+ const callbackURL = new URL(redirectTo(), page.url.origin).toString();
51
+ type SocialProvider = Parameters<typeof api.auth.signIn.social>[0]["provider"];
52
+ const { error: authError } = await api.auth.signIn.social({
53
+ provider: provider as SocialProvider,
54
+ callbackURL,
55
+ errorCallbackURL: `${page.url.origin}/login`,
56
+ newUserCallbackURL: callbackURL,
57
+ });
58
+ if (authError) {
59
+ if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
60
+ await goto("/pending-approval");
61
+ return;
62
+ }
63
+ error = authError.message ?? i18n.t.auth.signInFailed;
64
+ }
65
+ }
33
66
 
34
67
  // Returns true when 2FA is still owed (caller shows the step instead of leaving).
35
68
  function needsTwoFactor(data: unknown): boolean {
@@ -59,6 +92,10 @@
59
92
  : await api.auth.twoFactor.verifyTotp({ code: twoFaCode.trim() });
60
93
  twoFaLoading = false;
61
94
  if (authError) {
95
+ if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
96
+ await goto("/pending-approval");
97
+ return;
98
+ }
62
99
  error = twoFactorError(authError.code);
63
100
  return;
64
101
  }
@@ -78,10 +115,15 @@
78
115
  error = null;
79
116
  const { error: authError } = await api.auth.signIn.magicLink({
80
117
  email,
81
- callbackURL: page.url.searchParams.get("redirect") ?? "/admin",
118
+ callbackURL: new URL(redirectTo(), page.url.origin).toString(),
119
+ errorCallbackURL: `${page.url.origin}/login`,
82
120
  });
83
121
  magicLoading = false;
84
122
  if (authError) {
123
+ if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
124
+ await goto("/pending-approval");
125
+ return;
126
+ }
85
127
  error = authError.message ?? i18n.t.auth.signInFailed;
86
128
  return;
87
129
  }
@@ -104,6 +146,10 @@
104
146
  const { error: authError } = await api.auth.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
105
147
  otpLoading = false;
106
148
  if (authError) {
149
+ if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
150
+ await goto("/pending-approval");
151
+ return;
152
+ }
107
153
  error = authError.message ?? i18n.t.auth.signInFailed;
108
154
  return;
109
155
  }
@@ -115,6 +161,10 @@
115
161
  const { data, error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
116
162
  otpLoading = false;
117
163
  if (authError) {
164
+ if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
165
+ await goto("/pending-approval");
166
+ return;
167
+ }
118
168
  error = authError.message ?? i18n.t.auth.signInFailed;
119
169
  return;
120
170
  }
@@ -134,6 +184,10 @@
134
184
  const res = await api.auth.signIn.passkey();
135
185
  passkeyLoading = false;
136
186
  if (res?.error) {
187
+ if ("code" in res.error && res.error.code === SIGNUP_APPROVAL_REQUIRED) {
188
+ await goto("/pending-approval");
189
+ return;
190
+ }
137
191
  error = res.error.message ?? i18n.t.auth.signInFailed;
138
192
  return;
139
193
  }
@@ -152,6 +206,10 @@
152
206
  : await api.auth.signIn.email({ email, password });
153
207
  loading = false;
154
208
  if (authError) {
209
+ if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
210
+ await goto("/pending-approval");
211
+ return;
212
+ }
155
213
  // Surface an unverified address with a path to a fresh verification link.
156
214
  unverified = authError.code === "EMAIL_NOT_VERIFIED";
157
215
  error = unverified ? i18n.t.auth.emailNotVerified : (authError.message ?? i18n.t.auth.signInFailed);
@@ -225,8 +283,13 @@
225
283
  </div>
226
284
  <Button type="submit" disabled={loading}>{loading ? i18n.t.auth.signingIn : i18n.t.auth.signIn}</Button>
227
285
  </form>
228
- {#if magicLinkEnabled || emailOtpEnabled || passkeyEnabled}
286
+ {#if socialProviders.length > 0 || magicLinkEnabled || emailOtpEnabled || passkeyEnabled}
229
287
  <div class="mt-4 flex flex-col gap-3 border-t pt-4">
288
+ {#each socialProviders as provider (provider)}
289
+ <Button type="button" variant="outline" class="w-full" onclick={() => signInSocial(provider)}>
290
+ {fmt(i18n.t.auth.continueWith, { provider: providerLabel(provider) })}
291
+ </Button>
292
+ {/each}
230
293
  {#if passkeyEnabled}
231
294
  <Button type="button" variant="outline" class="w-full" disabled={passkeyLoading} onclick={signInWithPasskey}>
232
295
  {passkeyLoading ? i18n.t.auth.signingIn : i18n.t.auth.passkeyButton}
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import { goto } from "$app/navigation";
3
+ import { Button } from "$lib/components/ui/button";
4
+ import * as Card from "$lib/components/ui/card";
5
+ import { getI18n } from "$lib/i18n";
6
+
7
+ const i18n = getI18n();
8
+ </script>
9
+
10
+ <Card.Root>
11
+ <Card.Header>
12
+ <Card.Title role="heading" aria-level={1}>{i18n.t.auth.approvalPendingTitle}</Card.Title>
13
+ <Card.Description>{i18n.t.auth.approvalPendingDesc}</Card.Description>
14
+ </Card.Header>
15
+ <Card.Footer>
16
+ <Button class="w-full" onclick={() => goto("/login")}>{i18n.t.auth.backToSignIn}</Button>
17
+ </Card.Footer>
18
+ </Card.Root>
@@ -0,0 +1,12 @@
1
+ import type { PageServerLoad } from "./$types";
2
+ import type { Capabilities } from "@podosoft/podokit-api-client";
3
+
4
+ export const load: PageServerLoad = async ({ fetch }) => {
5
+ try {
6
+ const res = await fetch("/api/account/capabilities");
7
+ if (res.ok) return { capabilities: (await res.json()) as Capabilities };
8
+ } catch {
9
+ /* fall through to a safe default */
10
+ }
11
+ return { capabilities: null };
12
+ };
@@ -8,7 +8,10 @@
8
8
  import * as Alert from "$lib/components/ui/alert";
9
9
  import { api } from "$lib/api";
10
10
  import { getI18n } from "$lib/i18n";
11
+ import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
12
+ import type { PageData } from "./$types";
11
13
 
14
+ let { data }: { data: PageData } = $props();
12
15
  const i18n = getI18n();
13
16
  let name = $state("");
14
17
  let email = $state("");
@@ -30,6 +33,10 @@
30
33
  });
31
34
  if (authError) {
32
35
  loading = false;
36
+ if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
37
+ await goto("/pending-approval");
38
+ return;
39
+ }
33
40
  error = authError.message ?? i18n.t.auth.signUpFailed;
34
41
  return;
35
42
  }
@@ -38,6 +45,10 @@
38
45
  const { data: session } = await api.auth.getSession();
39
46
  loading = false;
40
47
  if (!session?.session) {
48
+ if (data.capabilities?.signupApprovalRequired) {
49
+ await goto("/pending-approval");
50
+ return;
51
+ }
41
52
  await goto(`/verify-email?email=${encodeURIComponent(email)}`);
42
53
  return;
43
54
  }
@@ -11,6 +11,7 @@ const PUBLIC_PATHS = [
11
11
  "/forgot-password",
12
12
  "/reset-password",
13
13
  "/verify-email",
14
+ "/pending-approval",
14
15
  "/maintenance",
15
16
  // Modules add their public (no-session) page prefixes here.
16
17
  // podokit:begin:public-paths
@@ -78,3 +78,82 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
78
78
  expect((await user.put("/api/account/auth-config", { data: { server: { hibp: true } } })).status()).toBe(403);
79
79
  await user.dispose();
80
80
  });
81
+
82
+ test("new registrations require approval when the policy is enabled @smoke", async ({ playwright }) => {
83
+ const admin = await adminCtx(playwright);
84
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
85
+ const password = "Podokit3e-Str0ng!pw";
86
+ const email = `approval-${Date.now()}@example.com`;
87
+ const adminCreatedEmail = `approval-admin-${Date.now()}@example.com`;
88
+ let pendingId = "";
89
+ let adminCreatedId = "";
90
+
91
+ try {
92
+ const enabled = await admin.put("/api/account/auth-config", {
93
+ data: { server: { requireSignupApproval: true } },
94
+ });
95
+ expect(enabled.ok()).toBeTruthy();
96
+ expect((await enabled.json()).server.requireSignupApproval).toBe(true);
97
+
98
+ // authRuntime and its config store use a short cache; an auth request after
99
+ // the TTL applies the new policy without restarting the process.
100
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
101
+ await anon.get("/api/auth/get-session");
102
+
103
+ const signup = await anon.post("/api/auth/sign-up/email", {
104
+ data: { email, password, name: "Pending User" },
105
+ });
106
+ expect(signup.ok()).toBeTruthy();
107
+ expect((await signup.json()).token).toBeNull();
108
+ expect((await anon.get("/api/auth/get-session")).status()).toBe(200);
109
+ expect((await (await anon.get("/api/auth/get-session")).json())?.session).toBeFalsy();
110
+
111
+ const listed = await admin.get(
112
+ `/api/auth/admin/list-users?searchValue=${encodeURIComponent(email)}&searchField=email`,
113
+ );
114
+ const pending = ((await listed.json()).users ?? [])[0] as {
115
+ id: string;
116
+ signupApproved?: boolean | null;
117
+ };
118
+ pendingId = pending.id;
119
+ expect(pending.signupApproved).toBe(false);
120
+
121
+ const blocked = await anon.post("/api/auth/sign-in/email", { data: { email, password } });
122
+ expect(blocked.status()).toBe(403);
123
+ expect((await blocked.json()).code).toBe("SIGNUP_APPROVAL_REQUIRED");
124
+
125
+ const approved = await admin.post("/api/auth/admin/update-user", {
126
+ data: { userId: pendingId, data: { signupApproved: true } },
127
+ });
128
+ expect(approved.ok()).toBeTruthy();
129
+ expect((await approved.json()).signupApproved).toBe(true);
130
+ expect((await anon.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
131
+
132
+ // Accounts created intentionally by an administrator bypass the queue.
133
+ const adminCreated = await admin.post("/api/auth/admin/create-user", {
134
+ data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
135
+ });
136
+ expect(adminCreated.ok()).toBeTruthy();
137
+ const adminCreatedUser = (await adminCreated.json()).user as {
138
+ id: string;
139
+ signupApproved?: boolean | null;
140
+ };
141
+ adminCreatedId = adminCreatedUser.id;
142
+ expect(adminCreatedUser.signupApproved).toBe(true);
143
+ const adminCreatedSession = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
144
+ expect(
145
+ (await adminCreatedSession.post("/api/auth/sign-in/email", {
146
+ data: { email: adminCreatedEmail, password },
147
+ })).ok(),
148
+ ).toBeTruthy();
149
+ await adminCreatedSession.dispose();
150
+ } finally {
151
+ if (pendingId) await admin.post("/api/auth/admin/remove-user", { data: { userId: pendingId } });
152
+ if (adminCreatedId) await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
153
+ await admin.put("/api/account/auth-config", { data: { server: { requireSignupApproval: false } } });
154
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
155
+ await anon.get("/api/auth/get-session");
156
+ await anon.dispose();
157
+ await admin.dispose();
158
+ }
159
+ });
@@ -105,6 +105,7 @@ test("account capabilities reports optional-feature flags", async ({ playwright
105
105
  expect(typeof caps.twoFactor).toBe("boolean");
106
106
  expect(Array.isArray(caps.providers)).toBe(true);
107
107
  expect(typeof caps.deleteAccount).toBe("boolean");
108
+ expect(typeof caps.signupApprovalRequired).toBe("boolean");
108
109
  await ctx.dispose();
109
110
  });
110
111
 
@@ -4,6 +4,8 @@ import { ready } from "../helpers/hydration";
4
4
 
5
5
  test.use({ storageState: anonState });
6
6
 
7
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
+
7
9
  test("login page renders @smoke", async ({ page }) => {
8
10
  await page.goto("/login");
9
11
  await expect(page.getByLabel("Email")).toBeVisible();
@@ -28,6 +30,48 @@ test("signup page renders", async ({ page }) => {
28
30
  await expect(page.getByRole("button", { name: "Create account" })).toBeVisible();
29
31
  });
30
32
 
33
+ test("pending approval errors show the approval status page", async ({ page }) => {
34
+ await page.goto("/login?error=SIGNUP_APPROVAL_REQUIRED");
35
+ await expect(page).toHaveURL(/\/pending-approval$/);
36
+ await expect(page.getByRole("heading", { name: "Waiting for approval" })).toBeVisible();
37
+ });
38
+
39
+ test("configured social providers appear on the login page", async ({ page, playwright }) => {
40
+ const admin = await playwright.request.newContext({
41
+ baseURL: base,
42
+ extraHTTPHeaders: { origin: base },
43
+ });
44
+ await admin.post("/api/auth/sign-in/email", {
45
+ data: { email: ADMIN.email, password: ADMIN.password },
46
+ });
47
+ try {
48
+ expect(
49
+ (await admin.put("/api/account/auth-config", {
50
+ data: {
51
+ social: {
52
+ google: {
53
+ enabled: true,
54
+ clientId: "dummy-google-client-id",
55
+ clientSecret: "dummy-google-client-secret",
56
+ },
57
+ },
58
+ },
59
+ })).ok(),
60
+ ).toBeTruthy();
61
+ await expect
62
+ .poll(async () => {
63
+ await page.goto("/login");
64
+ return page.getByRole("button", { name: "Continue with Google" }).count();
65
+ }, { timeout: 8_000 })
66
+ .toBe(1);
67
+ } finally {
68
+ await admin.put("/api/account/auth-config", {
69
+ data: { social: { google: { enabled: false } } },
70
+ });
71
+ await admin.dispose();
72
+ }
73
+ });
74
+
31
75
  test("forgot-password shows a sent confirmation", async ({ page }) => {
32
76
  await ready(page, "/forgot-password");
33
77
  // Retry the submit to absorb any first-load hydration timing.
@@ -88,4 +88,5 @@ test("settings: the require-two-factor toggle is available @smoke", async ({ pag
88
88
  // the require-2fa api/ui specs. Not toggled here — turning it on would gate this
89
89
  // admin session.
90
90
  await expect(page.getByRole("switch", { name: "Require two-factor" })).toBeVisible();
91
+ await expect(page.getByRole("switch", { name: "Sign-up approval" })).toBeVisible();
91
92
  });