feat(wu-003): add admin reset-password control to Settings > Users

Closes the UI gap in issue #77. The endpoint, resetPassword() and the
notification email have all existed since the feature was built; only
the control was missing, which is why the docs promised something the
screen did not offer.

The control renders on EVERY row, the admin's own included. It is
enabled only for accounts that sign in with a password and that are not
the signed-in admin; otherwise it is presented as unavailable WITH A
VISIBLE REASON. A control that simply vanishes is the confusion this
replaces, so silence was not an option.

No new control uses the `disabled` attribute. Unavailable is expressed
with aria-disabled, the element stays focusable, and the handler refuses
activation itself — a disabled button leaves the tab order, so a
keyboard or TalkBack user loses the element they just activated, and a
title tooltip explains nothing on a touch screen. The reporter is on
Android.

Two rules protect the credential, and both are enforced by the shape of
the state rather than by convention:

- An unconfirmed outcome REVEALS the password. If the request left the
  device and no answer came back, the server may well have stored it,
  making the revealed string the only copy in existence. Discarding it
  could leave an account holding a credential nobody knows.
- `password` and `message` are independent fields, so an error has
  nowhere to write that would replace a reveal. Revealed rows are also
  marked unavailable, so a second reset cannot be issued until Dismiss.

Offline is a definite state, not a lost response: the service worker
caches only GET /api/*, so a PATCH never leaves the browser. It is
checked before anything is generated, and reported as the certainty it
is.

The live region is rendered empty on first paint and only its text is
replaced, because a region inserted together with its content is not
reliably announced. It announces that a password appeared and where —
never the secret itself, which a polite region would speak aloud once,
unrepeatably, to whoever is in earshot.

DoD items verified (22):
- [x] 1-2. control on every row; enabled iff eligible and not self,
      else aria-disabled with a visible, aria-describedby reason
- [x] 3-4. PATCHes newPassword; "Resetting…" and no double-submit
- [x] 5-6. revealed as selectable text, announced; stays unavailable
- [x] 7. Copy confirms success; failure offers a manual-copy hint
- [x] 8-10. 4xx reveals nothing; offline definite; unknown REVEALS
- [x] 11-12. errors never replace a reveal; both announced
- [x] 13-14. 404 drops the row and its plaintext; rows isolated
- [x] 15-17. Dismiss clears and restores focus; 44px targets;
      activating an aria-disabled control fires no request
- [x] 18-19. victim login from a separate context, old password 401
      last; plaintext in no storage, no IndexedDB, no console
- [x] 20-22. CSS variables only, no hover-only; untruncated at 360px;
      declarative Gherkin with distinct victim emails

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files;
admin-users.spec.ts 28 passed; full e2e suite 126 passed.

The reviewer additionally mutation-tested the security core, each
mutation rebuilt and caught with clean attribution: revealing null on an
unconfirmed outcome, forcing the offline pre-flight false (which falls
into the unknown path and would reveal a password that never left the
browser), dropping the state merge in patchReset, swapping aria-disabled
for disabled (which breaks focus return), and deleting the activation
guard — the last proving the force-click assertions test the component
refusing rather than Playwright declining to click.

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
This commit is contained in:
Frederic Cabassut
2026-08-18 12:31:12 +02:00
committed by Fred Cabassut
co-authored by Claude Opus 5
parent a6f86ec1f0
commit ffde151b75
2 changed files with 860 additions and 27 deletions
+331 -27
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { User } from '$lib/types/index.js'; import type { User } from '$lib/types/index.js';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte'; import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
import { canResetPassword, passwordResetBlockedReason } from '$lib/utils/auth-methods.js';
import { generatePassword } from '$lib/utils/password.js';
let { data } = $props(); let { data } = $props();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
@@ -15,6 +17,213 @@
let createMsg = $state(''); let createMsg = $state('');
let createError = $state(false); let createError = $state(false);
/**
* One row's password-reset state.
*
* The generated plaintext lives here and nowhere else — never localStorage,
* sessionStorage, IndexedDB, a sync payload or a log — so navigating away is
* enough to lose it. State is per row rather than shared because two rows'
* outcomes would otherwise overwrite each other, and a password destroyed that
* way is unrecoverable.
*/
type ResetState = {
inFlight: boolean;
/** Revealed plaintext. Set on success and on an unconfirmed outcome. */
password: string | null;
/** Row message. Always rendered *alongside* `password`, never instead of it. */
message: string | null;
copyStatus: string | null;
/**
* Reason the server refused, kept even after the message is dismissed. This
* list snapshots `authProvider` at page load and never refetches, so a row can
* still look eligible after the account moved to OAuth; without this the
* control would stay available and invite the same failure forever.
*/
serverReason: string | null;
};
const IDLE_RESET: ResetState = {
inFlight: false,
password: null,
message: null,
copyStatus: null,
serverReason: null
};
const SELF_RESET_REASON = 'Change your own password in Profile.';
/**
* The request left the device but no answer came back, so the server may well
* have stored this password. Revealing it anyway is deliberate: it is the only
* copy that exists, and dropping it could leave the account holding a credential
* nobody knows.
*/
const UNCONFIRMED_MESSAGE =
'Could not confirm the reset. This password may or may not have been applied. Keep it — if the user cannot sign in, reset again once you are back online.';
/**
* Definite, not hedged: the service worker caches only `GET /api/*`, so a PATCH
* issued while offline never leaves the browser and nothing can have changed.
*/
const OFFLINE_MESSAGE = "You're offline — nothing was changed. Try again when reconnected.";
let resets = $state<Record<number, ResetState>>({});
let announcement = $state('');
const resetButtons: Record<number, HTMLButtonElement | null> = {};
function userLabel(user: User): string {
return user.displayName || user.email;
}
/**
* Updates the live region's text. The region itself is rendered empty from the
* first paint and never inserted alongside its content — a region added to the
* DOM together with its text is not reliably announced, least of all by
* TalkBack.
*/
function announce(text: string) {
announcement = text;
}
/** Why this row's password cannot be reset, or null when it can. */
function resetBlockedReason(user: User): string | null {
// Takes precedence over eligibility: an admin changes their own password in
// the profile flow, which verifies the current one.
if (user.id === data.user?.id) return SELF_RESET_REASON;
const serverReason = resets[user.id]?.serverReason;
if (serverReason) return serverReason;
if (!canResetPassword(user)) {
// auth-methods guarantees a non-empty reason whenever the check fails; the
// ?? only satisfies the nullable return type, as on the API route.
return passwordResetBlockedReason(user) ?? 'Password login is not available for this account.';
}
return null;
}
/**
* How one row's control presents itself. `unavailable` is expressed with
* `aria-disabled`, never the `disabled` attribute: a disabled button leaves the
* tab order, so a keyboard or screen-reader user loses the element they just
* activated, and a `title` tooltip explains nothing on a touch screen.
*/
function resetControl(user: User): {
reason: string | null;
inFlight: boolean;
unavailable: boolean;
} {
const state = resets[user.id];
const reason = resetBlockedReason(user);
const inFlight = state?.inFlight === true;
return {
reason,
inFlight,
unavailable: reason !== null || inFlight || state?.password != null
};
}
/** Merges a partial update into one row's state, leaving untouched fields alone. */
function patchReset(userId: number, patch: Partial<ResetState>) {
resets[userId] = { ...IDLE_RESET, ...resets[userId], ...patch };
}
function revealUnconfirmed(user: User, password: string) {
patchReset(user.id, { inFlight: false, password, message: UNCONFIRMED_MESSAGE });
announce(
`Could not confirm the password reset for ${userLabel(user)}. The generated password is shown in their row — keep it.`
);
}
async function resetUserPassword(user: User) {
// The control stays focusable while unavailable, so activation is refused
// here instead of by the browser.
if (resetControl(user).unavailable) return;
// Checked before the request, so the offline outcome is reported as the
// certainty it is rather than as a lost response.
if (navigator.onLine === false) {
patchReset(user.id, { message: OFFLINE_MESSAGE, copyStatus: null });
announce(`${userLabel(user)}: ${OFFLINE_MESSAGE}`);
return;
}
const password = generatePassword();
patchReset(user.id, { inFlight: true, message: null, copyStatus: null });
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: password })
});
if (res.ok) {
patchReset(user.id, { inFlight: false, password, message: null });
announce(
`New password generated for ${userLabel(user)}. It is shown in their row — copy it now, it cannot be shown again.`
);
return;
}
if (res.status === 404) {
users = users.filter((u) => u.id !== user.id);
// No account, no credential: drop the row's plaintext with the row.
delete resets[user.id];
announce(
`${userLabel(user)} no longer exists. The row was removed and no password was set.`
);
return;
}
const body = await res.json().catch(() => ({}));
const message = typeof body.message === 'string' && body.message ? body.message : null;
if (message !== null && res.status < 500) {
// A refusal the server states outright: nothing was stored, so no
// password is revealed and the reason sticks to the control.
patchReset(user.id, { inFlight: false, message, serverReason: message });
announce(`Password reset failed for ${userLabel(user)}. ${message}`);
return;
}
// A status without a readable reason says nothing about what the server did.
revealUnconfirmed(user, password);
} catch {
// The response was lost, not refused — see UNCONFIRMED_MESSAGE.
revealUnconfirmed(user, password);
}
}
async function copyPassword(user: User) {
const password = resets[user.id]?.password;
if (!password) return;
try {
// Absent outside a secure context, which a LAN-IP HTTP deployment is not,
// so the failure below has to be a working fallback rather than a dead end.
await navigator.clipboard.writeText(password);
patchReset(user.id, { copyStatus: 'Copied to clipboard.' });
announce(`Password for ${userLabel(user)} copied to clipboard.`);
} catch {
patchReset(user.id, {
copyStatus: 'Could not copy automatically — select the password and copy it by hand.'
});
announce(
`Could not copy the password for ${userLabel(user)}. Select it in their row and copy it by hand.`
);
}
}
function dismissReset(user: User) {
const serverReason = resets[user.id]?.serverReason ?? null;
// A server refusal explains the control's state, which outlives the message
// being dismissed here.
if (serverReason) resets[user.id] = { ...IDLE_RESET, serverReason };
else delete resets[user.id];
announce(`Password reset message for ${userLabel(user)} cleared.`);
// Never drop the admin on <body>: give the row's control the focus back.
resetButtons[user.id]?.focus();
}
async function createUser() { async function createUser() {
createMsg = ''; createMsg = '';
if (!newEmail) { if (!newEmail) {
@@ -70,6 +279,7 @@
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' }); const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) { if (res.ok) {
users = users.filter((u) => u.id !== userId); users = users.filter((u) => u.id !== userId);
delete resets[userId];
createMsg = 'User deleted'; createMsg = 'User deleted';
createError = false; createError = false;
} else { } else {
@@ -172,46 +382,140 @@
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]"> <section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2> <h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
<!-- Rendered empty on first paint; only its text is ever replaced, so both
reveals and per-row failures are announced from a region assistive tech
has already been watching. -->
<p
role="status"
aria-live="polite"
data-testid="reset-password-live"
class="sr-only"
>{announcement}</p>
<div class="space-y-3"> <div class="space-y-3">
{#each users as user (user.id)} {#each users as user (user.id)}
{@const reset = resets[user.id]}
{@const control = resetControl(user)}
<div <div
class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between" class="flex flex-col gap-3 rounded-sm border border-[var(--border-subtle)] p-4"
data-testid="user-row-{user.id}" data-testid="user-row-{user.id}"
> >
<div class="min-w-0"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="truncate font-medium text-[var(--text)]"> <div class="min-w-0">
{user.displayName || user.email} <p class="truncate font-medium text-[var(--text)]">
{#if user.role === 'admin'} {user.displayName || user.email}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span> {#if user.role === 'admin'}
{/if} <span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
</p> {/if}
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p> </p>
</div> <p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
<div class="flex flex-wrap items-center gap-2"> </div>
<button <div class="flex flex-wrap items-center gap-2">
onclick={() => toggleRole(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Make user' : 'Make admin'}
</button>
{#if user.id !== data.user?.id}
<button <button
onclick={() => revokeSessions(user)} onclick={() => toggleRole(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50" class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title="Force logout" title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
> >
Revoke {user.role === 'admin' ? 'Make user' : 'Make admin'}
</button> </button>
<!-- Offered on every row, the admin's own included: a control that
vanishes is the confusion this replaces. -->
<button <button
onclick={() => deleteUser(user.id)} bind:this={resetButtons[user.id]}
data-testid="delete-user-btn-{user.id}" onclick={() => resetUserPassword(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]" data-testid="reset-password-btn-{user.id}"
aria-disabled={control.unavailable}
aria-describedby={control.reason ? `reset-password-reason-${user.id}` : undefined}
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border px-3 text-xs transition-colors duration-150 ease-out {control.unavailable
? 'cursor-not-allowed border-dashed border-[var(--border-subtle)] text-[var(--text-muted)]'
: 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)] hover:bg-[var(--hover-wash)]/10'}"
> >
Delete {control.inFlight ? 'Resetting…' : 'Reset password'}
</button> </button>
{/if} {#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title="Force logout"
>
Revoke
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Delete
</button>
{/if}
</div>
</div> </div>
{#if control.reason || reset?.message || reset?.password}
<div class="flex flex-col gap-2">
{#if control.reason}
<!-- Visible text, not a tooltip: a title attribute is unreachable
by touch, and colour alone explains nothing. -->
<p
id="reset-password-reason-{user.id}"
data-testid="reset-password-reason-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{control.reason}</p>
{/if}
{#if reset?.message}
<p
data-testid="reset-password-error-{user.id}"
class="rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-2 text-xs text-[var(--error-text)]"
>{reset.message}</p>
{/if}
{#if reset?.password}
<!-- Rendered independently of the message above, so a failure can
never replace a password that is already the account's only
credential. -->
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border)] bg-[var(--bg-base)] p-3 shadow-[var(--card-shadow)]">
<p class="text-xs text-[var(--text-muted)]">
New password for {userLabel(user)} — shown once, not stored anywhere.
</p>
<code
data-testid="generated-password-{user.id}"
class="block break-all select-all text-sm text-[var(--text)]"
>{reset.password}</code>
</div>
{/if}
{#if reset?.message || reset?.password}
<!-- 44px targets with a wide gap: dismissing is irreversible, so a
mis-tap next to Copy would destroy a live credential. -->
<div class="flex flex-wrap items-center gap-3">
{#if reset?.password}
<button
onclick={() => copyPassword(user)}
data-testid="copy-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--border-subtle)] px-3 text-xs text-[var(--text)] transition-colors duration-150 ease-out hover:border-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
>
Copy
</button>
{/if}
<button
onclick={() => dismissReset(user)}
data-testid="dismiss-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--destructive)] px-3 text-xs text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
>
Dismiss
</button>
</div>
{/if}
{#if reset?.copyStatus}
<p
data-testid="copy-status-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{reset.copyStatus}</p>
{/if}
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
+529
View File
@@ -284,3 +284,532 @@ test.describe.serial('Admin — Password Reset', () => {
await page.request.delete(`/api/admin/users/${victimId}`); await page.request.delete(`/api/admin/users/${victimId}`);
}); });
}); });
/** Open the users settings page and wait for it to hydrate. */
async function openUsersPage(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
}
/**
* Reset a row's password through the UI and return the revealed plaintext.
* Trimmed because the reveal is markup-formatted text, not a form value.
*/
async function resetViaUi(
page: import('@playwright/test').Page,
victimId: number
): Promise<string> {
await page.getByTestId(`reset-password-btn-${victimId}`).click();
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
const revealed = (await reveal.textContent())?.trim() ?? '';
expect(revealed.length).toBeGreaterThanOrEqual(8);
return revealed;
}
test.describe('Admin — Password Reset UI', () => {
test('Scenario: Every user row offers a reset control, and an unavailable one says why', async ({
authenticatedPage: page
}) => {
// Given a password-auth user and an OAuth-only user exist
const eligibleId = await createVictim(page, 'ui-reason-pw@test.com', 'UI Reason Pw');
const oauthId = await createOAuthOnlyVictim(page, 'ui-reason-oauth@test.com', 'UI Reason OAuth');
const adminId = await currentAdminId(page);
// When the admin views the users list
const patchedIds: string[] = [];
page.on('request', (req) => {
if (req.method() === 'PATCH' && req.url().includes('/api/admin/users/')) {
patchedIds.push(req.url());
}
});
await openUsersPage(page);
// Then the control is offered on every row, including the admin's own
for (const id of [eligibleId, oauthId, adminId]) {
await expect(page.getByTestId(`reset-password-btn-${id}`)).toBeVisible();
}
// And it is available for the password-auth account
await expect(page.getByTestId(`reset-password-btn-${eligibleId}`)).toHaveAttribute(
'aria-disabled',
'false'
);
await expect(page.getByTestId(`reset-password-reason-${eligibleId}`)).toHaveCount(0);
// And the OAuth-only account explains in visible text why it is unavailable
const oauthBtn = page.getByTestId(`reset-password-btn-${oauthId}`);
await expect(oauthBtn).toHaveAttribute('aria-disabled', 'true');
const oauthReason = page.getByTestId(`reset-password-reason-${oauthId}`);
await expect(oauthReason).toBeVisible();
await expect(oauthReason).toContainText(/OAuth|password login/i);
await expect(oauthBtn).toHaveAttribute(
'aria-describedby',
`reset-password-reason-${oauthId}`
);
// And the admin's own row points them at their profile instead
const ownBtn = page.getByTestId(`reset-password-btn-${adminId}`);
await expect(ownBtn).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByTestId(`reset-password-reason-${adminId}`)).toContainText(/Profile/i);
// And an unavailable control stays reachable by keyboard
for (const btn of [oauthBtn, ownBtn]) {
await expect(btn).not.toHaveAttribute('disabled', /.*/);
await btn.focus();
await expect(btn).toBeFocused();
}
// And activating an unavailable control changes nothing. force: true skips
// Playwright's own aria-disabled actionability check, so what is under test is
// the handler refusing the activation, not the harness declining to send it.
await ownBtn.click({ force: true });
await oauthBtn.click({ force: true });
await expect(page.getByTestId(`generated-password-${adminId}`)).toHaveCount(0);
await expect(page.getByTestId(`generated-password-${oauthId}`)).toHaveCount(0);
expect(patchedIds).toEqual([]);
// Clean up
await page.request.delete(`/api/admin/users/${eligibleId}`);
await page.request.delete(`/api/admin/users/${oauthId}`);
});
test('Scenario: An account signs in with the password the admin generated, and its old one stops working', async ({
authenticatedPage: page,
browser
}) => {
// Given a password-auth user exists
const email = 'ui-roundtrip@test.com';
const victimId = await createVictim(page, email, 'UI Roundtrip');
await openUsersPage(page);
// And the announcement region is present and silent
await expect(page.getByTestId('reset-password-live')).toHaveText('');
// When the admin resets that account's password
const patch = page.waitForResponse(
(res) =>
res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'PATCH'
);
const revealed = await resetViaUi(page, victimId);
expect((await patch).status()).toBe(200);
// Then the reveal is announced
await expect(page.getByTestId('reset-password-live')).toContainText(/password/i);
// And that row's control is unavailable until the reveal is dismissed
await expect(page.getByTestId(`reset-password-btn-${victimId}`)).toHaveAttribute(
'aria-disabled',
'true'
);
// And the account signs in with the generated password, from a context whose
// cookie jar is separate from the admin's
const ctx = await browser.newContext();
const newLogin = await ctx.request.post('/api/auth/login', {
data: { email, password: revealed }
});
expect(newLogin.status()).toBe(200);
// And the password it had before is refused. 401 exactly: a 429 would mean the
// shared test IP is rate-limited, which proves nothing about the credential.
const oldLogin = await ctx.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(oldLogin.status()).toBe(401);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A revealed password is never persisted or logged', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists
const victimId = await createVictim(page, 'ui-nostore@test.com', 'UI No Store');
const consoleMessages: string[] = [];
page.on('console', (msg) => consoleMessages.push(msg.text()));
await openUsersPage(page);
// When the admin resets that account's password
const revealed = await resetViaUi(page, victimId);
// Then the plaintext reaches no persistent client storage
const leaks = await page.evaluate(async (secret: string) => {
const hits: string[] = [];
const scanWebStorage = (store: Storage, label: string) => {
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
if (key === null) continue;
if (key.includes(secret) || (store.getItem(key) ?? '').includes(secret)) {
hits.push(`${label}:${key}`);
}
}
};
scanWebStorage(localStorage, 'localStorage');
scanWebStorage(sessionStorage, 'sessionStorage');
const databases = (await indexedDB.databases?.()) ?? [];
for (const info of databases) {
if (!info.name) continue;
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(info.name as string);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
for (const storeName of Array.from(db.objectStoreNames)) {
const records = await new Promise<unknown[]>((resolve, reject) => {
const request = db.transaction(storeName, 'readonly').objectStore(storeName).getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
let serialised = '';
try {
serialised = JSON.stringify(records) ?? '';
} catch {
serialised = '';
}
if (serialised.includes(secret)) hits.push(`indexedDB:${info.name}/${storeName}`);
}
db.close();
}
return hits;
}, revealed);
expect(leaks).toEqual([]);
// And it appears in no console output
expect(consoleMessages.filter((m) => m.includes(revealed))).toEqual([]);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A revealed password is shown whole on a phone-sized screen', async ({
authenticatedPage: page
}) => {
// Given a password-auth user seen on a 360px-wide screen
const victimId = await createVictim(page, 'ui-narrow@test.com', 'UI Narrow');
await page.setViewportSize({ width: 360, height: 800 });
await openUsersPage(page);
// When the admin resets that account's password
const revealed = await resetViaUi(page, victimId);
// Then none of it is clipped away — truncating it would quietly corrupt the
// fallback of copying it by hand
const shown = await page.getByTestId(`generated-password-${victimId}`).evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
text: (el.textContent ?? '').trim()
}));
expect(shown.text).toBe(revealed);
expect(shown.clientWidth).toBeGreaterThan(0);
expect(shown.scrollWidth).toBeLessThanOrEqual(shown.clientWidth);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Copying a revealed password confirms that it was copied', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the clipboard is available
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const victimId = await createVictim(page, 'ui-copy-ok@test.com', 'UI Copy Ok');
await openUsersPage(page);
// When the admin resets the password and copies it
const revealed = await resetViaUi(page, victimId);
await page.getByTestId(`copy-password-btn-${victimId}`).click();
// Then the copy is confirmed and the clipboard holds the password
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/copied/i);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(revealed);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A refused clipboard leaves the password copyable by hand', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the clipboard refuses writes.
// defineProperty, not assignment: navigator.clipboard is a getter-only
// accessor, so assigning to it silently leaves the real API in place.
const victimId = await createVictim(page, 'ui-copy-denied@test.com', 'UI Copy Denied');
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
get: () => ({
writeText: () => Promise.reject(new DOMException('denied', 'NotAllowedError'))
})
});
});
await openUsersPage(page);
// When the admin resets the password and tries to copy it
const revealed = await resetViaUi(page, victimId);
await page.getByTestId(`copy-password-btn-${victimId}`).click();
// Then the failure is reported as a manual-copy fallback
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/by hand|manually/i);
// And the password is still shown, selectable, for copying by hand
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect((await reveal.textContent())?.trim()).toBe(revealed);
expect(await reveal.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Dismissing a reveal clears the password and leaves focus on that row', async ({
authenticatedPage: page
}) => {
// Given a password-auth user whose password was just reset
const victimId = await createVictim(page, 'ui-dismiss@test.com', 'UI Dismiss');
await openUsersPage(page);
await resetViaUi(page, victimId);
// When the admin dismisses the reveal
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
// Then the password is gone from the page
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toHaveCount(0);
// And focus is back on that row's control, which is available again
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await expect(btn).toBeFocused();
await expect(btn).toHaveAttribute('aria-disabled', 'false');
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Each of the three controls is large enough to hit on a phone', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists, seen on a phone-sized screen
const victimId = await createVictim(page, 'ui-target@test.com', 'UI Target');
await page.setViewportSize({ width: 360, height: 800 });
await openUsersPage(page);
// When the admin resets that account's password
await resetViaUi(page, victimId);
// Then every control in the flow meets the 44px minimum. Dismiss destroys the
// only copy of the credential, so a cramped target is a data-loss risk.
for (const testId of [
`reset-password-btn-${victimId}`,
`copy-password-btn-${victimId}`,
`dismiss-password-btn-${victimId}`
]) {
const box = await page.getByTestId(testId).boundingBox();
expect(box, testId).not.toBeNull();
expect(Math.min(box!.width, box!.height), testId).toBeGreaterThanOrEqual(44);
}
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A refused reset reports the server reason and reveals no password', async ({
authenticatedPage: page
}) => {
// Given a user the server refuses to reset — an account whose sign-in method
// changed after the list was loaded, so the row still looks eligible
const victimId = await createVictim(page, 'ui-rejected@test.com', 'UI Rejected');
const serverReason = 'Managed by SSO — password login is disabled for this account.';
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ message: serverReason })
});
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the refusal is shown in that row, and announced
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(serverReason);
await expect(page.getByTestId('reset-password-live')).toContainText(serverReason);
// And no password is revealed
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
// And the control no longer invites a retry that would fail the same way
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await expect(btn).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toContainText(serverReason);
// And dismissing the message keeps that explanation in place
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toHaveCount(0);
await expect(btn).toBeFocused();
await expect(btn).toHaveAttribute('aria-disabled', 'true');
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A reset whose outcome is unknown keeps the generated password', async ({
authenticatedPage: page
}) => {
// Given a user whose reset response never arrives, so the server may or may
// not have stored the new password
const victimId = await createVictim(page, 'ui-unknown@test.com', 'UI Unknown');
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.abort('connectionreset');
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the uncertainty is stated
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/may or may not/i
);
// And the generated password is kept, because it may be the account's only
// working credential and nothing else holds a copy of it
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toBeVisible();
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A reset attempted offline reports that nothing was changed', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the browser has no connection
const victimId = await createVictim(page, 'ui-offline@test.com', 'UI Offline');
await openUsersPage(page);
const patchRequests: string[] = [];
page.on('request', (req) => {
if (req.method() === 'PATCH') patchRequests.push(req.url());
});
await page.context().setOffline(true);
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the outcome is stated as definite: nothing left the device
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/offline.*nothing was changed/i
);
expect(patchRequests).toEqual([]);
// And no password is revealed, since none was set
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
// Restore connectivity — Playwright does not reliably emit the online event
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: One row\'s reset never touches another row', async ({
authenticatedPage: page
}) => {
// Given two password-auth users exist
const firstId = await createVictim(page, 'ui-isolation-a@test.com', 'UI Isolation A');
const secondId = await createVictim(page, 'ui-isolation-b@test.com', 'UI Isolation B');
await openUsersPage(page);
// When the first account's password is reset
const revealed = await resetViaUi(page, firstId);
// Then the second row shows neither a password nor a message
await expect(page.getByTestId(`generated-password-${secondId}`)).toHaveCount(0);
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toHaveCount(0);
await expect(page.getByTestId(`reset-password-btn-${secondId}`)).toHaveAttribute(
'aria-disabled',
'false'
);
// And when the second account's reset is refused
await page.route(`**/api/admin/users/${secondId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ message: 'Password login is not available for this account.' })
});
});
await page.getByTestId(`reset-password-btn-${secondId}`).click();
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toBeVisible();
// Then the first row keeps its password and stays free of that message
expect((await page.getByTestId(`generated-password-${firstId}`).textContent())?.trim()).toBe(
revealed
);
await expect(page.getByTestId(`reset-password-error-${firstId}`)).toHaveCount(0);
// Clean up
await page.unroute(`**/api/admin/users/${secondId}`);
await page.request.delete(`/api/admin/users/${firstId}`);
await page.request.delete(`/api/admin/users/${secondId}`);
});
test('Scenario: Resetting an account that no longer exists drops it from the list', async ({
authenticatedPage: page
}) => {
// Given a user who is deleted after the list was loaded
const victimId = await createVictim(page, 'ui-missing@test.com', 'UI Missing');
await openUsersPage(page);
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
expect((await page.request.delete(`/api/admin/users/${victimId}`)).status()).toBe(200);
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the row disappears and no password is left behind
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
});
test('Scenario: A reset in flight cannot be issued twice', async ({
authenticatedPage: page
}) => {
// Given a password-auth user whose reset response is slow to arrive
const victimId = await createVictim(page, 'ui-inflight@test.com', 'UI In Flight');
await openUsersPage(page);
let patchCount = 0;
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
patchCount++;
await new Promise((resolve) => setTimeout(resolve, 1500));
await route.continue();
});
// When the admin activates the control twice in a row
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await btn.click();
await expect(btn).toContainText(/Resetting/);
await expect(btn).toHaveAttribute('aria-disabled', 'true');
await btn.click({ force: true });
// Then only one reset is issued
await expect(page.getByTestId(`generated-password-${victimId}`)).toBeVisible();
expect(patchCount).toBe(1);
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
});