@podosoft/podokit 0.4.0 → 0.5.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 (141) hide show
  1. package/README.md +2 -2
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +5 -1
  4. package/dist/assemble.d.ts +14 -0
  5. package/dist/assemble.js +60 -0
  6. package/dist/create.d.ts +2 -0
  7. package/dist/create.js +7 -0
  8. package/dist/eject.d.ts +11 -0
  9. package/dist/eject.js +27 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +122 -1
  12. package/dist/inspect.d.ts +43 -0
  13. package/dist/inspect.js +82 -0
  14. package/dist/lockfile.d.ts +80 -0
  15. package/dist/lockfile.js +193 -0
  16. package/dist/templates/base/dot-env.example +11 -6
  17. package/dist/templates/fullstack-nest-svelte/README.md +2 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
  23. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  29. package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
  30. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
  31. package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
  33. package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
  34. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
  63. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
  64. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
  66. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
  67. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
  68. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
  72. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +52 -0
  81. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
  82. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
  83. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
  84. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
  85. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
  86. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
  87. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
  88. package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
  90. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
  91. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
  92. package/dist/templates/modules/audit-log/module.manifest.json +15 -9
  93. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
  94. package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
  104. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
  106. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
  113. package/dist/templates/modules/auth/module.manifest.json +54 -20
  114. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
  115. package/dist/templates/modules/bullmq/module.manifest.json +4 -4
  116. package/dist/templates/modules/file-upload/module.manifest.json +3 -3
  117. package/dist/templates/modules/job-progress/module.manifest.json +9 -9
  118. package/dist/templates/modules/logging/module.manifest.json +3 -3
  119. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
  120. package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
  121. package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
  122. package/dist/templates/modules/redis/module.manifest.json +4 -4
  123. package/dist/templates/modules/sse/module.manifest.json +4 -4
  124. package/dist/templates/todo/README.md +2 -2
  125. package/dist/templates/todo/apps/api/package.json +1 -0
  126. package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
  127. package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
  128. package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
  129. package/dist/templates/todo/apps/api/src/main.ts +1 -1
  130. package/dist/templates/todo/apps/web/package.json +2 -2
  131. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  132. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  134. package/dist/templates/todo/dot-env.example +10 -5
  135. package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
  136. package/dist/templates/todo/tests/README.md +1 -1
  137. package/dist/templates/todo/tests/package.json +2 -1
  138. package/dist/templates/todo/tests/playwright.config.ts +1 -1
  139. package/dist/update.d.ts +49 -0
  140. package/dist/update.js +158 -0
  141. package/package.json +2 -2
@@ -9,20 +9,102 @@
9
9
  import { api } from "$lib/api";
10
10
  import { getI18n } from "$lib/i18n";
11
11
 
12
+ import type { PageData } from "./$types";
13
+
14
+ let { data }: { data: PageData } = $props();
12
15
  const i18n = getI18n();
13
16
  let email = $state("");
14
17
  let password = $state("");
15
18
  let error = $state<string | null>(null);
19
+ let unverified = $state(false);
16
20
  let loading = $state(false);
17
21
 
22
+ // Passwordless sign-in, shown only when the server enabled the magic-link plugin.
23
+ const magicLinkEnabled = $derived(data.capabilities?.magicLink ?? false);
24
+ let magicLoading = $state(false);
25
+ let magicSent = $state(false);
26
+ async function sendMagicLink(): Promise<void> {
27
+ if (!email) {
28
+ error = i18n.t.auth.magicLinkNeedsEmail;
29
+ return;
30
+ }
31
+ magicLoading = true;
32
+ error = null;
33
+ const { error: authError } = await api.auth.signIn.magicLink({
34
+ email,
35
+ callbackURL: page.url.searchParams.get("redirect") ?? "/admin",
36
+ });
37
+ magicLoading = false;
38
+ if (authError) {
39
+ error = authError.message ?? i18n.t.auth.signInFailed;
40
+ return;
41
+ }
42
+ magicSent = true;
43
+ }
44
+
45
+ // Passwordless sign-in with an emailed one-time code (two steps: send, verify).
46
+ const usernameEnabled = $derived(data.capabilities?.username ?? false);
47
+ const emailOtpEnabled = $derived(data.capabilities?.emailOtp ?? false);
48
+ let otpSent = $state(false);
49
+ let otpCode = $state("");
50
+ let otpLoading = $state(false);
51
+ async function sendOtp(): Promise<void> {
52
+ if (!email) {
53
+ error = i18n.t.auth.magicLinkNeedsEmail;
54
+ return;
55
+ }
56
+ otpLoading = true;
57
+ error = null;
58
+ const { error: authError } = await api.auth.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
59
+ otpLoading = false;
60
+ if (authError) {
61
+ error = authError.message ?? i18n.t.auth.signInFailed;
62
+ return;
63
+ }
64
+ otpSent = true;
65
+ }
66
+ async function verifyOtp(): Promise<void> {
67
+ otpLoading = true;
68
+ error = null;
69
+ const { error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
70
+ otpLoading = false;
71
+ if (authError) {
72
+ error = authError.message ?? i18n.t.auth.signInFailed;
73
+ return;
74
+ }
75
+ await goto(page.url.searchParams.get("redirect") ?? "/admin", { invalidateAll: true });
76
+ }
77
+
78
+ // Passwordless sign-in with a registered passkey (WebAuthn), shown when enabled.
79
+ const passkeyEnabled = $derived(data.capabilities?.passkey ?? false);
80
+ let passkeyLoading = $state(false);
81
+ async function signInWithPasskey(): Promise<void> {
82
+ passkeyLoading = true;
83
+ error = null;
84
+ const res = await api.auth.signIn.passkey();
85
+ passkeyLoading = false;
86
+ if (res?.error) {
87
+ error = res.error.message ?? i18n.t.auth.signInFailed;
88
+ return;
89
+ }
90
+ await goto(page.url.searchParams.get("redirect") ?? "/admin", { invalidateAll: true });
91
+ }
92
+
18
93
  async function submit(event: SubmitEvent): Promise<void> {
19
94
  event.preventDefault();
20
95
  loading = true;
21
96
  error = null;
22
- const { error: authError } = await api.auth.signIn.email({ email, password });
97
+ unverified = false;
98
+ // With the username plugin on, an identifier without "@" is treated as a username.
99
+ const asUsername = usernameEnabled && !email.includes("@");
100
+ const { error: authError } = asUsername
101
+ ? await api.auth.signIn.username({ username: email, password })
102
+ : await api.auth.signIn.email({ email, password });
23
103
  loading = false;
24
104
  if (authError) {
25
- error = authError.message ?? i18n.t.auth.signInFailed;
105
+ // Surface an unverified address with a path to a fresh verification link.
106
+ unverified = authError.code === "EMAIL_NOT_VERIFIED";
107
+ error = unverified ? i18n.t.auth.emailNotVerified : (authError.message ?? i18n.t.auth.signInFailed);
26
108
  return;
27
109
  }
28
110
  await goto(page.url.searchParams.get("redirect") ?? "/admin", { invalidateAll: true });
@@ -37,11 +119,18 @@
37
119
  <Card.Content>
38
120
  <form class="flex flex-col gap-4" onsubmit={submit}>
39
121
  {#if error}
40
- <Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>
122
+ <Alert.Root variant="destructive">
123
+ <Alert.Description>
124
+ {error}
125
+ {#if unverified}
126
+ <a href="/verify-email?email={encodeURIComponent(email)}" class="font-medium underline">{i18n.t.auth.resendVerification}</a>
127
+ {/if}
128
+ </Alert.Description>
129
+ </Alert.Root>
41
130
  {/if}
42
131
  <div class="flex flex-col gap-2">
43
- <Label for="email">{i18n.t.auth.email}</Label>
44
- <Input id="email" type="email" bind:value={email} required autocomplete="email" />
132
+ <Label for="email">{usernameEnabled ? i18n.t.auth.emailOrUsername : i18n.t.auth.email}</Label>
133
+ <Input id="email" type={usernameEnabled ? "text" : "email"} bind:value={email} required autocomplete="username" />
45
134
  </div>
46
135
  <div class="flex flex-col gap-2">
47
136
  <div class="flex items-center justify-between">
@@ -52,6 +141,39 @@
52
141
  </div>
53
142
  <Button type="submit" disabled={loading}>{loading ? i18n.t.auth.signingIn : i18n.t.auth.signIn}</Button>
54
143
  </form>
144
+ {#if magicLinkEnabled || emailOtpEnabled || passkeyEnabled}
145
+ <div class="mt-4 flex flex-col gap-3 border-t pt-4">
146
+ {#if passkeyEnabled}
147
+ <Button type="button" variant="outline" class="w-full" disabled={passkeyLoading} onclick={signInWithPasskey}>
148
+ {passkeyLoading ? i18n.t.auth.signingIn : i18n.t.auth.passkeyButton}
149
+ </Button>
150
+ {/if}
151
+ {#if magicLinkEnabled}
152
+ {#if magicSent}
153
+ <p class="text-muted-foreground text-sm" data-testid="magic-link-sent">{i18n.t.auth.magicLinkSent}</p>
154
+ {:else}
155
+ <Button type="button" variant="outline" class="w-full" disabled={magicLoading} onclick={sendMagicLink}>
156
+ {magicLoading ? i18n.t.auth.sending : i18n.t.auth.magicLinkButton}
157
+ </Button>
158
+ {/if}
159
+ {/if}
160
+ {#if emailOtpEnabled}
161
+ {#if otpSent}
162
+ <div class="flex flex-col gap-2">
163
+ <Label for="otp">{i18n.t.auth.otpCode}</Label>
164
+ <Input id="otp" bind:value={otpCode} inputmode="numeric" autocomplete="one-time-code" />
165
+ <Button type="button" class="w-full" disabled={otpLoading || !otpCode} onclick={verifyOtp}>
166
+ {otpLoading ? i18n.t.auth.signingIn : i18n.t.auth.otpVerify}
167
+ </Button>
168
+ </div>
169
+ {:else}
170
+ <Button type="button" variant="outline" class="w-full" disabled={otpLoading} onclick={sendOtp}>
171
+ {otpLoading ? i18n.t.auth.sending : i18n.t.auth.otpButton}
172
+ </Button>
173
+ {/if}
174
+ {/if}
175
+ </div>
176
+ {/if}
55
177
  </Card.Content>
56
178
  <Card.Footer class="justify-center">
57
179
  <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>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { goto } from "$app/navigation";
3
+ import { page } from "$app/state";
3
4
  import { Button } from "$lib/components/ui/button";
4
5
  import { Input } from "$lib/components/ui/input";
5
6
  import { Label } from "$lib/components/ui/label";
@@ -19,12 +20,27 @@
19
20
  event.preventDefault();
20
21
  loading = true;
21
22
  error = null;
22
- const { error: authError } = await api.auth.signUp.email({ name, email, password });
23
- loading = false;
23
+ // callbackURL: where the verification link lands once confirmed (absolute so
24
+ // it returns to the web app, not the API origin). Ignored when verification is off.
25
+ const { error: authError } = await api.auth.signUp.email({
26
+ name,
27
+ email,
28
+ password,
29
+ callbackURL: `${page.url.origin}/admin`,
30
+ });
24
31
  if (authError) {
32
+ loading = false;
25
33
  error = authError.message ?? i18n.t.auth.signUpFailed;
26
34
  return;
27
35
  }
36
+ // When email verification is required, sign-up creates no session — send the
37
+ // user to confirm their address instead of into the (guarded) app.
38
+ const { data: session } = await api.auth.getSession();
39
+ loading = false;
40
+ if (!session?.session) {
41
+ await goto(`/verify-email?email=${encodeURIComponent(email)}`);
42
+ return;
43
+ }
28
44
  await goto("/admin", { invalidateAll: true });
29
45
  }
30
46
  </script>
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ import { page } from "$app/state";
3
+ import { Button } from "$lib/components/ui/button";
4
+ import { Input } from "$lib/components/ui/input";
5
+ import { Label } from "$lib/components/ui/label";
6
+ import * as Card from "$lib/components/ui/card";
7
+ import * as Alert from "$lib/components/ui/alert";
8
+ import { api } from "$lib/api";
9
+ import { fmt, getI18n } from "$lib/i18n";
10
+
11
+ const i18n = getI18n();
12
+ // ?email from the sign-up redirect; ?error when the API bounces a bad/expired link here.
13
+ let email = $state(page.url.searchParams.get("email") ?? "");
14
+ const linkError = $derived(page.url.searchParams.get("error"));
15
+ let sent = $state(false);
16
+ let error = $state<string | null>(null);
17
+ let loading = $state(false);
18
+
19
+ async function resend(event: SubmitEvent): Promise<void> {
20
+ event.preventDefault();
21
+ loading = true;
22
+ error = null;
23
+ const { error: authError } = await api.auth.sendVerificationEmail({
24
+ email,
25
+ callbackURL: `${page.url.origin}/admin`,
26
+ });
27
+ loading = false;
28
+ if (authError) {
29
+ error = authError.message ?? i18n.t.auth.requestFailed;
30
+ return;
31
+ }
32
+ sent = true;
33
+ }
34
+ </script>
35
+
36
+ <Card.Root>
37
+ <Card.Header>
38
+ <Card.Title>{i18n.t.auth.verifyTitle}</Card.Title>
39
+ <Card.Description>{email ? fmt(i18n.t.auth.verifyDesc, { email }) : i18n.t.auth.verifyDescNoEmail}</Card.Description>
40
+ </Card.Header>
41
+ <Card.Content>
42
+ {#if linkError}
43
+ <Alert.Root variant="destructive" class="mb-4"><Alert.Description>{i18n.t.auth.verifyLinkError}</Alert.Description></Alert.Root>
44
+ {/if}
45
+ {#if sent}
46
+ <Alert.Root><Alert.Description>{i18n.t.auth.verifySent}</Alert.Description></Alert.Root>
47
+ {:else}
48
+ <form class="flex flex-col gap-4" onsubmit={resend}>
49
+ {#if error}
50
+ <Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>
51
+ {/if}
52
+ <div class="flex flex-col gap-2">
53
+ <Label for="email">{i18n.t.auth.email}</Label>
54
+ <Input id="email" type="email" bind:value={email} required autocomplete="email" />
55
+ </div>
56
+ <Button type="submit" disabled={loading || !email}>{loading ? i18n.t.auth.sending : i18n.t.auth.resendVerification}</Button>
57
+ </form>
58
+ {/if}
59
+ </Card.Content>
60
+ <Card.Footer class="justify-center">
61
+ <a href="/login" class="text-muted-foreground text-sm hover:underline">{i18n.t.auth.backToSignIn}</a>
62
+ </Card.Footer>
63
+ </Card.Root>
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { page } from "$app/state";
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
+ // The OAuth provider redirects here with the authorization request in the query
9
+ // (and a pending-request cookie). Show what's being requested, then approve/deny.
10
+ const clientId = $derived(page.url.searchParams.get("client_id") ?? "");
11
+ const scopes = $derived((page.url.searchParams.get("scope") ?? "").split(/[+\s]/).filter(Boolean));
12
+ let busy = $state(false);
13
+
14
+ async function decide(accept: boolean): Promise<void> {
15
+ busy = true;
16
+ // The authorization request is carried in the signed query params we were
17
+ // redirected here with; forward them verbatim so the server can validate and
18
+ // restore it. Credentials carry the user's session. The response says where next.
19
+ const res = await fetch(`/api/auth/oauth2/consent${page.url.search}`, {
20
+ method: "POST",
21
+ credentials: "include",
22
+ headers: { "content-type": "application/json" },
23
+ body: JSON.stringify({ accept }),
24
+ });
25
+ const data = (await res.json().catch(() => ({}))) as { url?: string; redirectURI?: string };
26
+ const next = data.url ?? data.redirectURI;
27
+ if (next) window.location.href = next;
28
+ else busy = false;
29
+ }
30
+ </script>
31
+
32
+ <div class="mx-auto flex min-h-[70vh] max-w-md items-center">
33
+ <Card.Root class="w-full">
34
+ <Card.Header>
35
+ <Card.Title>{i18n.t.consent.title}</Card.Title>
36
+ <Card.Description>{i18n.t.consent.subtitle}</Card.Description>
37
+ </Card.Header>
38
+ <Card.Content class="flex flex-col gap-4">
39
+ <div class="text-sm">
40
+ <p class="font-medium">{clientId}</p>
41
+ {#if scopes.length}
42
+ <p class="text-muted-foreground mt-1">{i18n.t.consent.requests}</p>
43
+ <ul class="text-muted-foreground mt-1 list-inside list-disc">
44
+ {#each scopes as s (s)}<li>{s}</li>{/each}
45
+ </ul>
46
+ {/if}
47
+ </div>
48
+ <div class="flex justify-end gap-2">
49
+ <Button variant="outline" disabled={busy} onclick={() => decide(false)}>{i18n.t.consent.deny}</Button>
50
+ <Button disabled={busy} onclick={() => decide(true)}>{i18n.t.consent.allow}</Button>
51
+ </div>
52
+ </Card.Content>
53
+ </Card.Root>
54
+ </div>
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN, USER } from "../helpers/accounts";
3
3
 
4
- const base = process.env.E2E_BASE_URL ?? "http://localhost:5173";
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
  const origin = { origin: base };
6
6
 
7
7
  async function signedIn(
@@ -21,7 +21,7 @@ test("audit log records auth/admin actions", async ({ playwright }) => {
21
21
  // an admin action that flows through better-auth (bypasses the interceptor)
22
22
  const email = `audit-${Date.now()}@example.com`;
23
23
  const created = await admin.post("/api/auth/admin/create-user", {
24
- data: { email, password: "password123", name: "Audit", role: "user" },
24
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "Audit", role: "user" },
25
25
  });
26
26
  expect(created.ok()).toBeTruthy();
27
27
  const userId = (await created.json())?.user?.id as string;
@@ -46,3 +46,52 @@ test("the audit log is admin-only", async ({ playwright }) => {
46
46
  expect((await user.get("/api/audit-logs")).status()).toBe(403);
47
47
  await user.dispose();
48
48
  });
49
+
50
+ test("sending a verification email is audited", async ({ playwright }) => {
51
+ const admin = await signedIn(playwright, ADMIN);
52
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
53
+ const email = `verif-audit-${Date.now()}@example.com`;
54
+ await admin.post("/api/auth/admin/create-user", { data: { email, password: "Podokit3e-Str0ng!pw", name: "VA", role: "user" } });
55
+ await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
56
+ const entries = (await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>;
57
+ expect(entries.some((e) => e.action === "auth.verification_sent"), "verification send should be audited").toBeTruthy();
58
+ await admin.dispose();
59
+ });
60
+
61
+ // Audit logging is a DB-backed on/off toggle (auth_config `server`, applied live).
62
+ // Runs after the recording tests and restores the flag so nothing else is affected.
63
+ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright }) => {
64
+ const admin = await signedIn(playwright, ADMIN);
65
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
66
+ const auditLog = async () => (await (await admin.get("/api/account/capabilities")).json()).auditLog as boolean;
67
+ const recorded = async (label: string) =>
68
+ ((await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>).some(
69
+ (e) => e.action === "user.create" && e.targetLabel === label,
70
+ );
71
+ const createUser = async (email: string) =>
72
+ (await (await admin.post("/api/auth/admin/create-user", { data: { email, password: "Podokit3e-Str0ng!pw", name: "T", role: "user" } })).json())?.user?.id as string;
73
+
74
+ try {
75
+ // OFF → the action is not recorded. Wait past the config-store cache TTL so the
76
+ // audit gate sees the new value (its cache is independent of the capabilities one).
77
+ await admin.put("/api/account/auth-config", { data: { server: { auditLog: false } } });
78
+ await expect.poll(auditLog, { timeout: 8000 }).toBe(false);
79
+ await new Promise((r) => setTimeout(r, 4000));
80
+ const offEmail = `audit-off-${Date.now()}@example.com`;
81
+ const offId = await createUser(offEmail);
82
+ expect(await recorded(offEmail), "no audit entry while disabled").toBe(false);
83
+ await admin.post("/api/auth/admin/remove-user", { data: { userId: offId } });
84
+
85
+ // ON → recording resumes.
86
+ await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
87
+ await expect.poll(auditLog, { timeout: 8000 }).toBe(true);
88
+ await new Promise((r) => setTimeout(r, 4000));
89
+ const onEmail = `audit-on-${Date.now()}@example.com`;
90
+ const onId = await createUser(onEmail);
91
+ expect(await recorded(onEmail), "audit entry recorded once re-enabled").toBe(true);
92
+ await admin.post("/api/auth/admin/remove-user", { data: { userId: onId } });
93
+ } finally {
94
+ await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
95
+ await admin.dispose();
96
+ }
97
+ });
@@ -0,0 +1,80 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ADMIN } from "../helpers/accounts";
3
+
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+ const origin = { origin: base };
6
+
7
+ // Fresh admin context (not the shared storageState) so these never rotate the
8
+ // seeded admin session.
9
+ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
10
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
11
+ await ctx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
12
+ return ctx;
13
+ }
14
+
15
+ test("admin can enable Google OAuth from the DB and it applies without a restart @smoke", async ({ playwright }) => {
16
+ const admin = await adminCtx(playwright);
17
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
18
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
19
+ const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
20
+
21
+ // Before: provider not configured.
22
+ await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
23
+
24
+ // Paste credentials via the admin API.
25
+ const put = await admin.put("/api/account/auth-config", {
26
+ data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
27
+ });
28
+ expect(put.ok()).toBeTruthy();
29
+ const view = await put.json();
30
+ expect(view.social.google.hasSecret).toBe(true);
31
+ expect(view.social.google.clientId).toBe("dummy-google-client-id");
32
+ expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
33
+
34
+ // Applied live (no restart): social sign-in now redirects to Google.
35
+ await expect
36
+ .poll(async () => {
37
+ const r = await social();
38
+ return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
39
+ }, { timeout: 8000 })
40
+ .toContain("accounts.google.com");
41
+ const caps = await (await anon.get("/api/account/capabilities")).json();
42
+ expect(caps.providers).toContain("google");
43
+
44
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
45
+ await anon.dispose();
46
+ await admin.dispose();
47
+ });
48
+
49
+ test("admin can add and remove a social provider dynamically @smoke", async ({ playwright }) => {
50
+ const admin = await adminCtx(playwright);
51
+ // The catalog offers providers beyond the two legacy env-backed ones.
52
+ const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
53
+ expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
54
+
55
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
56
+ const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
57
+
58
+ // Add GitHub (a provider with no env fallback) → applies live, no restart.
59
+ await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
60
+ await expect
61
+ .poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
62
+ .toContain("github.com");
63
+
64
+ // Remove it → the row is deleted and the provider stops working (again live).
65
+ await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
66
+ expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
67
+ await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
68
+
69
+ await anon.dispose();
70
+ await admin.dispose();
71
+ });
72
+
73
+ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwright }) => {
74
+ const user = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
75
+ const email = `acfg-${Date.now()}@example.com`;
76
+ await user.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "U" } });
77
+ expect((await user.get("/api/account/auth-config")).status()).toBe(403);
78
+ expect((await user.put("/api/account/auth-config", { data: { server: { hibp: true } } })).status()).toBe(403);
79
+ await user.dispose();
80
+ });