@podosoft/podokit 0.17.0 → 0.17.2
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/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +50 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +9 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +9 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +53 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- package/package.json +1 -1
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte
CHANGED
|
@@ -219,9 +219,16 @@
|
|
|
219
219
|
// Security — change password
|
|
220
220
|
let currentPassword = $state("");
|
|
221
221
|
let newPassword = $state("");
|
|
222
|
+
let confirmNewPassword = $state("");
|
|
222
223
|
let changing = $state(false);
|
|
223
224
|
async function changePassword(event: SubmitEvent): Promise<void> {
|
|
224
225
|
event.preventDefault();
|
|
226
|
+
// Checked here rather than by the API, which only sees one new password and
|
|
227
|
+
// has no way to know it is not the one that was meant.
|
|
228
|
+
if (newPassword !== confirmNewPassword) {
|
|
229
|
+
toast.error(i18n.t.account.passwordMismatch);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
225
232
|
changing = true;
|
|
226
233
|
const { error } = await api.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
|
|
227
234
|
changing = false;
|
|
@@ -230,6 +237,7 @@
|
|
|
230
237
|
toast.success(i18n.t.account.changed);
|
|
231
238
|
currentPassword = "";
|
|
232
239
|
newPassword = "";
|
|
240
|
+
confirmNewPassword = "";
|
|
233
241
|
await loadSessions();
|
|
234
242
|
}
|
|
235
243
|
}
|
|
@@ -376,6 +384,8 @@
|
|
|
376
384
|
}
|
|
377
385
|
|
|
378
386
|
// Sessions — this user's own devices
|
|
387
|
+
/** better-auth's code for "this session is older than `session.freshAge`". */
|
|
388
|
+
const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
|
|
379
389
|
type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
|
|
380
390
|
const PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
381
391
|
let sessions = $state<Session[]>([]);
|
|
@@ -389,8 +399,30 @@
|
|
|
389
399
|
{ key: "status", label: i18n.t.sessions.status },
|
|
390
400
|
{ key: "actions", label: "", class: "w-10" },
|
|
391
401
|
];
|
|
402
|
+
/**
|
|
403
|
+
* True when the session is too old to list sessions.
|
|
404
|
+
*
|
|
405
|
+
* better-auth guards `/list-sessions` (and `/unlink-account`) with its freshness
|
|
406
|
+
* middleware, and `session.freshAge` defaults to 24 hours. So for anyone who signed
|
|
407
|
+
* in yesterday — which is most people, most of the time — this call returns 403
|
|
408
|
+
* `SESSION_NOT_FRESH`. It is not a failure to report as one: the tab has to say what
|
|
409
|
+
* happened and what to do, or it is a blank table under an untranslated toast.
|
|
410
|
+
*/
|
|
411
|
+
let sessionsStale = $state(false);
|
|
412
|
+
async function signOutToRefresh(): Promise<void> {
|
|
413
|
+
busy = true;
|
|
414
|
+
await api.auth.signOut();
|
|
415
|
+
await goto("/login", { invalidateAll: true });
|
|
416
|
+
}
|
|
392
417
|
async function loadSessions(): Promise<void> {
|
|
393
418
|
const { data: res, error } = await api.auth.listSessions();
|
|
419
|
+
if (error?.code === SESSION_NOT_FRESH) {
|
|
420
|
+
// Explained in the panel below, not thrown at the corner of the screen.
|
|
421
|
+
sessionsStale = true;
|
|
422
|
+
sessions = [];
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
sessionsStale = false;
|
|
394
426
|
if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
|
|
395
427
|
// Current session first (users expect it on top; also keeps it on page 1),
|
|
396
428
|
// then newest to oldest.
|
|
@@ -633,6 +665,10 @@
|
|
|
633
665
|
<Label for="new">{i18n.t.account.newPassword}</Label>
|
|
634
666
|
<Input id="new" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
|
635
667
|
</div>
|
|
668
|
+
<div class="flex flex-col gap-2">
|
|
669
|
+
<Label for="new-confirm">{i18n.t.account.confirmNewPassword}</Label>
|
|
670
|
+
<Input id="new-confirm" type="password" bind:value={confirmNewPassword} required autocomplete="new-password" />
|
|
671
|
+
</div>
|
|
636
672
|
<Button type="submit" class="w-fit" disabled={changing}>{changing ? i18n.t.account.updating : i18n.t.account.update}</Button>
|
|
637
673
|
</form>
|
|
638
674
|
</Card.Content>
|
|
@@ -779,9 +815,21 @@
|
|
|
779
815
|
<Card.Root>
|
|
780
816
|
<Card.Header class="flex flex-row items-center justify-between gap-2">
|
|
781
817
|
<Card.Title>{i18n.t.sessions.title}</Card.Title>
|
|
782
|
-
<Button variant="outline" size="sm" disabled={busy || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
|
|
818
|
+
<Button variant="outline" size="sm" disabled={busy || sessionsStale || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
|
|
783
819
|
</Card.Header>
|
|
784
820
|
<Card.Content>
|
|
821
|
+
{#if sessionsStale}
|
|
822
|
+
<!-- The list is withheld by the server, so the card explains the rule and
|
|
823
|
+
offers the only thing that satisfies it. A toast would have scrolled
|
|
824
|
+
away and left an empty table behind it. -->
|
|
825
|
+
<div class="flex flex-col items-start gap-3" data-testid="sessions-stale">
|
|
826
|
+
<p class="text-muted-foreground text-sm">{i18n.t.sessions.staleTitle}</p>
|
|
827
|
+
<p class="text-muted-foreground text-xs">{i18n.t.sessions.staleHint}</p>
|
|
828
|
+
<Button variant="outline" size="sm" disabled={busy} onclick={signOutToRefresh} data-testid="sessions-stale-signin">
|
|
829
|
+
{i18n.t.sessions.staleAction}
|
|
830
|
+
</Button>
|
|
831
|
+
</div>
|
|
832
|
+
{:else}
|
|
785
833
|
<DataTable
|
|
786
834
|
columns={sessionsColumns}
|
|
787
835
|
rows={sessions}
|
|
@@ -803,6 +851,7 @@
|
|
|
803
851
|
</Table.Cell>
|
|
804
852
|
{/snippet}
|
|
805
853
|
</DataTable>
|
|
854
|
+
{/if}
|
|
806
855
|
</Card.Content>
|
|
807
856
|
</Card.Root>
|
|
808
857
|
{:else if section === "apiKeys"}
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"email": "Email",
|
|
54
54
|
"emailOrUsername": "Email or username",
|
|
55
55
|
"password": "Password",
|
|
56
|
+
"confirmPassword": "Confirm password",
|
|
57
|
+
"passwordMismatch": "Passwords do not match",
|
|
56
58
|
"forgot": "Forgot?",
|
|
57
59
|
"signIn": "Sign in",
|
|
58
60
|
"signingIn": "Signing in…",
|
|
@@ -93,6 +95,7 @@
|
|
|
93
95
|
"requestFailed": "Request failed",
|
|
94
96
|
"newTitle": "Set a new password",
|
|
95
97
|
"newPassword": "New password",
|
|
98
|
+
"confirmNewPassword": "Confirm new password",
|
|
96
99
|
"updatePassword": "Update password",
|
|
97
100
|
"updating": "Updating…",
|
|
98
101
|
"missingToken": "Missing or invalid reset token.",
|
|
@@ -480,7 +483,10 @@
|
|
|
480
483
|
"switchAccount": "Switch",
|
|
481
484
|
"switchFailed": "Failed to switch account",
|
|
482
485
|
"othersRevoked": "Signed out other sessions",
|
|
483
|
-
"total": "{count} sessions"
|
|
486
|
+
"total": "{count} sessions",
|
|
487
|
+
"staleTitle": "Sign in again to see where you are signed in.",
|
|
488
|
+
"staleHint": "For your security this list is only shown to a session that signed in recently.",
|
|
489
|
+
"staleAction": "Sign in again"
|
|
484
490
|
},
|
|
485
491
|
"adminSessions": {
|
|
486
492
|
"title": "User sessions",
|
|
@@ -528,6 +534,8 @@
|
|
|
528
534
|
"changePassword": "Change password",
|
|
529
535
|
"currentPassword": "Current password",
|
|
530
536
|
"newPassword": "New password",
|
|
537
|
+
"confirmNewPassword": "Confirm new password",
|
|
538
|
+
"passwordMismatch": "Passwords do not match",
|
|
531
539
|
"update": "Update password",
|
|
532
540
|
"updating": "Updating…",
|
|
533
541
|
"changed": "Password changed",
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"email": "이메일",
|
|
54
54
|
"emailOrUsername": "이메일 또는 사용자명",
|
|
55
55
|
"password": "비밀번호",
|
|
56
|
+
"confirmPassword": "비밀번호 확인",
|
|
57
|
+
"passwordMismatch": "비밀번호가 일치하지 않습니다",
|
|
56
58
|
"forgot": "비밀번호 찾기",
|
|
57
59
|
"signIn": "로그인",
|
|
58
60
|
"signingIn": "로그인 중…",
|
|
@@ -92,6 +94,7 @@
|
|
|
92
94
|
"requestFailed": "요청에 실패했습니다",
|
|
93
95
|
"newTitle": "새 비밀번호 설정",
|
|
94
96
|
"newPassword": "새 비밀번호",
|
|
97
|
+
"confirmNewPassword": "새 비밀번호 확인",
|
|
95
98
|
"updatePassword": "비밀번호 변경",
|
|
96
99
|
"updating": "변경 중…",
|
|
97
100
|
"missingToken": "재설정 토큰이 없거나 유효하지 않습니다.",
|
|
@@ -480,7 +483,10 @@
|
|
|
480
483
|
"switchAccount": "전환",
|
|
481
484
|
"switchFailed": "계정 전환에 실패했습니다",
|
|
482
485
|
"othersRevoked": "다른 세션을 모두 종료했습니다",
|
|
483
|
-
"total": "세션 {count}개"
|
|
486
|
+
"total": "세션 {count}개",
|
|
487
|
+
"staleTitle": "어디에서 로그인되어 있는지 보려면 다시 로그인하세요.",
|
|
488
|
+
"staleHint": "보안을 위해 이 목록은 최근에 로그인한 세션에만 보여줍니다.",
|
|
489
|
+
"staleAction": "다시 로그인"
|
|
484
490
|
},
|
|
485
491
|
"adminSessions": {
|
|
486
492
|
"title": "사용자 세션",
|
|
@@ -528,6 +534,8 @@
|
|
|
528
534
|
"changePassword": "비밀번호 변경",
|
|
529
535
|
"currentPassword": "현재 비밀번호",
|
|
530
536
|
"newPassword": "새 비밀번호",
|
|
537
|
+
"confirmNewPassword": "새 비밀번호 확인",
|
|
538
|
+
"passwordMismatch": "비밀번호가 일치하지 않습니다",
|
|
531
539
|
"update": "비밀번호 변경",
|
|
532
540
|
"updating": "변경 중…",
|
|
533
541
|
"changed": "비밀번호를 변경했습니다",
|
|
@@ -11,14 +11,21 @@
|
|
|
11
11
|
|
|
12
12
|
const i18n = getI18n();
|
|
13
13
|
let password = $state("");
|
|
14
|
+
let confirmPassword = $state("");
|
|
14
15
|
let error = $state<string | null>(null);
|
|
15
16
|
let loading = $state(false);
|
|
16
17
|
const token = $derived(page.url.searchParams.get("token") ?? "");
|
|
17
18
|
|
|
18
19
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
19
20
|
event.preventDefault();
|
|
20
|
-
loading = true;
|
|
21
21
|
error = null;
|
|
22
|
+
// The token is single-use: a mistyped password here is not a retry, it is
|
|
23
|
+
// another round trip through the mailbox to get a link that still works.
|
|
24
|
+
if (password !== confirmPassword) {
|
|
25
|
+
error = i18n.t.auth.passwordMismatch;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
loading = true;
|
|
22
29
|
const { error: authError } = await api.auth.resetPassword({ newPassword: password, token });
|
|
23
30
|
loading = false;
|
|
24
31
|
if (authError) {
|
|
@@ -45,6 +52,10 @@
|
|
|
45
52
|
<Label for="password">{i18n.t.auth.newPassword}</Label>
|
|
46
53
|
<Input id="password" type="password" bind:value={password} required autocomplete="new-password" />
|
|
47
54
|
</div>
|
|
55
|
+
<div class="flex flex-col gap-2">
|
|
56
|
+
<Label for="confirm-password">{i18n.t.auth.confirmNewPassword}</Label>
|
|
57
|
+
<Input id="confirm-password" type="password" bind:value={confirmPassword} required autocomplete="new-password" />
|
|
58
|
+
</div>
|
|
48
59
|
<Button type="submit" disabled={loading || !token}>{loading ? i18n.t.auth.updating : i18n.t.auth.updatePassword}</Button>
|
|
49
60
|
</form>
|
|
50
61
|
</Card.Content>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte
CHANGED
|
@@ -18,14 +18,22 @@
|
|
|
18
18
|
let name = $state("");
|
|
19
19
|
let email = $state("");
|
|
20
20
|
let password = $state("");
|
|
21
|
+
let confirmPassword = $state("");
|
|
21
22
|
let error = $state<string | null>(null);
|
|
22
23
|
let loading = $state(false);
|
|
23
24
|
const redirectTo = (): string => safeAuthRedirect(page.url.searchParams.get("redirect"));
|
|
24
25
|
|
|
25
26
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
26
27
|
event.preventDefault();
|
|
27
|
-
loading = true;
|
|
28
28
|
error = null;
|
|
29
|
+
// Before the request, not after: a typo here costs the account. Sign-up is the
|
|
30
|
+
// one password entry with no way back — the address is not verified yet, so the
|
|
31
|
+
// reset link that would rescue it goes somewhere the user cannot read.
|
|
32
|
+
if (password !== confirmPassword) {
|
|
33
|
+
error = i18n.t.auth.passwordMismatch;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
loading = true;
|
|
29
37
|
// callbackURL: where the verification link lands once confirmed (absolute so
|
|
30
38
|
// it returns to the web app, not the API origin). Ignored when verification is off.
|
|
31
39
|
const { error: authError } = await api.auth.signUp.email({
|
|
@@ -85,6 +93,10 @@
|
|
|
85
93
|
<Label for="password">{i18n.t.auth.password}</Label>
|
|
86
94
|
<Input id="password" type="password" bind:value={password} required autocomplete="new-password" />
|
|
87
95
|
</div>
|
|
96
|
+
<div class="flex flex-col gap-2">
|
|
97
|
+
<Label for="confirm-password">{i18n.t.auth.confirmPassword}</Label>
|
|
98
|
+
<Input id="confirm-password" type="password" bind:value={confirmPassword} required autocomplete="new-password" />
|
|
99
|
+
</div>
|
|
88
100
|
<Button type="submit" disabled={loading}>{loading ? i18n.t.auth.creating : i18n.t.auth.create}</Button>
|
|
89
101
|
</form>
|
|
90
102
|
</Card.Content>
|
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
export type Account = { name: string; email: string; password: string };
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The password the suite signs in with, per account.
|
|
5
|
+
*
|
|
6
|
+
* A test must never change an existing account's credentials. `seed.setup.ts` signs up
|
|
7
|
+
* idempotently and then signs in with these values, which is right on a stack the suite
|
|
8
|
+
* created — and wrong on every stack where the account already exists with a password of
|
|
9
|
+
* its own. An install bootstrapped with `admin:bootstrap` is exactly that: `admin@example.com`
|
|
10
|
+
* is a real account somebody logs in as, and resetting it to match the constant takes their
|
|
11
|
+
* login with it while the run goes green.
|
|
12
|
+
*
|
|
13
|
+
* So the constant is a DEFAULT and the environment wins:
|
|
14
|
+
*
|
|
15
|
+
* ```bash
|
|
16
|
+
* E2E_BASE_URL=https://app.example.com E2E_ADMIN_PASSWORD='…' npx playwright test …
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Without the override the run fails with `INVALID_EMAIL_OR_PASSWORD`, which is the correct
|
|
20
|
+
* outcome — a refusal, not a silent overwrite. Do not commit a real value here.
|
|
21
|
+
*/
|
|
22
|
+
function seedPassword(envVar: "E2E_ADMIN_PASSWORD" | "E2E_USER_PASSWORD"): string {
|
|
23
|
+
const value = process.env[envVar];
|
|
24
|
+
return value !== undefined && value.length > 0 ? value : "Podokit3e-Str0ng!pw";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ADMIN = { name: "Admin", email: "admin@example.com", password: seedPassword("E2E_ADMIN_PASSWORD") };
|
|
28
|
+
export const USER = { name: "Normal User", email: "user@example.com", password: seedPassword("E2E_USER_PASSWORD") };
|
|
4
29
|
export const adminState = "playwright/.auth/admin.json";
|
|
5
30
|
export const userState = "playwright/.auth/user.json";
|
|
6
31
|
export const userBaselineState = "playwright/.auth/user-baseline.json";
|
|
@@ -161,12 +161,64 @@ test("account security and sessions sub-navigation", async ({ page }) => {
|
|
|
161
161
|
await ready(page, "/admin/account");
|
|
162
162
|
await page.getByRole("button", { name: "Security" }).click();
|
|
163
163
|
await expect(page.getByLabel("Current password")).toBeVisible();
|
|
164
|
+
// `exact` because "Confirm new password" also contains "New password".
|
|
165
|
+
await expect(page.getByLabel("New password", { exact: true })).toBeVisible();
|
|
166
|
+
await expect(page.getByLabel("Confirm new password")).toBeVisible();
|
|
164
167
|
await page.getByRole("button", { name: "Sessions" }).click();
|
|
165
168
|
await expect(page.getByText("Active sessions")).toBeVisible();
|
|
166
169
|
await expect(page.getByText("Current", { exact: true })).toBeVisible();
|
|
167
170
|
await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeVisible();
|
|
168
171
|
});
|
|
169
172
|
|
|
173
|
+
/**
|
|
174
|
+
* ⚠ THE SUITE ALWAYS SIGNS IN FIRST, WHICH IS WHY THIS NEEDED A MOCK.
|
|
175
|
+
*
|
|
176
|
+
* better-auth guards `/list-sessions` with its freshness middleware and
|
|
177
|
+
* `session.freshAge` defaults to 24 hours, so the endpoint returns 403
|
|
178
|
+
* `SESSION_NOT_FRESH` to anyone who signed in yesterday. The seeded session is
|
|
179
|
+
* minutes old, so the test above passes for every run while the screen is broken for
|
|
180
|
+
* real users — the tab showed an empty table under an untranslated "Session is not
|
|
181
|
+
* fresh" toast. Forcing the response is the only way to reach the state the suite's
|
|
182
|
+
* own setup prevents.
|
|
183
|
+
*/
|
|
184
|
+
test("sessions tab explains a session that is too old to list them", async ({ page }) => {
|
|
185
|
+
await page.route("**/api/auth/list-sessions", (route) =>
|
|
186
|
+
route.fulfill({
|
|
187
|
+
status: 403,
|
|
188
|
+
contentType: "application/json",
|
|
189
|
+
body: JSON.stringify({ message: "Session is not fresh", code: "SESSION_NOT_FRESH" }),
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await ready(page, "/admin/account");
|
|
194
|
+
await page.getByRole("button", { name: "Sessions" }).click();
|
|
195
|
+
|
|
196
|
+
const panel = page.getByTestId("sessions-stale");
|
|
197
|
+
await expect(panel).toBeVisible();
|
|
198
|
+
// The raw upstream string must not be what a person reads.
|
|
199
|
+
await expect(page.getByText("Session is not fresh")).toHaveCount(0);
|
|
200
|
+
// And the one action that satisfies the rule is offered rather than described.
|
|
201
|
+
await expect(page.getByTestId("sessions-stale-signin")).toBeVisible();
|
|
202
|
+
// Revoking is meaningless while the list is withheld, so it is not offered either.
|
|
203
|
+
await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeDisabled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Changing a password is the one form here whose mistake is silent: the change
|
|
207
|
+
// succeeds, and the person finds out at the next sign-in with a password they
|
|
208
|
+
// cannot reproduce.
|
|
209
|
+
test("changing a password refuses a mistyped confirmation", async ({ page }) => {
|
|
210
|
+
await ready(page, "/admin/account");
|
|
211
|
+
await page.getByRole("button", { name: "Security" }).click();
|
|
212
|
+
await page.getByLabel("Current password").fill(ADMIN.password);
|
|
213
|
+
await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
|
|
214
|
+
await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pwX");
|
|
215
|
+
// The control: the message must not already be on screen, or this test would pass
|
|
216
|
+
// against a form that shows it unconditionally.
|
|
217
|
+
await expect(page.getByText("Passwords do not match")).toBeHidden();
|
|
218
|
+
await page.getByRole("button", { name: "Update password" }).click();
|
|
219
|
+
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
|
220
|
+
});
|
|
221
|
+
|
|
170
222
|
test("account nav shows the core sections", async ({ page }) => {
|
|
171
223
|
await ready(page, "/admin/account");
|
|
172
224
|
await expect(page.getByRole("button", { name: "Profile", exact: true })).toBeVisible();
|
|
@@ -182,7 +234,7 @@ test("two-factor setup shows a scannable QR code when enabled", async ({ page })
|
|
|
182
234
|
await expect(page.getByRole("button", { name: "Enable", exact: true })).toBeVisible();
|
|
183
235
|
// Start setup (password + Enable) and confirm the QR renders. Stop before
|
|
184
236
|
// verifying so 2FA stays inactive and the shared admin session keeps working.
|
|
185
|
-
await page.locator("#tf-on-pw").fill(
|
|
237
|
+
await page.locator("#tf-on-pw").fill(ADMIN.password);
|
|
186
238
|
await page.getByRole("button", { name: "Enable", exact: true }).click();
|
|
187
239
|
await expect(page.getByRole("img", { name: "TOTP QR code" })).toBeVisible();
|
|
188
240
|
});
|
|
@@ -47,6 +47,8 @@ test("authentication return targets reject external and recursive redirects", ()
|
|
|
47
47
|
test("signup page renders", async ({ page }) => {
|
|
48
48
|
await page.goto("/signup");
|
|
49
49
|
await expect(page.getByLabel("Name")).toBeVisible();
|
|
50
|
+
await expect(page.getByLabel("Password", { exact: true })).toBeVisible();
|
|
51
|
+
await expect(page.getByLabel("Confirm password")).toBeVisible();
|
|
50
52
|
await expect(page.getByRole("button", { name: "Create account" })).toBeVisible();
|
|
51
53
|
});
|
|
52
54
|
|
|
@@ -120,8 +122,31 @@ test("signup creates an account and enters the app @smoke", async ({ page }) =>
|
|
|
120
122
|
const email = `signup-${Date.now()}@example.com`;
|
|
121
123
|
await page.getByLabel("Name").fill("New User");
|
|
122
124
|
await page.getByLabel("Email").fill(email);
|
|
123
|
-
|
|
125
|
+
// `exact` because "Confirm password" also contains "Password".
|
|
126
|
+
await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
|
|
127
|
+
await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
|
|
124
128
|
await page.getByRole("button", { name: "Create account" }).click();
|
|
125
129
|
// Verification off → straight into the app; on → the verify-email page.
|
|
126
130
|
await expect(page).toHaveURL((url) => url.pathname === "/" || url.pathname === "/verify-email");
|
|
127
131
|
});
|
|
132
|
+
|
|
133
|
+
// A typo here is not recoverable the way a mistyped login is: the address has not
|
|
134
|
+
// been verified, so the reset link that would rescue the account is sent to an
|
|
135
|
+
// inbox the person cannot open.
|
|
136
|
+
test("signup refuses a mistyped password confirmation", async ({ page }) => {
|
|
137
|
+
await ready(page, "/signup");
|
|
138
|
+
const email = `signup-mismatch-${Date.now()}@example.com`;
|
|
139
|
+
await page.getByLabel("Name").fill("New User");
|
|
140
|
+
await page.getByLabel("Email").fill(email);
|
|
141
|
+
await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
|
|
142
|
+
await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pwX");
|
|
143
|
+
await page.getByRole("button", { name: "Create account" }).click();
|
|
144
|
+
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
|
145
|
+
// Still on the form, and no account was created for that address.
|
|
146
|
+
await expect(page).toHaveURL(/\/signup/);
|
|
147
|
+
const probe = await page.context().request.post("/api/auth/sign-in/email", {
|
|
148
|
+
data: { email, password: "Podokit3e-Str0ng!pw" },
|
|
149
|
+
headers: { origin: new URL(page.url()).origin },
|
|
150
|
+
});
|
|
151
|
+
expect(probe.ok()).toBeFalsy();
|
|
152
|
+
});
|
|
@@ -31,7 +31,16 @@ test("forgot password: request, open the emailed link, set a new password, sign
|
|
|
31
31
|
await page.goto(await waitForLink(email));
|
|
32
32
|
await page.waitForLoadState("load");
|
|
33
33
|
await page.waitForTimeout(400);
|
|
34
|
-
|
|
34
|
+
// `exact` because "Confirm new password" also contains "New password".
|
|
35
|
+
await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
|
|
36
|
+
// A mismatch is refused, and the single-use token survives it: the same page
|
|
37
|
+
// still accepts the corrected pair below.
|
|
38
|
+
await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pwX");
|
|
39
|
+
await page.getByRole("button", { name: "Update password" }).click();
|
|
40
|
+
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
|
41
|
+
await expect(page).not.toHaveURL(/\/login/);
|
|
42
|
+
|
|
43
|
+
await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pw");
|
|
35
44
|
await page.getByRole("button", { name: "Update password" }).click();
|
|
36
45
|
await expect(page).toHaveURL(/\/login/);
|
|
37
46
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
-
import { anonState } from "../helpers/accounts";
|
|
2
|
+
import { ADMIN, USER, anonState } from "../helpers/accounts";
|
|
3
3
|
import { totpCode } from "../helpers/totp";
|
|
4
4
|
|
|
5
5
|
test.use({ storageState: anonState });
|
|
@@ -59,7 +59,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
|
|
|
59
59
|
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
60
60
|
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
61
61
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
62
|
-
await admin.post("/api/auth/sign-in/email", { data: { email:
|
|
62
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
63
63
|
|
|
64
64
|
const email = `rq-ui-${Date.now()}@example.com`;
|
|
65
65
|
const pw = "Podokit3e-Str0ng!pw";
|
|
@@ -97,7 +97,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
|
|
|
97
97
|
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
98
98
|
await expect(async () => {
|
|
99
99
|
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
100
|
-
await probe.post("/api/auth/sign-in/email", { data: { email:
|
|
100
|
+
await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
|
|
101
101
|
const status = (await probe.get("/api/account/me")).status();
|
|
102
102
|
await probe.dispose();
|
|
103
103
|
expect(status).toBe(200);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|