@podosoft/podokit 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +25 -0
- package/dist/remove.d.ts +28 -0
- package/dist/remove.js +170 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +8 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +10 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +58 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +39 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +13 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +61 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +129 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
- package/dist/templates/modules/auth/module.manifest.json +10 -0
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/apps/web/vite.config.ts +8 -2
- package/dist/templates/todo/compose.dev.yaml +10 -3
- package/package.json +1 -1
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte
CHANGED
|
@@ -22,6 +22,49 @@
|
|
|
22
22
|
let unverified = $state(false);
|
|
23
23
|
let loading = $state(false);
|
|
24
24
|
|
|
25
|
+
// Second-factor step: a password/OTP sign-in with 2FA enabled returns
|
|
26
|
+
// `twoFactorRedirect` and no session; the user then verifies with an
|
|
27
|
+
// authenticator code or a backup code to complete the login.
|
|
28
|
+
let twoFaRequired = $state(false);
|
|
29
|
+
let twoFaCode = $state("");
|
|
30
|
+
let useBackupCode = $state(false);
|
|
31
|
+
let twoFaLoading = $state(false);
|
|
32
|
+
const redirectTo = (): string => page.url.searchParams.get("redirect") ?? "/admin";
|
|
33
|
+
|
|
34
|
+
// Returns true when 2FA is still owed (caller shows the step instead of leaving).
|
|
35
|
+
function needsTwoFactor(data: unknown): boolean {
|
|
36
|
+
return Boolean(data && typeof data === "object" && (data as { twoFactorRedirect?: boolean }).twoFactorRedirect);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Map better-auth's (English) two-factor error codes to localized copy — the
|
|
40
|
+
// backend returns a stable `code`, never a translated message (see i18n policy).
|
|
41
|
+
function twoFactorError(code: string | undefined): string {
|
|
42
|
+
switch (code) {
|
|
43
|
+
case "INVALID_CODE":
|
|
44
|
+
case "INVALID_BACKUP_CODE":
|
|
45
|
+
return i18n.t.auth.twoFactorInvalidCode;
|
|
46
|
+
case "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE":
|
|
47
|
+
case "ACCOUNT_TEMPORARILY_LOCKED":
|
|
48
|
+
return i18n.t.auth.twoFactorLocked;
|
|
49
|
+
default:
|
|
50
|
+
return i18n.t.auth.signInFailed;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function verifyTwoFactor(): Promise<void> {
|
|
55
|
+
twoFaLoading = true;
|
|
56
|
+
error = null;
|
|
57
|
+
const { error: authError } = useBackupCode
|
|
58
|
+
? await api.auth.twoFactor.verifyBackupCode({ code: twoFaCode.trim() })
|
|
59
|
+
: await api.auth.twoFactor.verifyTotp({ code: twoFaCode.trim() });
|
|
60
|
+
twoFaLoading = false;
|
|
61
|
+
if (authError) {
|
|
62
|
+
error = twoFactorError(authError.code);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await goto(redirectTo(), { invalidateAll: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
25
68
|
// Passwordless sign-in, shown only when the server enabled the magic-link plugin.
|
|
26
69
|
const magicLinkEnabled = $derived(data.capabilities?.magicLink ?? false);
|
|
27
70
|
let magicLoading = $state(false);
|
|
@@ -69,13 +112,17 @@
|
|
|
69
112
|
async function verifyOtp(): Promise<void> {
|
|
70
113
|
otpLoading = true;
|
|
71
114
|
error = null;
|
|
72
|
-
const { error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
|
|
115
|
+
const { data, error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
|
|
73
116
|
otpLoading = false;
|
|
74
117
|
if (authError) {
|
|
75
118
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
76
119
|
return;
|
|
77
120
|
}
|
|
78
|
-
|
|
121
|
+
if (needsTwoFactor(data)) {
|
|
122
|
+
twoFaRequired = true;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await goto(redirectTo(), { invalidateAll: true });
|
|
79
126
|
}
|
|
80
127
|
|
|
81
128
|
// Passwordless sign-in with a registered passkey (WebAuthn), shown when enabled.
|
|
@@ -100,7 +147,7 @@
|
|
|
100
147
|
unverified = false;
|
|
101
148
|
// With the username plugin on, an identifier without "@" is treated as a username.
|
|
102
149
|
const asUsername = usernameEnabled && !email.includes("@");
|
|
103
|
-
const { error: authError } = asUsername
|
|
150
|
+
const { data, error: authError } = asUsername
|
|
104
151
|
? await api.auth.signIn.username({ username: email, password })
|
|
105
152
|
: await api.auth.signIn.email({ email, password });
|
|
106
153
|
loading = false;
|
|
@@ -110,7 +157,11 @@
|
|
|
110
157
|
error = unverified ? i18n.t.auth.emailNotVerified : (authError.message ?? i18n.t.auth.signInFailed);
|
|
111
158
|
return;
|
|
112
159
|
}
|
|
113
|
-
|
|
160
|
+
if (needsTwoFactor(data)) {
|
|
161
|
+
twoFaRequired = true;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await goto(redirectTo(), { invalidateAll: true });
|
|
114
165
|
}
|
|
115
166
|
</script>
|
|
116
167
|
|
|
@@ -120,17 +171,47 @@
|
|
|
120
171
|
<Card.Description>{i18n.t.auth.signInDesc}</Card.Description>
|
|
121
172
|
</Card.Header>
|
|
122
173
|
<Card.Content>
|
|
174
|
+
{#if error}
|
|
175
|
+
<Alert.Root variant="destructive" class="mb-4">
|
|
176
|
+
<Alert.Description>
|
|
177
|
+
{error}
|
|
178
|
+
{#if unverified}
|
|
179
|
+
<a href="/verify-email?email={encodeURIComponent(email)}" class="font-medium underline">{i18n.t.auth.resendVerification}</a>
|
|
180
|
+
{/if}
|
|
181
|
+
</Alert.Description>
|
|
182
|
+
</Alert.Root>
|
|
183
|
+
{/if}
|
|
184
|
+
{#if twoFaRequired}
|
|
185
|
+
<div class="flex flex-col gap-4" data-testid="two-factor-step">
|
|
186
|
+
<p class="text-muted-foreground text-sm">{useBackupCode ? i18n.t.auth.twoFactorBackupPrompt : i18n.t.auth.twoFactorPrompt}</p>
|
|
187
|
+
<div class="flex flex-col gap-2">
|
|
188
|
+
<Label for="twofa">{useBackupCode ? i18n.t.auth.backupCodeLabel : i18n.t.auth.twoFactorCodeLabel}</Label>
|
|
189
|
+
<Input
|
|
190
|
+
id="twofa"
|
|
191
|
+
bind:value={twoFaCode}
|
|
192
|
+
inputmode={useBackupCode ? "text" : "numeric"}
|
|
193
|
+
autocomplete="one-time-code"
|
|
194
|
+
autofocus
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
<Button type="button" disabled={twoFaLoading || !twoFaCode} onclick={verifyTwoFactor}>
|
|
198
|
+
{twoFaLoading ? i18n.t.auth.signingIn : i18n.t.auth.verify}
|
|
199
|
+
</Button>
|
|
200
|
+
<Button
|
|
201
|
+
type="button"
|
|
202
|
+
variant="link"
|
|
203
|
+
class="h-auto justify-start p-0 text-xs"
|
|
204
|
+
onclick={() => {
|
|
205
|
+
useBackupCode = !useBackupCode;
|
|
206
|
+
twoFaCode = "";
|
|
207
|
+
error = null;
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{useBackupCode ? i18n.t.auth.twoFactorUseAuthenticator : i18n.t.auth.twoFactorUseBackup}
|
|
211
|
+
</Button>
|
|
212
|
+
</div>
|
|
213
|
+
{:else}
|
|
123
214
|
<form class="flex flex-col gap-4" onsubmit={submit}>
|
|
124
|
-
{#if error}
|
|
125
|
-
<Alert.Root variant="destructive">
|
|
126
|
-
<Alert.Description>
|
|
127
|
-
{error}
|
|
128
|
-
{#if unverified}
|
|
129
|
-
<a href="/verify-email?email={encodeURIComponent(email)}" class="font-medium underline">{i18n.t.auth.resendVerification}</a>
|
|
130
|
-
{/if}
|
|
131
|
-
</Alert.Description>
|
|
132
|
-
</Alert.Root>
|
|
133
|
-
{/if}
|
|
134
215
|
<div class="flex flex-col gap-2">
|
|
135
216
|
<Label for="email">{usernameEnabled ? i18n.t.auth.emailOrUsername : i18n.t.auth.email}</Label>
|
|
136
217
|
<Input id="email" type={usernameEnabled ? "text" : "email"} bind:value={email} required autocomplete="username" />
|
|
@@ -177,6 +258,7 @@
|
|
|
177
258
|
{/if}
|
|
178
259
|
</div>
|
|
179
260
|
{/if}
|
|
261
|
+
{/if}
|
|
180
262
|
</Card.Content>
|
|
181
263
|
{#if signupOpen}
|
|
182
264
|
<Card.Footer class="justify-center">
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { redirect } from "@sveltejs/kit";
|
|
2
|
+
import type { PageServerLoad } from "./$types";
|
|
3
|
+
|
|
4
|
+
// The mandatory two-factor enrolment page (reached via the require-2fa gate).
|
|
5
|
+
// Must be signed in; already-enrolled users have nothing to do here.
|
|
6
|
+
export const load: PageServerLoad = ({ locals }) => {
|
|
7
|
+
if (!locals.user) redirect(302, "/login");
|
|
8
|
+
const user = locals.user as App.Locals["user"] & { twoFactorEnabled?: boolean };
|
|
9
|
+
if (user.twoFactorEnabled) redirect(302, "/admin");
|
|
10
|
+
return { user: locals.user };
|
|
11
|
+
};
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from "$app/navigation";
|
|
3
|
+
import QRCode from "qrcode";
|
|
4
|
+
import { Button } from "$lib/components/ui/button";
|
|
5
|
+
import { Input } from "$lib/components/ui/input";
|
|
6
|
+
import { Label } from "$lib/components/ui/label";
|
|
7
|
+
import { Checkbox } from "$lib/components/ui/checkbox";
|
|
8
|
+
import * as Card from "$lib/components/ui/card";
|
|
9
|
+
import * as Alert from "$lib/components/ui/alert";
|
|
10
|
+
import { api } from "$lib/api";
|
|
11
|
+
import { getI18n } from "$lib/i18n";
|
|
12
|
+
|
|
13
|
+
const i18n = getI18n();
|
|
14
|
+
let password = $state("");
|
|
15
|
+
let code = $state("");
|
|
16
|
+
let busy = $state(false);
|
|
17
|
+
let error = $state<string | null>(null);
|
|
18
|
+
let saved = $state(false);
|
|
19
|
+
let setup = $state<{ totpURI: string; backupCodes: string[] } | null>(null);
|
|
20
|
+
let qrDataUrl = $state("");
|
|
21
|
+
|
|
22
|
+
$effect(() => {
|
|
23
|
+
const uri = setup?.totpURI;
|
|
24
|
+
if (!uri) {
|
|
25
|
+
qrDataUrl = "";
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
QRCode.toDataURL(uri, { margin: 1, width: 176 })
|
|
29
|
+
.then((url) => (qrDataUrl = url))
|
|
30
|
+
.catch(() => (qrDataUrl = ""));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function begin(): Promise<void> {
|
|
34
|
+
busy = true;
|
|
35
|
+
error = null;
|
|
36
|
+
const { data, error: e } = await api.auth.twoFactor.enable({ password });
|
|
37
|
+
busy = false;
|
|
38
|
+
if (e) {
|
|
39
|
+
error = e.code === "INVALID_PASSWORD" ? i18n.t.setup2fa.wrongPassword : i18n.t.setup2fa.enableFailed;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
setup = { totpURI: data?.totpURI ?? "", backupCodes: (data?.backupCodes ?? []) as string[] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function activate(): Promise<void> {
|
|
46
|
+
busy = true;
|
|
47
|
+
error = null;
|
|
48
|
+
const { error: e } = await api.auth.twoFactor.verifyTotp({ code: code.trim() });
|
|
49
|
+
busy = false;
|
|
50
|
+
if (e) {
|
|
51
|
+
error = i18n.t.auth.twoFactorInvalidCode;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await goto("/admin", { invalidateAll: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function downloadCodes(): void {
|
|
58
|
+
if (!setup) return;
|
|
59
|
+
const blob = new Blob([setup.backupCodes.join("\n") + "\n"], { type: "text/plain" });
|
|
60
|
+
const url = URL.createObjectURL(blob);
|
|
61
|
+
const a = document.createElement("a");
|
|
62
|
+
a.href = url;
|
|
63
|
+
a.download = "backup-codes.txt";
|
|
64
|
+
a.click();
|
|
65
|
+
URL.revokeObjectURL(url);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function signOut(): Promise<void> {
|
|
69
|
+
await api.auth.signOut();
|
|
70
|
+
await goto("/login", { invalidateAll: true });
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<div class="flex min-h-svh items-center justify-center p-4">
|
|
75
|
+
<Card.Root class="w-full max-w-md">
|
|
76
|
+
<Card.Header>
|
|
77
|
+
<Card.Title>{i18n.t.setup2fa.title}</Card.Title>
|
|
78
|
+
<Card.Description>{i18n.t.setup2fa.subtitle}</Card.Description>
|
|
79
|
+
</Card.Header>
|
|
80
|
+
<Card.Content class="flex flex-col gap-4">
|
|
81
|
+
{#if error}
|
|
82
|
+
<Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>
|
|
83
|
+
{/if}
|
|
84
|
+
|
|
85
|
+
{#if !setup}
|
|
86
|
+
<div class="flex flex-col gap-2">
|
|
87
|
+
<Label for="pw">{i18n.t.setup2fa.passwordLabel}</Label>
|
|
88
|
+
<Input id="pw" type="password" bind:value={password} autocomplete="current-password" />
|
|
89
|
+
</div>
|
|
90
|
+
<Button disabled={busy || !password} onclick={begin}>{i18n.t.setup2fa.begin}</Button>
|
|
91
|
+
{:else}
|
|
92
|
+
<div class="flex flex-col gap-1">
|
|
93
|
+
<Label>{i18n.t.setup2fa.step1}</Label>
|
|
94
|
+
{#if qrDataUrl}
|
|
95
|
+
<img src={qrDataUrl} alt="TOTP QR code" width="176" height="176" class="rounded border bg-white p-2" />
|
|
96
|
+
{/if}
|
|
97
|
+
<code class="bg-muted overflow-x-auto rounded px-2 py-1 text-xs">{setup.totpURI}</code>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="flex flex-col gap-1">
|
|
101
|
+
<Label>{i18n.t.setup2fa.step2}</Label>
|
|
102
|
+
<p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
|
|
103
|
+
<div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
|
|
104
|
+
{#each setup.backupCodes as c (c)}<span>{c}</span>{/each}
|
|
105
|
+
</div>
|
|
106
|
+
<Button variant="outline" size="sm" class="w-fit" onclick={downloadCodes}>{i18n.t.account.downloadBackupCodes}</Button>
|
|
107
|
+
<label class="mt-1 flex items-center gap-2 text-sm">
|
|
108
|
+
<Checkbox bind:checked={saved} /> {i18n.t.setup2fa.savedConfirm}
|
|
109
|
+
</label>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="flex flex-col gap-2">
|
|
113
|
+
<Label for="code">{i18n.t.setup2fa.step3}</Label>
|
|
114
|
+
<Input id="code" bind:value={code} inputmode="numeric" autocomplete="one-time-code" />
|
|
115
|
+
</div>
|
|
116
|
+
<Button disabled={busy || !saved || !code} onclick={activate}>{i18n.t.setup2fa.activate}</Button>
|
|
117
|
+
{/if}
|
|
118
|
+
|
|
119
|
+
<Button variant="link" class="h-auto justify-start p-0 text-xs" onclick={signOut}>{i18n.t.setup2fa.signOut}</Button>
|
|
120
|
+
</Card.Content>
|
|
121
|
+
</Card.Root>
|
|
122
|
+
</div>
|
|
@@ -74,6 +74,112 @@ test("two-factor: enable with a real TOTP code and require it at sign-in @smoke"
|
|
|
74
74
|
await ctx2.dispose();
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test("two-factor: a backup code completes sign-in and is single-use @smoke", async ({ playwright }) => {
|
|
78
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
|
+
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
80
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
81
|
+
const email = `tf-bc-${Date.now()}@example.com`;
|
|
82
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
83
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "BC" } });
|
|
84
|
+
|
|
85
|
+
// Enable 2FA; the enable response includes the one-time backup codes.
|
|
86
|
+
const enable = await (await ctx.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
87
|
+
await ctx.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
88
|
+
const backupCodes = enable.backupCodes as string[];
|
|
89
|
+
expect(Array.isArray(backupCodes)).toBe(true);
|
|
90
|
+
expect(backupCodes.length).toBeGreaterThan(0);
|
|
91
|
+
const code = backupCodes[0]!;
|
|
92
|
+
|
|
93
|
+
// A backup code completes a challenged sign-in (no authenticator needed).
|
|
94
|
+
const ctx2 = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
95
|
+
const signin = await (await ctx2.post("/api/auth/sign-in/email", { data: { email, password: pw } })).json();
|
|
96
|
+
expect(signin.twoFactorRedirect).toBe(true);
|
|
97
|
+
const used = await ctx2.post("/api/auth/two-factor/verify-backup-code", { data: { code } });
|
|
98
|
+
expect(used.ok()).toBeTruthy();
|
|
99
|
+
const session = await (await ctx2.get("/api/auth/get-session")).json();
|
|
100
|
+
expect(session?.user?.email).toBe(email);
|
|
101
|
+
|
|
102
|
+
// The same code is single-use: a new challenge cannot reuse it.
|
|
103
|
+
const ctx3 = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
104
|
+
await ctx3.post("/api/auth/sign-in/email", { data: { email, password: pw } });
|
|
105
|
+
const reuse = await ctx3.post("/api/auth/two-factor/verify-backup-code", { data: { code } });
|
|
106
|
+
expect(reuse.ok()).toBeFalsy();
|
|
107
|
+
|
|
108
|
+
await ctx.dispose();
|
|
109
|
+
await ctx2.dispose();
|
|
110
|
+
await ctx3.dispose();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("two-factor: regenerating backup codes invalidates the old set @smoke", async ({ playwright }) => {
|
|
114
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
115
|
+
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
116
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
117
|
+
const email = `tf-rg-${Date.now()}@example.com`;
|
|
118
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
119
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RG" } });
|
|
120
|
+
const enable = await (await ctx.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
121
|
+
await ctx.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
122
|
+
const oldCode = (enable.backupCodes as string[])[0]!;
|
|
123
|
+
|
|
124
|
+
// Regenerating returns a fresh set; the previous codes are replaced.
|
|
125
|
+
const regen = await (await ctx.post("/api/auth/two-factor/generate-backup-codes", { data: { password: pw } })).json();
|
|
126
|
+
const newCodes = regen.backupCodes as string[];
|
|
127
|
+
expect(Array.isArray(newCodes)).toBe(true);
|
|
128
|
+
expect(newCodes.length).toBeGreaterThan(0);
|
|
129
|
+
expect(newCodes).not.toContain(oldCode);
|
|
130
|
+
|
|
131
|
+
// An old code no longer completes a challenge; a new one does.
|
|
132
|
+
const ctxOld = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
133
|
+
await ctxOld.post("/api/auth/sign-in/email", { data: { email, password: pw } });
|
|
134
|
+
expect((await ctxOld.post("/api/auth/two-factor/verify-backup-code", { data: { code: oldCode } })).ok()).toBeFalsy();
|
|
135
|
+
|
|
136
|
+
const ctxNew = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
137
|
+
await ctxNew.post("/api/auth/sign-in/email", { data: { email, password: pw } });
|
|
138
|
+
expect((await ctxNew.post("/api/auth/two-factor/verify-backup-code", { data: { code: newCodes[0]! } })).ok()).toBeTruthy();
|
|
139
|
+
|
|
140
|
+
await ctx.dispose();
|
|
141
|
+
await ctxOld.dispose();
|
|
142
|
+
await ctxNew.dispose();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("require-2fa: gates an un-enrolled user until they enrol @smoke", async ({ playwright }) => {
|
|
146
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
147
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
148
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
149
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
150
|
+
|
|
151
|
+
const user = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
152
|
+
const email = `rq-${Date.now()}@example.com`;
|
|
153
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
154
|
+
await user.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQ" } });
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
// Turn the policy on (admin; the toggle endpoint is allow-listed by the guard).
|
|
158
|
+
await admin.put("/api/account/settings", { data: { require2fa: true } });
|
|
159
|
+
// The guard caches for a few seconds — poll until the un-enrolled user is gated.
|
|
160
|
+
await expect(async () => {
|
|
161
|
+
expect((await user.get("/api/account/me")).status()).toBe(403);
|
|
162
|
+
}).toPass({ timeout: 8000 });
|
|
163
|
+
|
|
164
|
+
// Enrolling restores access.
|
|
165
|
+
const enable = await (await user.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
166
|
+
await user.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
167
|
+
expect((await user.get("/api/account/me")).status()).toBe(200);
|
|
168
|
+
} finally {
|
|
169
|
+
// Always turn it back off so the shared (un-enrolled) sessions aren't gated.
|
|
170
|
+
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
171
|
+
await expect(async () => {
|
|
172
|
+
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
173
|
+
await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
|
|
174
|
+
const status = (await probe.get("/api/account/me")).status();
|
|
175
|
+
await probe.dispose();
|
|
176
|
+
expect(status).toBe(200);
|
|
177
|
+
}).toPass({ timeout: 8000 });
|
|
178
|
+
await admin.dispose();
|
|
179
|
+
await user.dispose();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
77
183
|
test("multi-session holds several accounts and switches between them @smoke", async ({ playwright }) => {
|
|
78
184
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
185
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { anonState } from "../helpers/accounts";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
4
|
+
import { smsSinkReachable, clearSms, waitForSmsOtp } from "../helpers/sms";
|
|
5
|
+
|
|
6
|
+
// Each test drives a fresh disposable account (via a real UI login) so the shared
|
|
7
|
+
// admin/user sessions are never mutated.
|
|
8
|
+
test.use({ storageState: anonState });
|
|
9
|
+
|
|
10
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
11
|
+
const origin = { origin: base };
|
|
12
|
+
const PW = "Podokit3e-Str0ng!pw";
|
|
13
|
+
|
|
14
|
+
async function loginFresh(page: import("@playwright/test").Page, playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"], email: string): Promise<void> {
|
|
15
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
16
|
+
await api.post("/api/auth/sign-up/email", { data: { email, password: PW, name: "Acc" } });
|
|
17
|
+
await api.dispose();
|
|
18
|
+
await ready(page, `/login?redirect=${encodeURIComponent("/admin/account")}`);
|
|
19
|
+
await page.locator("#email").fill(email);
|
|
20
|
+
await page.locator("#password").fill(PW);
|
|
21
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
22
|
+
await expect(page).toHaveURL(/\/admin\/account/);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("account: register and verify a phone number @smoke", async ({ page, playwright }) => {
|
|
26
|
+
test.skip(!(await smsSinkReachable()), "sms sink unreachable");
|
|
27
|
+
await loginFresh(page, playwright, `acc-ph-${Date.now()}@example.com`);
|
|
28
|
+
const phoneInput = page.locator("#phone");
|
|
29
|
+
test.skip((await phoneInput.count()) === 0, "phone number feature disabled");
|
|
30
|
+
|
|
31
|
+
const phone = `+1555${String(Date.now()).slice(-7)}`;
|
|
32
|
+
await clearSms();
|
|
33
|
+
await phoneInput.fill(phone);
|
|
34
|
+
await page.getByRole("button", { name: "Send code" }).click();
|
|
35
|
+
const code = await waitForSmsOtp(phone);
|
|
36
|
+
await page.getByPlaceholder("Verification code").fill(code);
|
|
37
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
38
|
+
await expect(page.getByText("Phone verified")).toBeVisible();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("account: change email address @smoke", async ({ page, playwright }) => {
|
|
42
|
+
await loginFresh(page, playwright, `acc-em-${Date.now()}@example.com`);
|
|
43
|
+
await page.locator("#email").fill(`changed-${Date.now()}@example.com`);
|
|
44
|
+
await page.getByRole("button", { name: "Change email" }).click();
|
|
45
|
+
await expect(page.getByText(/Email address updated|approve the change/)).toBeVisible();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("account: delete my own account @smoke", async ({ page, playwright }) => {
|
|
49
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
50
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: PW } });
|
|
51
|
+
try {
|
|
52
|
+
// Account deletion is a server toggle (off by default) — enable it, then poll
|
|
53
|
+
// until the capability is live (config store has a short TTL).
|
|
54
|
+
await admin.put("/api/account/auth-config", { data: { server: { allowDelete: true } } });
|
|
55
|
+
await expect(async () => {
|
|
56
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
57
|
+
expect(caps.deleteAccount).toBe(true);
|
|
58
|
+
}).toPass({ timeout: 8000 });
|
|
59
|
+
|
|
60
|
+
await loginFresh(page, playwright, `acc-del-${Date.now()}@example.com`);
|
|
61
|
+
await page.getByRole("button", { name: "Danger zone" }).click();
|
|
62
|
+
await page.getByRole("button", { name: "Delete account" }).first().click();
|
|
63
|
+
await page.locator("#del-pw").fill(PW);
|
|
64
|
+
// The auth instance rebuilds a few seconds after allowDelete is enabled, so
|
|
65
|
+
// the first delete can be rejected — retry the confirm until it goes through.
|
|
66
|
+
await expect(async () => {
|
|
67
|
+
await page.getByRole("dialog").getByRole("button", { name: "Delete account" }).click();
|
|
68
|
+
await expect(page).toHaveURL(/\/login/, { timeout: 3000 });
|
|
69
|
+
}).toPass({ timeout: 15000 });
|
|
70
|
+
} finally {
|
|
71
|
+
await admin.put("/api/account/auth-config", { data: { server: { allowDelete: false } } });
|
|
72
|
+
await admin.dispose();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
@@ -43,3 +43,14 @@ test("reset-password without a token is disabled", async ({ page }) => {
|
|
|
43
43
|
await expect(page.getByRole("alert")).toBeVisible();
|
|
44
44
|
await expect(page.getByRole("button", { name: "Update password" })).toBeDisabled();
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
test("signup creates an account and enters the app @smoke", async ({ page }) => {
|
|
48
|
+
await ready(page, "/signup");
|
|
49
|
+
const email = `signup-${Date.now()}@example.com`;
|
|
50
|
+
await page.getByLabel("Name").fill("New User");
|
|
51
|
+
await page.getByLabel("Email").fill(email);
|
|
52
|
+
await page.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
|
|
53
|
+
await page.getByRole("button", { name: "Create account" }).click();
|
|
54
|
+
// Verification off → straight into the app; on → the verify-email page.
|
|
55
|
+
await expect(page).toHaveURL(/\/admin|\/verify-email/);
|
|
56
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { ready } from "../helpers/hydration";
|
|
3
|
+
import { USER } from "../helpers/accounts";
|
|
4
|
+
|
|
5
|
+
// admin storageState (project default)
|
|
6
|
+
|
|
7
|
+
test("admin can impersonate a user and stop @smoke", async ({ page }) => {
|
|
8
|
+
await ready(page, "/admin/users");
|
|
9
|
+
await page.locator("#toolbar-search").fill(USER.email);
|
|
10
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
11
|
+
|
|
12
|
+
const row = page.getByRole("row", { name: new RegExp(USER.email) });
|
|
13
|
+
await row.getByRole("button").click();
|
|
14
|
+
await page.getByRole("menuitem", { name: "Impersonate" }).click();
|
|
15
|
+
|
|
16
|
+
// Impersonating drops us into the app with a banner offering to stop.
|
|
17
|
+
await expect(page).toHaveURL(/\/admin(\/|$)/);
|
|
18
|
+
const stop = page.getByRole("button", { name: "Stop impersonating" });
|
|
19
|
+
await expect(stop).toBeVisible();
|
|
20
|
+
|
|
21
|
+
await stop.click();
|
|
22
|
+
await expect(page.getByRole("button", { name: "Stop impersonating" })).toHaveCount(0);
|
|
23
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { anonState } from "../helpers/accounts";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
4
|
+
import { mailpitReachable, clearMailpit, waitForOtp } from "../helpers/mailpit";
|
|
5
|
+
|
|
6
|
+
test.use({ storageState: anonState });
|
|
7
|
+
|
|
8
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
9
|
+
const origin = { origin: base };
|
|
10
|
+
|
|
11
|
+
test("login: magic link shows a sent confirmation @smoke", async ({ page, playwright }) => {
|
|
12
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
13
|
+
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
14
|
+
await api.dispose();
|
|
15
|
+
test.skip(!caps?.magicLink, "magic link disabled");
|
|
16
|
+
|
|
17
|
+
await ready(page, "/login");
|
|
18
|
+
await page.locator("#email").fill(`ml-${Date.now()}@example.com`);
|
|
19
|
+
await page.getByRole("button", { name: /sign-in link/i }).click();
|
|
20
|
+
await expect(page.getByTestId("magic-link-sent")).toBeVisible();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("login: an emailed one-time code signs a user in @smoke", async ({ page, playwright }) => {
|
|
24
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
25
|
+
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
26
|
+
test.skip(!caps?.emailOtp, "email otp disabled");
|
|
27
|
+
test.skip(!(await mailpitReachable()), "mailpit unreachable");
|
|
28
|
+
|
|
29
|
+
const email = `otp-${Date.now()}@example.com`;
|
|
30
|
+
await api.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "OTP" } });
|
|
31
|
+
await api.dispose();
|
|
32
|
+
await clearMailpit();
|
|
33
|
+
|
|
34
|
+
await ready(page, "/login");
|
|
35
|
+
await page.locator("#email").fill(email);
|
|
36
|
+
await page.getByRole("button", { name: "Email me a code" }).click();
|
|
37
|
+
const otp = await waitForOtp(email);
|
|
38
|
+
await page.getByLabel("One-time code").fill(otp);
|
|
39
|
+
await page.getByRole("button", { name: "Verify code" }).click();
|
|
40
|
+
await expect(page).toHaveURL(/\/admin/);
|
|
41
|
+
});
|
|
@@ -26,14 +26,18 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff @sm
|
|
|
26
26
|
const jwks = await (await page.request.get(disc.jwks_uri)).json();
|
|
27
27
|
expect(Array.isArray(jwks.keys) && jwks.keys.length).toBeTruthy();
|
|
28
28
|
|
|
29
|
-
// Dynamic client registration returns usable credentials.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
// Dynamic client registration returns usable credentials. Retry: right after
|
|
30
|
+
// the provider is enabled the auth instance may still be rebuilding.
|
|
31
|
+
let reg: { client_id?: string; client_secret?: string } = {};
|
|
32
|
+
await expect(async () => {
|
|
33
|
+
reg = await (
|
|
34
|
+
await page.request.post("/api/auth/oauth2/create-client", {
|
|
35
|
+
data: { client_name: "E2E RP", redirect_uris: [REDIRECT] },
|
|
36
|
+
headers,
|
|
37
|
+
})
|
|
38
|
+
).json();
|
|
39
|
+
expect(reg.client_id).toBeTruthy();
|
|
40
|
+
}).toPass({ timeout: 8000 });
|
|
37
41
|
expect(reg.client_secret).toBeTruthy();
|
|
38
42
|
|
|
39
43
|
// Authorize validates the client + PKCE and hands off to the consent page with
|
|
@@ -53,3 +53,38 @@ test("create an org with a non-ascii name, a parent, and a manager @smoke", asyn
|
|
|
53
53
|
await page.getByRole("row", { name: new RegExp(parent) }).getByRole("button", { name: "Delete" }).first().click();
|
|
54
54
|
await expect(page.getByText("Organization deleted").first()).toBeVisible();
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
test("organization manage: remove a member, invite one, and cancel the invite @smoke", async ({ page }) => {
|
|
58
|
+
await ready(page, "/admin/organizations");
|
|
59
|
+
const newBtn = page.getByRole("button", { name: "New organization" });
|
|
60
|
+
test.skip((await newBtn.count()) === 0, "organizations not enabled");
|
|
61
|
+
|
|
62
|
+
const name = `Mng ${Date.now()}`;
|
|
63
|
+
await newBtn.click();
|
|
64
|
+
await page.getByLabel("Name", { exact: true }).fill(name);
|
|
65
|
+
// Seed a specific non-owner member (the admin is always the owner, so pick the
|
|
66
|
+
// seeded user — .first() is order-dependent and could hit the owner).
|
|
67
|
+
await page.locator("label", { hasText: "user@example.com" }).getByRole("checkbox").click();
|
|
68
|
+
await page.getByRole("dialog").getByRole("button", { name: "Create", exact: true }).click();
|
|
69
|
+
await expect(page.getByText("Organization created").first()).toBeVisible();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await page.getByRole("row", { name: new RegExp(name) }).getByRole("button", { name: "Manage" }).click();
|
|
73
|
+
const dialog = page.getByRole("dialog");
|
|
74
|
+
|
|
75
|
+
// The seeded non-owner member is removable.
|
|
76
|
+
const remove = dialog.getByRole("button", { name: "Remove" }).first();
|
|
77
|
+
await expect(remove).toBeVisible();
|
|
78
|
+
await remove.click();
|
|
79
|
+
|
|
80
|
+
// Invite a member by email → pending invitation appears → cancel it.
|
|
81
|
+
await dialog.locator("#invite-email").fill(`invitee-${Date.now()}@example.com`);
|
|
82
|
+
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
|
|
83
|
+
await expect(page.getByText("Invitation sent").first()).toBeVisible();
|
|
84
|
+
await dialog.getByRole("button", { name: "Cancel", exact: true }).first().click();
|
|
85
|
+
} finally {
|
|
86
|
+
await page.keyboard.press("Escape").catch(() => undefined);
|
|
87
|
+
await page.getByRole("row", { name: new RegExp(name) }).getByRole("button", { name: "Delete" }).first().click();
|
|
88
|
+
await expect(page.getByText("Organization deleted").first()).toBeVisible();
|
|
89
|
+
}
|
|
90
|
+
});
|