Skip to content

Commit 4bf9b29

Browse files
authored
harden auth account and locale mutation UX (#62)
* harden auth account locale mutations * announce form feedback * ignore swc plugin cache * settle mutation cleanup states * harden turnstile token cleanup * reuse locale labels in e2e
1 parent ae1f72a commit 4bf9b29

19 files changed

Lines changed: 575 additions & 266 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# next.js
1515
/.next/
1616
/out/
17+
/.swc/
1718

1819
# production
1920
/build

e2e/auth.spec.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { expect, test } from "@playwright/test";
2-
import { HOST_EMAIL, SEEDED_THREAD_ID, signIn } from "./helpers";
2+
import {
3+
HOST_EMAIL,
4+
SEEDED_PASSWORD,
5+
SEEDED_THREAD_ID,
6+
delayServerActionRequests,
7+
signIn,
8+
} from "./helpers";
39

410
test("public listing shows the seeded public listing and guest contact gate", async ({
511
page,
@@ -45,3 +51,43 @@ test("guest chats redirect preserves the requested chat path", async ({
4551
);
4652
await expect(page.getByTestId("sign-in-form")).toBeVisible();
4753
});
54+
55+
test("sign-up shows client validation feedback before submitting", async ({
56+
page,
57+
}) => {
58+
await page.goto("/sign-up");
59+
60+
await page.locator('input[name="first_name"]').fill("@@");
61+
await page.locator('input[name="email"]').fill("new-person@example.com");
62+
await page.locator('input[name="password"]').fill(SEEDED_PASSWORD);
63+
await page.locator('input[name="legal_agreement"]').check();
64+
await page.getByTestId("sign-up-submit").click();
65+
66+
await expect(page.getByTestId("sign-up-first-name-error")).toBeVisible();
67+
await expect(page.getByTestId("sign-up-form")).toContainText(
68+
/Please fix the above error/
69+
);
70+
});
71+
72+
test("sign-up shows pending feedback and preserves server errors", async ({
73+
page,
74+
}) => {
75+
await delayServerActionRequests(page);
76+
await page.goto("/sign-up");
77+
78+
await page.locator('input[name="first_name"]').fill("Avery");
79+
await page.locator('input[name="email"]').fill(HOST_EMAIL);
80+
await page.locator('input[name="password"]').fill(SEEDED_PASSWORD);
81+
await page.locator('input[name="legal_agreement"]').check();
82+
83+
const submitButton = page.getByTestId("sign-up-submit");
84+
const submitClick = submitButton.click();
85+
await expect(submitButton).toBeDisabled();
86+
await expect(submitButton).toHaveAttribute("aria-busy", "true");
87+
await submitClick;
88+
89+
await expect(page).toHaveURL(/\/sign-up\?.*error=/);
90+
await expect(page.getByTestId("sign-up-form")).toContainText(
91+
/already exists/i
92+
);
93+
});

e2e/i18n.spec.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,44 @@
11
import { expect, test } from "@playwright/test";
2-
import { HOST_EMAIL, delayProfileActionRequests, signIn } from "./helpers";
2+
import {
3+
HOST_EMAIL,
4+
delayProfileActionRequests,
5+
delayServerActionRequests,
6+
signIn,
7+
} from "./helpers";
8+
import { isValidLocale, localeLabels, type Locale } from "../src/i18n/config";
39

410
test("public footer locale switch refreshes the page locale", async ({
511
page,
612
}) => {
13+
await delayServerActionRequests(page);
714
await page.goto("/");
815

916
await expect(page.locator("html")).toHaveAttribute("lang", "en");
10-
await page.getByTestId("locale-picker-select").selectOption("de");
17+
const localeSelect = page.getByTestId("locale-picker-select");
18+
const localeChange = localeSelect.selectOption("de");
19+
await expect(localeSelect).toBeDisabled();
20+
await expect(localeSelect).toHaveAttribute("aria-busy", "true");
21+
await localeChange;
1122
await expect(page.locator("html")).toHaveAttribute("lang", "de", {
1223
timeout: 15_000,
1324
});
14-
await expect(page.getByTestId("locale-picker-select")).toHaveValue("de");
25+
await expect(localeSelect).toHaveValue("de");
1526
});
1627

1728
test("profile locale change persists after refresh", async ({ page }) => {
1829
await signIn(page, { email: HOST_EMAIL, redirectTo: "/profile" });
1930
await delayProfileActionRequests(page);
20-
await page.waitForTimeout(3_000);
2131

2232
await page.getByTestId("profile-account-language-edit").click();
2333
const languageInput = page.getByTestId("profile-account-language-input");
2434
const originalLanguage = await languageInput.inputValue();
25-
const updatedLanguage = originalLanguage === "de" ? "en" : "de";
35+
36+
if (!isValidLocale(originalLanguage)) {
37+
throw new Error(`Unexpected profile language value: ${originalLanguage}`);
38+
}
39+
40+
const originalLocale: Locale = originalLanguage;
41+
const updatedLanguage: Locale = originalLocale === "de" ? "en" : "de";
2642

2743
await languageInput.selectOption(updatedLanguage);
2844

@@ -31,7 +47,9 @@ test("profile locale change persists after refresh", async ({ page }) => {
3147
await expect(languageSubmit).toBeDisabled();
3248
await expect(languageSubmit).toHaveAttribute("aria-busy", "true");
3349
await languageClick;
34-
await page.waitForTimeout(2_000);
50+
await expect(page.getByTestId("profile-account-language-value")).toHaveText(
51+
localeLabels[updatedLanguage]
52+
);
3553

3654
await page.reload();
3755
await expect(page.locator("html")).toHaveAttribute("lang", updatedLanguage, {
@@ -41,11 +59,13 @@ test("profile locale change persists after refresh", async ({ page }) => {
4159
await page.getByTestId("profile-account-language-edit").click();
4260
await page
4361
.getByTestId("profile-account-language-input")
44-
.selectOption(originalLanguage);
62+
.selectOption(originalLocale);
4563
await page.getByTestId("profile-account-language-submit").click();
46-
await page.waitForTimeout(2_000);
64+
await expect(page.getByTestId("profile-account-language-value")).toHaveText(
65+
localeLabels[originalLocale]
66+
);
4767
await page.reload();
48-
await expect(page.locator("html")).toHaveAttribute("lang", originalLanguage, {
68+
await expect(page.locator("html")).toHaveAttribute("lang", originalLocale, {
4969
timeout: 15_000,
5070
});
5171
});

e2e/profile.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,34 @@ test("profile account actions show pending feedback and update the read view", a
6868
).toHaveValue(originalNewsletterPreference);
6969
await page.getByRole("button", { name: /cancel|abbrechen/i }).click();
7070
});
71+
72+
test("profile email edit shows pending and inline error feedback", async ({
73+
page,
74+
}) => {
75+
await signIn(page, { email: HOST_EMAIL, redirectTo: "/profile" });
76+
await delayProfileActionRequests(page);
77+
78+
await page.getByTestId("profile-account-email-edit").click();
79+
const emailInput = page.getByTestId("profile-account-email-input");
80+
await emailInput.fill(HOST_EMAIL);
81+
82+
const emailSubmit = page.getByTestId("profile-account-email-submit");
83+
const emailClick = emailSubmit.click();
84+
await expect(emailSubmit).toBeDisabled();
85+
await expect(emailSubmit).toHaveAttribute("aria-busy", "true");
86+
await emailClick;
87+
88+
await expect(page.getByTestId("profile-account-email-message")).toContainText(
89+
/already.*email/i
90+
);
91+
await expect(page.getByTestId("profile-account-email-form")).toBeVisible();
92+
});
93+
94+
test("danger dialogs focus the safe cancel action first", async ({ page }) => {
95+
await signIn(page, { email: HOST_EMAIL, redirectTo: "/profile" });
96+
97+
await page.getByRole("button", { name: "Delete account" }).click();
98+
99+
await expect(page.getByTestId("dialog-cancel-button")).toBeFocused();
100+
await expect(page.getByTestId("dialog-confirm-button")).not.toBeFocused();
101+
});

messages/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
},
236236
"turnstile": {
237237
"expired": "Die Verifizierung ist abgelaufen. Bitte schließe sie erneut ab.",
238+
"notReady": "Die Sicherheitsverifizierung wird noch geladen. Bitte versuche es gleich erneut.",
238239
"timeout": "Die Sicherheitsprüfung wurde nicht abgeschlossen. Deaktiviere Werbeblocker und versuche es erneut. Wenn es weiterhin fehlschlägt, probiere einen anderen Browser oder ein anderes Netzwerk.",
239240
"unsupported": "Dieser Browser kann die Sicherheitsprüfung nicht abschließen. Bitte probiere einen anderen Browser oder ein anderes Netzwerk.",
240241
"failed": "Die Sicherheitsverifizierung ist mit Fehler #{code} fehlgeschlagen. Bitte versuche es erneut oder nutze einen anderen Browser."

messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
},
236236
"turnstile": {
237237
"expired": "Verification expired. Please complete it again.",
238+
"notReady": "Security verification is still loading. Please try again in a moment.",
238239
"timeout": "Security check didn’t complete. Try disabling ad blockers and then try again. If it still fails, try a different browser or network.",
239240
"unsupported": "This browser can’t complete the security check. Please try a different browser or network.",
240241
"failed": "Security verification failed with error #{code}. Please try again or use a different browser."

messages/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
},
236236
"turnstile": {
237237
"expired": "La verificación caducó. Complétala de nuevo.",
238+
"notReady": "La verificación de seguridad todavía se está cargando. Inténtalo de nuevo en un momento.",
238239
"timeout": "La comprobación de seguridad no se completó. Prueba desactivar bloqueadores de anuncios y vuelve a intentarlo. Si sigue fallando, prueba otro navegador o red.",
239240
"unsupported": "Este navegador no puede completar la comprobación de seguridad. Prueba otro navegador o red.",
240241
"failed": "La verificación de seguridad falló con el error #{code}. Inténtalo de nuevo o usa otro navegador."

messages/fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
},
236236
"turnstile": {
237237
"expired": "La vérification a expiré. Veuillez la terminer de nouveau.",
238+
"notReady": "La vérification de sécurité est encore en cours de chargement. Veuillez réessayer dans un instant.",
238239
"timeout": "Le contrôle de sécurité ne s’est pas terminé. Essayez de désactiver les bloqueurs de publicité puis réessayez. Si cela échoue encore, essayez un autre navigateur ou un autre réseau.",
239240
"unsupported": "Ce navigateur ne peut pas effectuer le contrôle de sécurité. Veuillez essayer un autre navigateur ou un autre réseau.",
240241
"failed": "La vérification de sécurité a échoué avec l’erreur n°{code}. Veuillez réessayer ou utiliser un autre navigateur."

messages/pt-BR.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
},
236236
"turnstile": {
237237
"expired": "A verificação expirou. Conclua-a novamente.",
238+
"notReady": "A verificação de segurança ainda está carregando. Tente novamente em instantes.",
238239
"timeout": "A verificação de segurança não foi concluída. Tente desativar bloqueadores de anúncios e tente de novo. Se continuar falhando, use outro navegador ou outra rede.",
239240
"unsupported": "Este navegador não consegue concluir a verificação de segurança. Tente outro navegador ou outra rede.",
240241
"failed": "A verificação de segurança falhou com o erro #{code}. Tente novamente ou use outro navegador."

0 commit comments

Comments
 (0)