@podosoft/podokit 0.17.0 → 0.17.1

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.
@@ -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
  }
@@ -633,6 +641,10 @@
633
641
  <Label for="new">{i18n.t.account.newPassword}</Label>
634
642
  <Input id="new" type="password" bind:value={newPassword} required autocomplete="new-password" />
635
643
  </div>
644
+ <div class="flex flex-col gap-2">
645
+ <Label for="new-confirm">{i18n.t.account.confirmNewPassword}</Label>
646
+ <Input id="new-confirm" type="password" bind:value={confirmNewPassword} required autocomplete="new-password" />
647
+ </div>
636
648
  <Button type="submit" class="w-fit" disabled={changing}>{changing ? i18n.t.account.updating : i18n.t.account.update}</Button>
637
649
  </form>
638
650
  </Card.Content>
@@ -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.",
@@ -528,6 +531,8 @@
528
531
  "changePassword": "Change password",
529
532
  "currentPassword": "Current password",
530
533
  "newPassword": "New password",
534
+ "confirmNewPassword": "Confirm new password",
535
+ "passwordMismatch": "Passwords do not match",
531
536
  "update": "Update password",
532
537
  "updating": "Updating…",
533
538
  "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": "재설정 토큰이 없거나 유효하지 않습니다.",
@@ -528,6 +531,8 @@
528
531
  "changePassword": "비밀번호 변경",
529
532
  "currentPassword": "현재 비밀번호",
530
533
  "newPassword": "새 비밀번호",
534
+ "confirmNewPassword": "새 비밀번호 확인",
535
+ "passwordMismatch": "비밀번호가 일치하지 않습니다",
531
536
  "update": "비밀번호 변경",
532
537
  "updating": "변경 중…",
533
538
  "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>
@@ -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
- export const ADMIN = { name: "Admin", email: "admin@example.com", password: "Podokit3e-Str0ng!pw" };
3
- export const USER = { name: "Normal User", email: "user@example.com", password: "Podokit3e-Str0ng!pw" };
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,31 @@ 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
+ // Changing a password is the one form here whose mistake is silent: the change
174
+ // succeeds, and the person finds out at the next sign-in with a password they
175
+ // cannot reproduce.
176
+ test("changing a password refuses a mistyped confirmation", async ({ page }) => {
177
+ await ready(page, "/admin/account");
178
+ await page.getByRole("button", { name: "Security" }).click();
179
+ await page.getByLabel("Current password").fill(ADMIN.password);
180
+ await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
181
+ await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pwX");
182
+ // The control: the message must not already be on screen, or this test would pass
183
+ // against a form that shows it unconditionally.
184
+ await expect(page.getByText("Passwords do not match")).toBeHidden();
185
+ await page.getByRole("button", { name: "Update password" }).click();
186
+ await expect(page.getByText("Passwords do not match")).toBeVisible();
187
+ });
188
+
170
189
  test("account nav shows the core sections", async ({ page }) => {
171
190
  await ready(page, "/admin/account");
172
191
  await expect(page.getByRole("button", { name: "Profile", exact: true })).toBeVisible();
@@ -182,7 +201,7 @@ test("two-factor setup shows a scannable QR code when enabled", async ({ page })
182
201
  await expect(page.getByRole("button", { name: "Enable", exact: true })).toBeVisible();
183
202
  // Start setup (password + Enable) and confirm the QR renders. Stop before
184
203
  // verifying so 2FA stays inactive and the shared admin session keeps working.
185
- await page.locator("#tf-on-pw").fill("Podokit3e-Str0ng!pw");
204
+ await page.locator("#tf-on-pw").fill(ADMIN.password);
186
205
  await page.getByRole("button", { name: "Enable", exact: true }).click();
187
206
  await expect(page.getByRole("img", { name: "TOTP QR code" })).toBeVisible();
188
207
  });
@@ -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
- await page.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
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
- await page.getByLabel("New password").fill("Podokit3e-N3wStr0ng!pw");
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: "admin@example.com", password: "Podokit3e-Str0ng!pw" } });
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: "user@example.com", password: "Podokit3e-Str0ng!pw" } });
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.0",
3
+ "version": "0.17.1",
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": {