diff --git a/web/app/(app)/dashboard/(components)/change-password-form.tsx b/web/app/(app)/dashboard/(components)/change-password-form.tsx
index 599f821..277b9e7 100644
--- a/web/app/(app)/dashboard/(components)/change-password-form.tsx
+++ b/web/app/(app)/dashboard/(components)/change-password-form.tsx
@@ -20,9 +20,11 @@ const changePasswordSchema = z
newPassword: z
.string()
.min(8, { message: 'Password must be at least 8 characters long' }),
+ // Matches newPassword's floor. At 4 a five-character confirmation failed
+ // with "Passwords must match" instead of the real length problem.
confirmPassword: z
.string()
- .min(4, { message: 'Please confirm your password' }),
+ .min(1, { message: 'Please confirm your password' }),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.confirmPassword) {
@@ -84,14 +86,30 @@ export default function ChangePasswordForm() {
>
+ {/* Without autoComplete, password managers cannot tell these three
+ boxes apart and fill the saved password into the wrong one. */}
{changePasswordForm.formState.errors.oldPassword && (
-
)}
diff --git a/web/app/(app)/dashboard/(components)/delete-account-form.tsx b/web/app/(app)/dashboard/(components)/delete-account-form.tsx
index 04467bc..c43aaf9 100644
--- a/web/app/(app)/dashboard/(components)/delete-account-form.tsx
+++ b/web/app/(app)/dashboard/(components)/delete-account-form.tsx
@@ -120,60 +120,77 @@ export default function DeleteAccountForm() {
Delete Account
+ {/* Description holds prose only. The form controls used to live
+ inside it, and Radix points the dialog's aria-describedby here,
+ so opening the dialog read the labels, placeholders and all
+ back as one long description string. */}
Are you sure you want to delete your account? This action:
-
+
Cannot be undone
Will permanently delete all your data
Will cancel all active subscriptions
Will remove access to all services
+
+
+
+
+ {/* htmlFor pointed at an id this field never had, so the label
+ was decorative and the textarea was announced unlabelled. */}
-
Please type your email address to confirm:
-
+
+
setDeleteConfirmEmail(e.target.value)}
/>
+
-
-
- {turnstileError && (
-
{turnstileError}
- )}
-
-
- {requestAccountDeletionError && (
-
- {(requestAccountDeletionError as any).response?.data
- ?.message ||
- requestAccountDeletionError.message ||
- 'Failed to submit account deletion request'}
+
+
+ {turnstileError && (
+
+ {turnstileError}
)}
+
+
+ {requestAccountDeletionError && (
+
+ {(requestAccountDeletionError as any).response?.data?.message ||
+ requestAccountDeletionError.message ||
+ 'Failed to submit account deletion request'}
+
+ {/* Visible only on focus. Without it, keyboard users tab through the
+ whole sidebar on every page before reaching the content. */}
+
+ Skip to content
+
{/* Desktop sidebar, sits below the sticky app header (h-14). */}
@@ -42,7 +50,9 @@ export default function DashboardLayout({
setSearchOpen(true)} />
-
- {children}
+
+ {children}
+
{/* Inside the sidebar-offset column so the fixed sidebar cannot paint
over it, and padded clear of the fixed mobile tab bar. */}
@@ -93,7 +105,10 @@ export default function DashboardLayout({
{/* Mobile bottom tab bar (max 4 items; the rest are desktop/palette only). */}
-
+
{mobileNavItems.map((item) => (
-
+
Log in
diff --git a/web/components/shared/footer.tsx b/web/components/shared/footer.tsx
index b7cf54e..b0d0f99 100644
--- a/web/components/shared/footer.tsx
+++ b/web/components/shared/footer.tsx
@@ -40,7 +40,10 @@ export default function Footer() {
{/* Stacked on mobile: wrapped inline links produced a ragged two-line
block that was hard to scan and gave small tap targets. */}
-
+
{links.map((link) => (
{
await expect(page.getByRole('heading', { name: 'Pro' })).toBeVisible()
})
+ // Password managers key off autoComplete to tell the three password boxes
+ // apart. Without it they fill the saved password into the wrong field.
+ test('password fields declare their autocomplete roles', async ({
+ page,
+ context,
+ }) => {
+ await authenticate(context)
+ await mockApi(page)
+ await page.goto('/dashboard/account/security')
+
+ await expect(page.getByLabel('Old Password')).toHaveAttribute(
+ 'autocomplete',
+ 'current-password'
+ )
+ await expect(page.getByLabel('New Password')).toHaveAttribute(
+ 'autocomplete',
+ 'new-password'
+ )
+ await expect(page.getByLabel('Confirm Password')).toHaveAttribute(
+ 'autocomplete',
+ 'new-password'
+ )
+ })
+
+ // The most destructive action in the app had a label pointing at an id that
+ // did not exist and an email field with no label at all.
+ test('the delete-account fields are reachable by their labels', async ({
+ page,
+ context,
+ }) => {
+ await authenticate(context)
+ await mockApi(page)
+ await page.goto('/dashboard/account/security')
+
+ await page.getByRole('button', { name: 'Delete Account' }).click()
+ const dialog = page.getByRole('dialog')
+
+ await dialog.getByLabel('Reason for deletion').fill('Testing labels')
+ await dialog
+ .getByLabel('Type your email address to confirm')
+ .fill('test@example.com')
+
+ await expect(dialog.getByLabel('Reason for deletion')).toHaveValue(
+ 'Testing labels'
+ )
+ })
+
test('security deep link survives refresh and separates the danger zone', async ({
page,
context,