@podosoft/podokit 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +7 -3
  2. package/dist/add.d.ts +16 -0
  3. package/dist/add.js +63 -20
  4. package/dist/assemble.js +9 -9
  5. package/dist/dev.d.ts +26 -0
  6. package/dist/dev.js +297 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +82 -2
  9. package/dist/lib.d.ts +2 -0
  10. package/dist/lib.js +7 -1
  11. package/dist/locale.d.ts +22 -0
  12. package/dist/locale.js +184 -0
  13. package/dist/lockfile.d.ts +5 -3
  14. package/dist/lockfile.js +21 -5
  15. package/dist/remove.js +26 -23
  16. package/dist/templates/fullstack-nest-svelte/README.md +42 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +1 -0
  18. package/dist/templates/fullstack-nest-svelte/apps/api/scripts/.keep +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  21. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  22. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
  25. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +8 -11
  26. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  27. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  28. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  29. package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
  30. package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
  31. package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +187 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +14 -3
  64. package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
  65. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
  67. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +15 -2
  68. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +2 -2
  69. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +51 -0
  70. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
  71. package/dist/templates/modules/auth/module.manifest.json +3 -0
  72. package/dist/templates/todo/README.md +42 -0
  73. package/dist/templates/todo/apps/api/Dockerfile +1 -0
  74. package/dist/templates/todo/apps/api/scripts/.keep +1 -0
  75. package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
  76. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  77. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  78. package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
  79. package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
  80. package/dist/templates/todo/apps/web/vite.config.ts +3 -9
  81. package/dist/templates/todo/compose.dev.yaml +8 -11
  82. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  83. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  84. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  85. package/dist/templates/todo/dot-env.docker +2 -2
  86. package/dist/templates/todo/dot-gitignore +3 -0
  87. package/dist/templates/todo/dot-podokit/dev.json +4 -0
  88. package/dist/templates/todo/tests/README.md +10 -4
  89. package/dist/update.js +48 -7
  90. package/package.json +1 -1
  91. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
  92. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
  93. package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
  94. package/dist/templates/todo/apps/web/src/lib/i18n/ko.ts +0 -11
@@ -40,14 +40,21 @@ const empty: SiteSettings = {
40
40
  };
41
41
 
42
42
  let current = $state<SiteSettings>({ ...empty });
43
+ let initialized = $state(false);
43
44
 
44
45
  export const site = {
45
46
  get value(): SiteSettings {
46
47
  return current;
47
48
  },
49
+ get initialized(): boolean {
50
+ return initialized;
51
+ },
48
52
  /** Seed from the server load (idempotent). */
49
53
  init(v: Partial<SiteSettings> | null | undefined): void {
50
- if (v) current = { ...empty, ...v };
54
+ if (v) {
55
+ current = { ...empty, ...v };
56
+ initialized = true;
57
+ }
51
58
  },
52
59
  /** Merge in changes so the layout re-applies title/favicon immediately. */
53
60
  patch(v: Partial<SiteSettings>): void {
@@ -28,13 +28,13 @@
28
28
  let searchField = $state("actor");
29
29
  let appliedSearchField = $state("actor");
30
30
 
31
- const columns: DataTableColumn<Entry>[] = [
31
+ const columns = $derived<DataTableColumn<Entry>[]>([
32
32
  { key: "createdAt", label: i18n.t.audit.when, sortable: true, class: "whitespace-nowrap" },
33
33
  { key: "actor", label: i18n.t.audit.actor, sortable: true, value: (e) => e.actorName ?? e.actorEmail },
34
34
  { key: "action", label: i18n.t.audit.action, sortable: true },
35
35
  { key: "target", label: i18n.t.audit.target, sortable: true, value: (e) => e.targetLabel },
36
36
  { key: "ip", label: i18n.t.audit.ip, sortable: true },
37
- ];
37
+ ]);
38
38
 
39
39
  const actions = $derived([...new Set(entries.map((e) => e.action))].sort());
40
40
  const filters = $derived<ToolbarFilter[]>([
@@ -45,10 +45,10 @@
45
45
  },
46
46
  ]);
47
47
 
48
- const searchFields: ToolbarSearchField[] = [
48
+ const searchFields = $derived<ToolbarSearchField[]>([
49
49
  { value: "actor", label: i18n.t.audit.actor },
50
50
  { value: "target", label: i18n.t.audit.target },
51
- ];
51
+ ]);
52
52
 
53
53
  const filtered = $derived(
54
54
  entries.filter((e) => {
@@ -17,13 +17,13 @@
17
17
  let orgs = $state<Org[]>([]);
18
18
  let sort = $state<SortState | null>({ key: "createdAt", dir: "desc" });
19
19
  let listPage = $state(1);
20
- const columns: DataTableColumn<Org>[] = [
20
+ const columns = $derived<DataTableColumn<Org>[]>([
21
21
  { key: "name", label: i18n.t.organizations.name, sortable: true },
22
22
  { key: "slug", label: i18n.t.organizations.slug, sortable: true },
23
23
  { key: "parent", label: i18n.t.organizations.parent, sortable: true, value: (o) => orgName(o.parentOrganizationId) },
24
24
  { key: "createdAt", label: i18n.t.organizations.created, sortable: true },
25
25
  { key: "actions", label: "", class: "w-10" },
26
- ];
26
+ ]);
27
27
  let busy = $state(false);
28
28
  let createOpen = $state(false);
29
29
  let form = $state({ name: "", slug: "", parentOrganizationId: "" });
@@ -41,19 +41,19 @@
41
41
  let searchField = $state("email");
42
42
  let appliedSearchField = $state("email");
43
43
 
44
- const columns: DataTableColumn<Row>[] = [
44
+ const columns = $derived<DataTableColumn<Row>[]>([
45
45
  { key: "user", label: i18n.t.adminSessions.user, sortable: true, value: (r) => r.userName },
46
46
  { key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => r.userAgent },
47
47
  { key: "ip", label: i18n.t.adminSessions.ip, sortable: true, value: (r) => r.ipAddress },
48
48
  { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true },
49
49
  { key: "expiresAt", label: i18n.t.adminSessions.expires, sortable: true, value: (r) => (r.expiresAt ? new Date(r.expiresAt).getTime() : 0) },
50
50
  { key: "actions", label: "", class: "w-10" },
51
- ];
51
+ ]);
52
52
 
53
- const searchFields: ToolbarSearchField[] = [
53
+ const searchFields = $derived<ToolbarSearchField[]>([
54
54
  { value: "email", label: i18n.t.users.email },
55
55
  { value: "name", label: i18n.t.users.name },
56
- ];
56
+ ]);
57
57
 
58
58
  const filtered = $derived(
59
59
  rows.filter((r) => {
@@ -72,6 +72,7 @@
72
72
  }
73
73
 
74
74
  const callbackBase = $derived(typeof window === "undefined" ? "" : window.location.origin);
75
+ const providerCallback = (id: string): string => `${callbackBase}/api/auth/callback/${id}`;
75
76
  const labelOf = (id: string): string => ac?.catalog.find((c) => c.id === id)?.label ?? id;
76
77
  const configured = $derived(Object.values(ac?.social ?? {}));
77
78
  const enabledProviders = $derived(configured.filter((p) => p.enabled && p.clientId && p.hasSecret));
@@ -102,6 +103,11 @@
102
103
  });
103
104
  const editingView = $derived(socialForm.adding ? undefined : ac?.social[socialForm.id]);
104
105
 
106
+ function socialRedirectURI(): string {
107
+ const configured = socialForm.redirectURI.trim();
108
+ return socialForm.adding || !configured ? providerCallback(socialForm.id) : configured;
109
+ }
110
+
105
111
  function openSocialManager(): void {
106
112
  socialView = "list";
107
113
  openDialog = "social";
@@ -122,7 +128,7 @@
122
128
  [socialForm.id]: {
123
129
  enabled: socialForm.enabled,
124
130
  clientId: socialForm.clientId,
125
- redirectURI: socialForm.redirectURI,
131
+ redirectURI: socialRedirectURI(),
126
132
  ...(socialForm.clientSecret ? { clientSecret: socialForm.clientSecret } : {}),
127
133
  },
128
134
  },
@@ -335,7 +341,7 @@
335
341
  <div class="flex flex-col gap-1"><Label for="social-id">{i18n.t.settings.clientId}</Label><Input id="social-id" bind:value={socialForm.clientId} autocomplete="off" /></div>
336
342
  <div class="flex flex-col gap-1"><Label for="social-secret">{i18n.t.settings.clientSecret}</Label><Input id="social-secret" type="password" bind:value={socialForm.clientSecret} autocomplete="off" placeholder={editingView?.hasSecret ? i18n.t.settings.secretSet : ""} /></div>
337
343
  {#if socialForm.id}
338
- <p class="text-muted-foreground text-xs">{i18n.t.settings.redirectUri}: <code class="break-all">{callbackBase}/api/auth/callback/{socialForm.id}</code></p>
344
+ <p class="text-muted-foreground text-xs">{i18n.t.settings.redirectUri}: <code class="break-all">{socialRedirectURI()}</code></p>
339
345
  {/if}
340
346
  </div>
341
347
  <Dialog.Footer>
@@ -3,10 +3,12 @@
3
3
  import { Label } from "$lib/components/ui/label";
4
4
  import { Button } from "$lib/components/ui/button";
5
5
  import { Switch } from "$lib/components/ui/switch";
6
+ import * as Select from "$lib/components/ui/select";
6
7
  import * as Card from "$lib/components/ui/card";
7
8
  import { toast } from "svelte-sonner";
8
9
  import { api } from "$lib/api";
9
10
  import { getI18n } from "$lib/i18n";
11
+ import { defaultLocale, LOCALES, localeNames, resolveLocale } from "$lib/i18n/messages";
10
12
  import { site, type SiteSettings } from "$lib/site.svelte";
11
13
 
12
14
  const i18n = getI18n();
@@ -21,14 +23,14 @@
21
23
  footerText: "",
22
24
  termsUrl: "",
23
25
  privacyUrl: "",
24
- locale: "",
26
+ locale: defaultLocale,
25
27
  timezone: "",
26
28
  });
27
29
  let maintenanceMode = $state(false);
28
30
  let allowSignup = $state(true);
29
31
  let seeded = $state(false);
30
32
  $effect(() => {
31
- if (seeded) return;
33
+ if (seeded || !site.initialized) return;
32
34
  form = {
33
35
  name: s.name ?? "",
34
36
  description: s.description ?? "",
@@ -36,7 +38,7 @@
36
38
  footerText: s.footerText ?? "",
37
39
  termsUrl: s.termsUrl ?? "",
38
40
  privacyUrl: s.privacyUrl ?? "",
39
- locale: s.locale ?? "",
41
+ locale: resolveLocale(s.locale),
40
42
  timezone: s.timezone ?? "",
41
43
  };
42
44
  maintenanceMode = s.maintenanceMode === "true";
@@ -157,7 +159,16 @@
157
159
  </div>
158
160
  <div class="flex flex-col gap-1.5">
159
161
  <Label for="locale">{t.locale}</Label>
160
- <Input id="locale" bind:value={form.locale} placeholder="en" />
162
+ <Select.Root type="single" bind:value={form.locale}>
163
+ <Select.Trigger id="locale" class="w-full">
164
+ {localeNames[form.locale] ?? form.locale ?? ""}
165
+ </Select.Trigger>
166
+ <Select.Content>
167
+ {#each LOCALES as locale (locale)}
168
+ <Select.Item value={locale}>{localeNames[locale] ?? locale}</Select.Item>
169
+ {/each}
170
+ </Select.Content>
171
+ </Select.Root>
161
172
  </div>
162
173
  <div class="flex flex-col gap-1.5">
163
174
  <Label for="timezone">{t.timezone}</Label>
@@ -55,14 +55,14 @@
55
55
  let filterValues = $state<Record<string, string>>({ role: "", status: "" });
56
56
  let appliedFilters = $state<Record<string, string>>({ role: "", status: "" });
57
57
 
58
- const columns: DataTableColumn<Row>[] = [
58
+ const columns = $derived<DataTableColumn<Row>[]>([
59
59
  { key: "name", label: i18n.t.users.name, sortable: true },
60
60
  { key: "email", label: i18n.t.users.email, sortable: true },
61
61
  { key: "role", label: i18n.t.users.role, sortable: true },
62
62
  { key: "status", label: i18n.t.users.status },
63
63
  { key: "createdAt", label: i18n.t.users.joined, sortable: true, value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0) },
64
64
  { key: "actions", label: "", class: "w-10" },
65
- ];
65
+ ]);
66
66
  const filters = $derived<ToolbarFilter[]>([
67
67
  {
68
68
  key: "role",
@@ -80,10 +80,10 @@
80
80
  ],
81
81
  },
82
82
  ]);
83
- const searchFields: ToolbarSearchField[] = [
83
+ const searchFields = $derived<ToolbarSearchField[]>([
84
84
  { value: "email", label: i18n.t.users.email },
85
85
  { value: "name", label: i18n.t.users.name },
86
- ];
86
+ ]);
87
87
 
88
88
  const filtered = $derived(
89
89
  users.filter((u) => {
@@ -7,6 +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 { PUBLIC_SIGNUP_DISABLED } from "$lib/auth-errors";
11
+ import { safeAuthRedirect, withAuthRedirect } from "$lib/auth-redirect";
10
12
  import { getI18n, fmt } from "$lib/i18n";
11
13
  import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
12
14
  import { site } from "$lib/site.svelte";
@@ -24,7 +26,8 @@
24
26
  let loading = $state(false);
25
27
 
26
28
  $effect(() => {
27
- if (data.oauthError) error = i18n.t.auth.signInFailed;
29
+ if (data.oauthError === PUBLIC_SIGNUP_DISABLED) error = i18n.t.auth.publicSignupDisabled;
30
+ else if (data.oauthError) error = i18n.t.auth.signInFailed;
28
31
  });
29
32
 
30
33
  // Second-factor step: a password/OTP sign-in with 2FA enabled returns
@@ -34,7 +37,10 @@
34
37
  let twoFaCode = $state("");
35
38
  let useBackupCode = $state(false);
36
39
  let twoFaLoading = $state(false);
37
- const redirectTo = (): string => page.url.searchParams.get("redirect") ?? "/admin";
40
+ const redirectTo = (): string => safeAuthRedirect(page.url.searchParams.get("redirect"));
41
+ const authErrorCallback = (): string =>
42
+ new URL(withAuthRedirect("/login", redirectTo()), page.url.origin).toString();
43
+ const signupHref = (): string => withAuthRedirect("/signup", redirectTo());
38
44
  const socialProviders = $derived(data.capabilities?.providers ?? []);
39
45
  const providerLabels: Record<string, string> = {
40
46
  github: "GitHub",
@@ -45,6 +51,18 @@
45
51
  const providerLabel = (provider: string): string =>
46
52
  providerLabels[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
47
53
 
54
+ async function handlePolicyError(code: string | undefined): Promise<boolean> {
55
+ if (code === SIGNUP_APPROVAL_REQUIRED) {
56
+ await goto("/pending-approval");
57
+ return true;
58
+ }
59
+ if (code === PUBLIC_SIGNUP_DISABLED) {
60
+ error = i18n.t.auth.publicSignupDisabled;
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+
48
66
  async function signInSocial(provider: string): Promise<void> {
49
67
  error = null;
50
68
  const callbackURL = new URL(redirectTo(), page.url.origin).toString();
@@ -52,14 +70,11 @@
52
70
  const { error: authError } = await api.auth.signIn.social({
53
71
  provider: provider as SocialProvider,
54
72
  callbackURL,
55
- errorCallbackURL: `${page.url.origin}/login`,
73
+ errorCallbackURL: authErrorCallback(),
56
74
  newUserCallbackURL: callbackURL,
57
75
  });
58
76
  if (authError) {
59
- if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
60
- await goto("/pending-approval");
61
- return;
62
- }
77
+ if (await handlePolicyError(authError.code)) return;
63
78
  error = authError.message ?? i18n.t.auth.signInFailed;
64
79
  }
65
80
  }
@@ -92,10 +107,7 @@
92
107
  : await api.auth.twoFactor.verifyTotp({ code: twoFaCode.trim() });
93
108
  twoFaLoading = false;
94
109
  if (authError) {
95
- if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
96
- await goto("/pending-approval");
97
- return;
98
- }
110
+ if (await handlePolicyError(authError.code)) return;
99
111
  error = twoFactorError(authError.code);
100
112
  return;
101
113
  }
@@ -116,14 +128,11 @@
116
128
  const { error: authError } = await api.auth.signIn.magicLink({
117
129
  email,
118
130
  callbackURL: new URL(redirectTo(), page.url.origin).toString(),
119
- errorCallbackURL: `${page.url.origin}/login`,
131
+ errorCallbackURL: authErrorCallback(),
120
132
  });
121
133
  magicLoading = false;
122
134
  if (authError) {
123
- if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
124
- await goto("/pending-approval");
125
- return;
126
- }
135
+ if (await handlePolicyError(authError.code)) return;
127
136
  error = authError.message ?? i18n.t.auth.signInFailed;
128
137
  return;
129
138
  }
@@ -146,10 +155,7 @@
146
155
  const { error: authError } = await api.auth.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
147
156
  otpLoading = false;
148
157
  if (authError) {
149
- if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
150
- await goto("/pending-approval");
151
- return;
152
- }
158
+ if (await handlePolicyError(authError.code)) return;
153
159
  error = authError.message ?? i18n.t.auth.signInFailed;
154
160
  return;
155
161
  }
@@ -161,10 +167,7 @@
161
167
  const { data, error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
162
168
  otpLoading = false;
163
169
  if (authError) {
164
- if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
165
- await goto("/pending-approval");
166
- return;
167
- }
170
+ if (await handlePolicyError(authError.code)) return;
168
171
  error = authError.message ?? i18n.t.auth.signInFailed;
169
172
  return;
170
173
  }
@@ -184,14 +187,11 @@
184
187
  const res = await api.auth.signIn.passkey();
185
188
  passkeyLoading = false;
186
189
  if (res?.error) {
187
- if ("code" in res.error && res.error.code === SIGNUP_APPROVAL_REQUIRED) {
188
- await goto("/pending-approval");
189
- return;
190
- }
190
+ if (await handlePolicyError("code" in res.error ? res.error.code : undefined)) return;
191
191
  error = res.error.message ?? i18n.t.auth.signInFailed;
192
192
  return;
193
193
  }
194
- await goto(page.url.searchParams.get("redirect") ?? "/admin", { invalidateAll: true });
194
+ await goto(redirectTo(), { invalidateAll: true });
195
195
  }
196
196
 
197
197
  async function submit(event: SubmitEvent): Promise<void> {
@@ -206,10 +206,7 @@
206
206
  : await api.auth.signIn.email({ email, password });
207
207
  loading = false;
208
208
  if (authError) {
209
- if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
210
- await goto("/pending-approval");
211
- return;
212
- }
209
+ if (await handlePolicyError(authError.code)) return;
213
210
  // Surface an unverified address with a path to a fresh verification link.
214
211
  unverified = authError.code === "EMAIL_NOT_VERIFIED";
215
212
  error = unverified ? i18n.t.auth.emailNotVerified : (authError.message ?? i18n.t.auth.signInFailed);
@@ -230,7 +227,7 @@
230
227
  </Card.Header>
231
228
  <Card.Content>
232
229
  {#if error}
233
- <Alert.Root variant="destructive" class="mb-4">
230
+ <Alert.Root data-testid="auth-error" variant="destructive" class="mb-4">
234
231
  <Alert.Description>
235
232
  {error}
236
233
  {#if unverified}
@@ -325,7 +322,7 @@
325
322
  </Card.Content>
326
323
  {#if signupOpen}
327
324
  <Card.Footer class="justify-center">
328
- <p class="text-muted-foreground text-sm">{i18n.t.auth.noAccount} <a href="/signup" class="text-foreground hover:underline">{i18n.t.auth.signUp}</a></p>
325
+ <p class="text-muted-foreground text-sm">{i18n.t.auth.noAccount} <a href={signupHref()} class="text-foreground hover:underline">{i18n.t.auth.signUp}</a></p>
329
326
  </Card.Footer>
330
327
  {/if}
331
328
  </Card.Root>
@@ -7,6 +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 { PUBLIC_SIGNUP_DISABLED } from "$lib/auth-errors";
11
+ import { safeAuthRedirect, withAuthRedirect } from "$lib/auth-redirect";
10
12
  import { getI18n } from "$lib/i18n";
11
13
  import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
12
14
  import type { PageData } from "./$types";
@@ -18,6 +20,7 @@
18
20
  let password = $state("");
19
21
  let error = $state<string | null>(null);
20
22
  let loading = $state(false);
23
+ const redirectTo = (): string => safeAuthRedirect(page.url.searchParams.get("redirect"));
21
24
 
22
25
  async function submit(event: SubmitEvent): Promise<void> {
23
26
  event.preventDefault();
@@ -29,7 +32,7 @@
29
32
  name,
30
33
  email,
31
34
  password,
32
- callbackURL: `${page.url.origin}/admin`,
35
+ callbackURL: new URL(redirectTo(), page.url.origin).toString(),
33
36
  });
34
37
  if (authError) {
35
38
  loading = false;
@@ -37,6 +40,10 @@
37
40
  await goto("/pending-approval");
38
41
  return;
39
42
  }
43
+ if (authError.code === PUBLIC_SIGNUP_DISABLED) {
44
+ error = i18n.t.auth.publicSignupDisabled;
45
+ return;
46
+ }
40
47
  error = authError.message ?? i18n.t.auth.signUpFailed;
41
48
  return;
42
49
  }
@@ -52,7 +59,7 @@
52
59
  await goto(`/verify-email?email=${encodeURIComponent(email)}`);
53
60
  return;
54
61
  }
55
- await goto("/admin", { invalidateAll: true });
62
+ await goto(redirectTo(), { invalidateAll: true });
56
63
  }
57
64
  </script>
58
65
 
@@ -82,6 +89,6 @@
82
89
  </form>
83
90
  </Card.Content>
84
91
  <Card.Footer class="justify-center">
85
- <p class="text-muted-foreground text-sm">{i18n.t.auth.haveAccount} <a href="/login" class="text-foreground hover:underline">{i18n.t.auth.signIn}</a></p>
92
+ <p class="text-muted-foreground text-sm">{i18n.t.auth.haveAccount} <a href={withAuthRedirect("/login", redirectTo())} class="text-foreground hover:underline">{i18n.t.auth.signIn}</a></p>
86
93
  </Card.Footer>
87
94
  </Card.Root>
@@ -1,9 +1,11 @@
1
1
  import type { LayoutServerLoad } from "./$types";
2
2
  import { redirect } from "@sveltejs/kit";
3
+ import { safeAuthRedirect } from "$lib/auth-redirect";
4
+ import { loadMessages } from "$lib/i18n/messages";
3
5
 
4
- // "/" is a public landing page. Auth pages send signed-in users to /admin, and
5
- // the admin area requires a session. /maintenance is public so it renders while
6
- // the rest of the site is held back.
6
+ // "/" is a public landing page. Auth pages return signed-in users to their safe
7
+ // requested page, and the admin area requires a session. /maintenance is public
8
+ // so it renders while the rest of the site is held back.
7
9
  const PUBLIC_PATHS = [
8
10
  "/",
9
11
  "/login",
@@ -41,14 +43,16 @@ export const load: LayoutServerLoad = async (event) => {
41
43
  }
42
44
  // Registration closed from the admin Settings: send /signup back to /login.
43
45
  if (site?.allowSignup === "false" && pathname === "/signup") {
44
- redirect(303, "/login");
46
+ const returnTo = safeAuthRedirect(url.searchParams.get("redirect"));
47
+ redirect(303, `/login?redirect=${encodeURIComponent(returnTo)}`);
45
48
  }
46
49
 
47
50
  if (locals.user && AUTH_PATHS.includes(pathname)) {
48
- redirect(303, "/admin");
51
+ redirect(303, safeAuthRedirect(url.searchParams.get("redirect")));
49
52
  }
50
53
  if (!locals.user && !isPublic(pathname)) {
51
- redirect(303, `/login?redirect=${encodeURIComponent(pathname)}`);
54
+ redirect(303, `/login?redirect=${encodeURIComponent(`${pathname}${url.search}`)}`);
52
55
  }
53
- return { user: locals.user, locale: locals.locale, site };
56
+ const messages = await loadMessages(locals.locale, site?.locale);
57
+ return { user: locals.user, locale: locals.locale, messages, site };
54
58
  };
@@ -1,4 +1,8 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import {
3
+ PUBLIC_SIGNUP_DISABLED,
4
+ isUserCreationAllowed,
5
+ } from "../../apps/api/src/auth/feature-gate";
2
6
  import { ADMIN } from "../helpers/accounts";
3
7
 
4
8
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
@@ -157,3 +161,77 @@ test("new registrations require approval when the policy is enabled @smoke", asy
157
161
  await admin.dispose();
158
162
  }
159
163
  });
164
+
165
+ test("closed public sign-up blocks new OAuth users but preserves existing sign-in @smoke", async ({ playwright }) => {
166
+ const admin = await adminCtx(playwright);
167
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
168
+ const password = "Podokit3e-Str0ng!pw";
169
+ const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
170
+ const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
171
+ allowSignup?: string | null;
172
+ };
173
+ const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
174
+ let adminCreatedId = "";
175
+
176
+ try {
177
+ expect(
178
+ (await admin.put("/api/account/auth-config", {
179
+ data: {
180
+ social: {
181
+ google: {
182
+ enabled: true,
183
+ clientId: "dummy-google-client-id",
184
+ clientSecret: "dummy-google-client-secret",
185
+ },
186
+ },
187
+ },
188
+ })).ok(),
189
+ ).toBeTruthy();
190
+ expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
191
+
192
+ // The site policy uses a short cache shared by sign-up requests and user
193
+ // creation hooks, so wait for the setting to become live.
194
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
195
+
196
+ const signup = await anon.post("/api/auth/sign-up/email", {
197
+ data: {
198
+ email: `closed-${Date.now()}@example.com`,
199
+ password,
200
+ name: "Closed Registration",
201
+ },
202
+ });
203
+ expect(signup.status()).toBe(403);
204
+ expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
205
+
206
+ // OAuth initiation must stay available for existing social users. Only a
207
+ // callback that attempts to create a new user is rejected by the DB hook.
208
+ const social = await anon.post("/api/auth/sign-in/social", {
209
+ data: { provider: "google", callbackURL: `${base}/` },
210
+ });
211
+ expect(social.ok()).toBeTruthy();
212
+ expect(((await social.json()).url as string)).toContain("accounts.google.com");
213
+ expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
214
+ expect(isUserCreationAllowed(false, undefined)).toBe(false);
215
+
216
+ // Deliberate administrator provisioning remains available while the public
217
+ // site is closed.
218
+ expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
219
+ const adminCreated = await admin.post("/api/auth/admin/create-user", {
220
+ data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
221
+ });
222
+ expect(adminCreated.ok()).toBeTruthy();
223
+ adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
224
+ } finally {
225
+ if (adminCreatedId) {
226
+ await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
227
+ }
228
+ await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
229
+ await admin.put("/api/account/auth-config", {
230
+ data: { social: { google: { enabled: false } } },
231
+ });
232
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
233
+ await anon.get("/api/auth/get-session");
234
+ await anon.dispose();
235
+ await admin.dispose();
236
+ }
237
+ });
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import { safeAuthRedirect, withAuthRedirect } from "../../apps/web/src/lib/auth-redirect";
2
3
  import { anonState, ADMIN } from "../helpers/accounts";
3
4
  import { ready } from "../helpers/hydration";
4
5
 
@@ -24,6 +25,25 @@ test("invalid credentials show an error", async ({ page }) => {
24
25
  await expect(page).toHaveURL(/\/login/);
25
26
  });
26
27
 
28
+ test("login returns to the requested page instead of the admin dashboard", async ({ page }) => {
29
+ await ready(page, "/login?redirect=%2F%3Ffrom%3Dlogin");
30
+ await page.locator("#email").fill(ADMIN.email);
31
+ await page.locator("#password").fill(ADMIN.password);
32
+ await page.locator('form button[type="submit"]').click();
33
+ await expect(page).toHaveURL((url) => url.pathname === "/" && url.searchParams.get("from") === "login");
34
+ });
35
+
36
+ test("authentication return targets reject external and recursive redirects", () => {
37
+ expect(safeAuthRedirect("/products?page=2")).toBe("/products?page=2");
38
+ expect(safeAuthRedirect("https://example.com/steal")).toBe("/");
39
+ expect(safeAuthRedirect("//example.com/steal")).toBe("/");
40
+ expect(safeAuthRedirect("/\\example.com/steal")).toBe("/");
41
+ expect(safeAuthRedirect("/login")).toBe("/");
42
+ expect(withAuthRedirect("/login", "/products?page=2")).toBe(
43
+ "/login?redirect=%2Fproducts%3Fpage%3D2",
44
+ );
45
+ });
46
+
27
47
  test("signup page renders", async ({ page }) => {
28
48
  await page.goto("/signup");
29
49
  await expect(page.getByLabel("Name")).toBeVisible();
@@ -36,6 +56,13 @@ test("pending approval errors show the approval status page", async ({ page }) =
36
56
  await expect(page.getByRole("heading", { name: "Waiting for approval" })).toBeVisible();
37
57
  });
38
58
 
59
+ test("closed social registration errors explain that existing users may sign in", async ({ page }) => {
60
+ await page.goto("/login?error=PUBLIC_SIGNUP_DISABLED");
61
+ await expect(page.getByTestId("auth-error")).toContainText(
62
+ /Public sign-up is closed|공개 가입이 닫혀 있습니다/,
63
+ );
64
+ });
65
+
39
66
  test("configured social providers appear on the login page", async ({ page, playwright }) => {
40
67
  const admin = await playwright.request.newContext({
41
68
  baseURL: base,
@@ -96,5 +123,5 @@ test("signup creates an account and enters the app @smoke", async ({ page }) =>
96
123
  await page.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
97
124
  await page.getByRole("button", { name: "Create account" }).click();
98
125
  // Verification off → straight into the app; on → the verify-email page.
99
- await expect(page).toHaveURL(/\/admin|\/verify-email/);
126
+ await expect(page).toHaveURL((url) => url.pathname === "/" || url.pathname === "/verify-email");
100
127
  });
@@ -38,5 +38,5 @@ test("forgot password: request, open the emailed link, set a new password, sign
38
38
  await page.getByLabel("Email").fill(email);
39
39
  await page.getByLabel("Password").fill("Podokit3e-N3wStr0ng!pw");
40
40
  await page.getByRole("button", { name: "Sign in" }).click();
41
- await expect(page).toHaveURL(/\/admin/);
41
+ await expect(page).toHaveURL(new URL("/", base).toString());
42
42
  });
@@ -28,6 +28,21 @@ async function saveGeneral(
28
28
  await expect(page.getByText("General settings saved.").last()).toBeVisible();
29
29
  }
30
30
 
31
+ test("general: empty locale displays the application default", async ({ page }) => {
32
+ await ready(page, "/admin/settings");
33
+ const origin = new URL(page.url()).origin;
34
+ const settingsUrl = `${origin}/api/site/settings`;
35
+ const previous = await (await page.request.get(settingsUrl)).json() as { locale?: string };
36
+
37
+ try {
38
+ expect((await page.request.put(settingsUrl, { data: { locale: "" } })).ok()).toBeTruthy();
39
+ await ready(page, "/admin/settings");
40
+ await expect(page.locator("#locale")).toContainText("English");
41
+ } finally {
42
+ await page.request.put(settingsUrl, { data: { locale: previous.locale ?? "" } });
43
+ }
44
+ });
45
+
31
46
  test("general: footer text and links render on public pages @smoke", async ({ page, browser }) => {
32
47
  await ready(page, "/admin/settings");
33
48
  const origin = new URL(page.url()).origin;