forked from mirror/crumbs-original
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d9bd737ab | ||
|
|
4812eaf1ed | ||
|
|
afce024cac | ||
|
|
ffde151b75 | ||
|
|
a6f86ec1f0 | ||
|
|
51dd844f96 | ||
|
|
0d21f7efda |
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.31.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.32.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+22
-3
@@ -10,7 +10,24 @@ On first launch, Crumbs presents a setup screen to create the initial admin acco
|
||||
|
||||
- Passwords are hashed with **Argon2** (argon2id variant)
|
||||
- Sessions use httpOnly cookies with 30-day expiry
|
||||
- Admins can reset passwords and revoke sessions for any user via Settings
|
||||
- Admins can revoke the sessions of, and reset the password of, users other than themselves via **Settings > Users** — see [Admin password resets](#admin-password-resets)
|
||||
|
||||
### Admin password resets
|
||||
|
||||
An admin can reset the password of any user other than themselves, provided that account signs in with a password. Crumbs generates the new password and shows it once in that user's row — copy it and pass it to the user out of band, because it is not stored anywhere and cannot be shown again once dismissed. If SMTP is configured Crumbs also tries to notify the user that their password was reset, but the attempt is best-effort: a delivery failure is logged on the server and never surfaced in the UI, so a successful reset is not evidence the user was told. The notification never contains the password.
|
||||
|
||||
The control appears on every row, but is unavailable in two cases, each stating its reason on the row:
|
||||
|
||||
- **Your own row** — change your own password in **Settings > Profile**, which verifies your current password first.
|
||||
- **An account that signs in via OAuth/SSO** — it has no password the login form would accept, so setting one would hand the user a credential that cannot work.
|
||||
|
||||
It is also unavailable while a generated password is still on screen, and that case gives no reason — dismiss the password first if you need to generate another.
|
||||
|
||||
A reset does not sign the user out. **Revoke all sessions** is a separate action on the same row and, like the reset, is not offered for your own account.
|
||||
|
||||
A displayed password is only good until that account's password changes again: any later reset — by another admin, or by the user themselves from **Settings > Profile** — silently invalidates one still on screen, and nothing marks it as stale. Separately, if Crumbs cannot confirm a reset it says so and shows the password anyway; keep that one, since it may or may not have been applied, and reset again if the user cannot sign in.
|
||||
|
||||
> **No password recovery.** Crumbs has no forgot-password flow. An admin who forgets their own password cannot reset it themselves: the admin control excludes their own row, and the profile flow requires the current password. Another admin can reset you, so keep a second admin account as cheap insurance. Failing that, if an OAuth/SSO provider is configured and your account's email matches your identity there, signing in with that provider gets you back in without a password — at the cost of handing the account over to the provider permanently, after which your password no longer works. With no other admin and no provider configured, there is no way back in through the app.
|
||||
|
||||
## OAuth / SSO
|
||||
|
||||
@@ -29,12 +46,14 @@ OAuth providers are **auto-enabled** when their environment variables are set
|
||||
|
||||
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
|
||||
|
||||
1. Go to **Settings > Users > Invite User**
|
||||
1. Go to **Settings > Users** and fill in the **Create User** form
|
||||
2. Create the user with the **same email** they use on the OAuth provider
|
||||
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
|
||||
|
||||
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
|
||||
|
||||
Tick **OAuth-only (no password)** when creating these accounts — the provider is how they will sign in anyway. If you set a password instead, treat it as temporary: the first OAuth sign-in hands the account over to the provider, after which the password no longer works at the login form and the admin password reset is unavailable for that account.
|
||||
|
||||
### Environment variables
|
||||
|
||||
#### Google OAuth
|
||||
@@ -130,7 +149,7 @@ services:
|
||||
|
||||
**4. Pre-create users:**
|
||||
|
||||
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
|
||||
In Crumbs, go to **Settings > Users** and create accounts with emails matching your Authentik users, ticking **OAuth-only (no password)**. They can then log in via the "Authentik" button on the login page.
|
||||
|
||||
#### Keycloak
|
||||
|
||||
|
||||
+5
-2
@@ -142,8 +142,11 @@
|
||||
### User Management (admin)
|
||||
- Admin dashboard to create, list, and delete users
|
||||
- Assign roles (admin / user)
|
||||
- Reset passwords for any user
|
||||
- Revoke all sessions (force logout) for any user
|
||||
- Reset another user's password — Crumbs generates a 20-character password and shows it once in that user's row, with a copy button; it cannot be shown again
|
||||
- Only for accounts that sign in with a password — OAuth/SSO accounts show why the reset is unavailable
|
||||
- Your own password is changed from Settings > Profile, not here
|
||||
- A reset does not sign the user out — revoke their sessions separately to force a logout
|
||||
- Revoke all sessions (force logout) for any user other than yourself
|
||||
|
||||
### PWA / Offline-First
|
||||
- Installable as standalone app
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.32.0",
|
||||
"version": "0.33.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { canResetPassword, passwordResetBlockedReason } from './auth-methods.js';
|
||||
|
||||
const NON_PASSWORD_PROVIDERS = ['none', 'google', 'github', 'oidc'];
|
||||
|
||||
describe('canResetPassword', () => {
|
||||
it('allows a reset for a password account', () => {
|
||||
expect(canResetPassword({ authProvider: 'password' })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks a reset for every non-password provider', () => {
|
||||
// verifyPassword() filters on authProvider === 'password', so a hash
|
||||
// written for any other provider could never be used to sign in.
|
||||
for (const authProvider of NON_PASSWORD_PROVIDERS) {
|
||||
expect(canResetPassword({ authProvider }), authProvider).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a reset for an unenumerated provider', () => {
|
||||
expect(canResetPassword({ authProvider: 'saml' })).toBe(false);
|
||||
expect(canResetPassword({ authProvider: '' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('passwordResetBlockedReason', () => {
|
||||
it('returns null for a password account', () => {
|
||||
expect(passwordResetBlockedReason({ authProvider: 'password' })).toBeNull();
|
||||
});
|
||||
|
||||
it('names the provider in the reason', () => {
|
||||
expect(passwordResetBlockedReason({ authProvider: 'none' })).toBe(
|
||||
'This account has no password — it signs in via OAuth.'
|
||||
);
|
||||
expect(passwordResetBlockedReason({ authProvider: 'google' })).toBe(
|
||||
'Managed by Google — password login is disabled for this account.'
|
||||
);
|
||||
expect(passwordResetBlockedReason({ authProvider: 'github' })).toBe(
|
||||
'Managed by GitHub — password login is disabled for this account.'
|
||||
);
|
||||
expect(passwordResetBlockedReason({ authProvider: 'oidc' })).toBe(
|
||||
'Managed by SSO — password login is disabled for this account.'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a generic reason for an unenumerated provider', () => {
|
||||
expect(passwordResetBlockedReason({ authProvider: 'saml' })).toBe(
|
||||
'Password login is not available for this account.'
|
||||
);
|
||||
});
|
||||
|
||||
it('always explains a blocked reset', () => {
|
||||
// authProvider is typed `string`, so no missing branch can be caught by
|
||||
// the compiler. A null reason here would render a disabled control with
|
||||
// no explanation — the confusion issue #77 reported.
|
||||
//
|
||||
// The Object.prototype keys are the interesting cases: with a plain object
|
||||
// literal as the lookup table they resolve to inherited members, so a
|
||||
// nullish fallback never fires and the function returns a function or an
|
||||
// object in place of its declared string. Asserting typeof rather than
|
||||
// truthiness is what catches that.
|
||||
const providers = [
|
||||
...NON_PASSWORD_PROVIDERS,
|
||||
'saml',
|
||||
'',
|
||||
'PASSWORD',
|
||||
'ldap',
|
||||
'unknown',
|
||||
'__proto__',
|
||||
'toString',
|
||||
'constructor',
|
||||
'hasOwnProperty',
|
||||
'valueOf'
|
||||
];
|
||||
for (const authProvider of providers) {
|
||||
const user = { authProvider };
|
||||
expect(canResetPassword(user), authProvider).toBe(false);
|
||||
const reason = passwordResetBlockedReason(user);
|
||||
expect(typeof reason, authProvider).toBe('string');
|
||||
expect(reason, authProvider).not.toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-methods module', () => {
|
||||
it('does not use Math.random', () => {
|
||||
// The reset-eligibility rule must be pure and deterministic.
|
||||
const source = readFileSync(new URL('./auth-methods.ts', import.meta.url), 'utf8');
|
||||
expect(source).not.toContain('Math.random');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { User } from '$lib/types/index.js';
|
||||
|
||||
/**
|
||||
* Human-readable reason a password reset is unavailable, keyed by auth provider.
|
||||
* Anything absent from this map falls back to BLOCKED_REASON_FALLBACK, so the
|
||||
* blocked path can never be left unexplained.
|
||||
*
|
||||
* A Map, not an object literal: an object inherits Object.prototype, so a lookup
|
||||
* for 'toString' or '__proto__' would resolve to an inherited member instead of
|
||||
* missing, and the fallback below would never fire. A Map has no such keys, so
|
||||
* totality holds by construction rather than by a guard that could be dropped.
|
||||
*/
|
||||
const BLOCKED_REASONS = new Map<string, string>([
|
||||
['none', 'This account has no password — it signs in via OAuth.'],
|
||||
['google', 'Managed by Google — password login is disabled for this account.'],
|
||||
['github', 'Managed by GitHub — password login is disabled for this account.'],
|
||||
['oidc', 'Managed by SSO — password login is disabled for this account.']
|
||||
]);
|
||||
|
||||
const BLOCKED_REASON_FALLBACK = 'Password login is not available for this account.';
|
||||
|
||||
/**
|
||||
* Whether an admin may set a new password for this account.
|
||||
*
|
||||
* Password login is gated on `authProvider` — verifyPassword() only matches rows
|
||||
* where it equals 'password' — so writing a hash for any other provider stores a
|
||||
* credential that can never be used to sign in.
|
||||
*/
|
||||
export function canResetPassword(user: Pick<User, 'authProvider'>): boolean {
|
||||
return user.authProvider === 'password';
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a password reset is blocked, or null when it is allowed.
|
||||
*
|
||||
* `authProvider` is typed `string`, so the compiler cannot enforce exhaustiveness
|
||||
* here. The fallback guarantees the invariant this module exists for: whenever
|
||||
* `canResetPassword` is false, this returns a non-empty explanation, so the UI
|
||||
* never shows a disabled control without saying why.
|
||||
*/
|
||||
export function passwordResetBlockedReason(user: Pick<User, 'authProvider'>): string | null {
|
||||
if (canResetPassword(user)) return null;
|
||||
return BLOCKED_REASONS.get(user.authProvider) ?? BLOCKED_REASON_FALLBACK;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { generatePassword, PASSWORD_ALPHABET } from './password.js';
|
||||
|
||||
const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/;
|
||||
const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/;
|
||||
const DIGIT = /[23456789]/;
|
||||
const SYMBOL = /[!@#$%^&*]/;
|
||||
|
||||
/** Visually ambiguous characters the charset deliberately excludes. */
|
||||
const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I'];
|
||||
|
||||
describe('PASSWORD_ALPHABET', () => {
|
||||
it('is exactly 64 distinct characters', () => {
|
||||
// 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform.
|
||||
// A charset edit that breaks this silently biases every password.
|
||||
expect(PASSWORD_ALPHABET).toHaveLength(64);
|
||||
expect(new Set(PASSWORD_ALPHABET).size).toBe(64);
|
||||
});
|
||||
|
||||
it('excludes every visually ambiguous character', () => {
|
||||
for (const char of AMBIGUOUS) {
|
||||
expect(PASSWORD_ALPHABET.includes(char), char).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePassword', () => {
|
||||
it('returns a 20-character password by default', () => {
|
||||
expect(generatePassword()).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('honours an explicit length', () => {
|
||||
expect(generatePassword(8)).toHaveLength(8);
|
||||
expect(generatePassword(32)).toHaveLength(32);
|
||||
expect(generatePassword(64)).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('always includes at least one character from each of the four groups', () => {
|
||||
// Not a statistical bet: the function only ever returns a class-complete
|
||||
// password or throws, so every sample must pass.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const password = generatePassword();
|
||||
expect(password, `sample ${i}`).toMatch(LOWERCASE);
|
||||
expect(password, `sample ${i}`).toMatch(UPPERCASE);
|
||||
expect(password, `sample ${i}`).toMatch(DIGIT);
|
||||
expect(password, `sample ${i}`).toMatch(SYMBOL);
|
||||
}
|
||||
});
|
||||
|
||||
it('never emits a visually ambiguous character', () => {
|
||||
// 0 O o 1 l I survive neither being read aloud nor retyped.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const password = generatePassword();
|
||||
for (const char of AMBIGUOUS) {
|
||||
expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('draws only from the pinned 64-character charset', () => {
|
||||
const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(generatePassword(), `sample ${i}`).toMatch(charset);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces a different password on each call', () => {
|
||||
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
|
||||
expect(passwords.size).toBe(20);
|
||||
});
|
||||
|
||||
it('throws below the 8-character server minimum', () => {
|
||||
expect(() => generatePassword(7)).toThrow(/8/);
|
||||
expect(() => generatePassword(0)).toThrow(/8/);
|
||||
expect(() => generatePassword(-1)).toThrow(/8/);
|
||||
});
|
||||
|
||||
it('does not use Math.random anywhere in the module', () => {
|
||||
// Randomness must come from nanoid's CSPRNG, not Math.random.
|
||||
const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8');
|
||||
expect(source).not.toContain('Math.random');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
// The excluded characters — 0 O o 1 l I — are visually ambiguous, so a generated
|
||||
// password survives being read aloud or retyped from a screenshot.
|
||||
const LOWERCASE = 'abcdefghijkmnpqrstuvwxyz'; // no l, no o
|
||||
const UPPERCASE = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no I, no O
|
||||
const DIGITS = '23456789'; // no 0, no 1
|
||||
const SYMBOLS = '!@#$%^&*';
|
||||
|
||||
/**
|
||||
* Pinned at exactly 64 characters. 64 is a power of two, so nanoid's masking
|
||||
* takes its exactly-uniform fast path (256 % 64 === 0) and no character is
|
||||
* favoured over another. At 64 symbols a 20-character password carries 120 bits
|
||||
* of entropy — 119.79 once conditioned on the class-completeness retry below.
|
||||
* Do not change the size without understanding both consequences.
|
||||
*/
|
||||
export const PASSWORD_ALPHABET = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS;
|
||||
|
||||
const GROUPS = [LOWERCASE, UPPERCASE, DIGITS, SYMBOLS];
|
||||
|
||||
/** The server rejects anything shorter. */
|
||||
const MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Per-attempt failure is 13.5% at the default length of 20 and 61.9% at the
|
||||
* minimum of 8, so exhausting the cap has probability ~1e-87 and ~1e-21
|
||||
* respectively — unreachable in practice. The bound exists so a future charset
|
||||
* edit cannot turn the retry into an infinite loop.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 100;
|
||||
|
||||
const randomString = customAlphabet(PASSWORD_ALPHABET);
|
||||
|
||||
function containsAnyOf(password: string, group: string): boolean {
|
||||
return [...password].some((char) => group.includes(char));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random password containing at least one lowercase letter, one
|
||||
* uppercase letter, one digit and one symbol.
|
||||
*
|
||||
* A password missing a group is discarded whole and regenerated, which keeps the
|
||||
* result uniform over exactly the set of valid passwords. Forcing characters
|
||||
* into chosen positions and shuffling would need its own unbiased index source.
|
||||
*/
|
||||
export function generatePassword(length = 20): string {
|
||||
if (length < MIN_LENGTH) {
|
||||
throw new Error(`Password length must be at least ${MIN_LENGTH}, got ${length}`);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const password = randomString(length);
|
||||
if (GROUPS.every((group) => containsAnyOf(password, group))) return password;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to generate a password with all character groups after ${MAX_ATTEMPTS} attempts`
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { User } from '$lib/types/index.js';
|
||||
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
|
||||
import { canResetPassword, passwordResetBlockedReason } from '$lib/utils/auth-methods.js';
|
||||
import { generatePassword } from '$lib/utils/password.js';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import ShieldCheck from 'lucide-svelte/icons/shield-check';
|
||||
import ShieldOff from 'lucide-svelte/icons/shield-off';
|
||||
import KeyRound from 'lucide-svelte/icons/key-round';
|
||||
import LogOut from 'lucide-svelte/icons/log-out';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
|
||||
|
||||
let { data } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -15,6 +24,213 @@
|
||||
let createMsg = $state('');
|
||||
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() {
|
||||
createMsg = '';
|
||||
if (!newEmail) {
|
||||
@@ -70,6 +286,7 @@
|
||||
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
users = users.filter((u) => u.id !== userId);
|
||||
delete resets[userId];
|
||||
createMsg = 'User deleted';
|
||||
createError = false;
|
||||
} else {
|
||||
@@ -172,46 +389,172 @@
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
{#each users as user (user.id)}
|
||||
{@const reset = resets[user.id]}
|
||||
{@const control = resetControl(user)}
|
||||
{@const roleAction = user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||
{@const resetLabel = control.inFlight ? 'Resetting…' : 'Reset password'}
|
||||
<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}"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<!-- One uniform 44x44 square per action, replacing four text labels that
|
||||
wrapped onto three lines at phone width. `flex-wrap` is what keeps the
|
||||
squares square: flex compresses items only on a line that overflows, so
|
||||
breaking the line means no button is ever asked to shrink. Drop it and
|
||||
the four flatten to 40x44 at 320px — under the minimum, on a row whose
|
||||
Delete is irreversible. They hold one line down to 336px, which is
|
||||
exactly 4x44 plus the gaps, and take a second line below that: correctly
|
||||
sized targets over two lines beat cramped ones on one. The container's
|
||||
own `shrink-0` is a different mechanism and does carry weight — from
|
||||
640px the row turns `sm:flex-row`, where the truncating name block would
|
||||
otherwise squeeze the strip. On the buttons themselves that same token
|
||||
would do nothing, `flex-wrap` having already spared them. Each icon
|
||||
carries both a tooltip, for a mouse, and an aria-label, since an icon
|
||||
alone has no accessible name. -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1">
|
||||
<button
|
||||
onclick={() => toggleRole(user)}
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={roleAction}
|
||||
aria-label={roleAction}
|
||||
>
|
||||
{#if user.role === 'admin'}
|
||||
<ShieldOff class="h-4 w-4" />
|
||||
{:else}
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Offered on every row, the admin's own included: a control that
|
||||
vanishes is the confusion this replaces. -->
|
||||
<button
|
||||
bind:this={resetButtons[user.id]}
|
||||
onclick={() => resetUserPassword(user)}
|
||||
data-testid="reset-password-btn-{user.id}"
|
||||
aria-disabled={control.unavailable}
|
||||
aria-describedby={control.reason ? `reset-password-reason-${user.id}` : undefined}
|
||||
use:tooltip={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm transition-colors duration-150 ease-out {control.unavailable
|
||||
? 'cursor-not-allowed border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)]'
|
||||
: 'text-[var(--text)] hover:bg-[var(--hover-wash)]/10'}"
|
||||
>
|
||||
{#if control.inFlight}
|
||||
<!-- The label went with the text, so in-flight has to be carried by
|
||||
the icon and by the accessible name above. -->
|
||||
<LoaderCircle class="h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<KeyRound class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if user.id !== data.user?.id}
|
||||
<button
|
||||
onclick={() => revokeSessions(user)}
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={'Revoke all sessions'}
|
||||
aria-label="Revoke all sessions"
|
||||
>
|
||||
<LogOut class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
data-testid="delete-user-btn-{user.id}"
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
|
||||
use:tooltip={'Delete user'}
|
||||
aria-label="Delete user"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
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
|
||||
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>
|
||||
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
|
||||
import { canResetPassword, passwordResetBlockedReason } from '$lib/utils/auth-methods.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const admin = requireAdmin(event);
|
||||
@@ -34,6 +35,20 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
if (body.newPassword.length < 8) {
|
||||
throw error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
// Prevent admin from resetting their own password — changing your own
|
||||
// password goes through the profile flow, which verifies the current one.
|
||||
if (userId === admin.id) {
|
||||
throw error(400, 'Cannot reset your own password — change it from your profile instead');
|
||||
}
|
||||
// Refuse accounts that do not sign in with a password. resetPassword()
|
||||
// writes only passwordHash, and verifyPassword() matches only rows whose
|
||||
// authProvider is 'password' — a hash written here could never be used to
|
||||
// sign in, while the email below would claim the reset worked. The reason
|
||||
// text comes from the same module as the check, so this matches what the
|
||||
// UI explains; the ?? only satisfies error()'s string parameter.
|
||||
if (!canResetPassword(user)) {
|
||||
throw error(400, passwordResetBlockedReason(user) ?? 'This account does not use password login');
|
||||
}
|
||||
await resetPassword(userId, body.newPassword);
|
||||
|
||||
// Notify user of password reset (fire-and-forget)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, TEST_EMAIL } from './helpers/fixtures.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
@@ -23,6 +24,35 @@ async function createVictim(
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a victim user with no password via the admin API, returning the user's id.
|
||||
* Omitting the password is the OAuth-only path: createUser() stores authProvider
|
||||
* 'none', which verifyPassword() never matches.
|
||||
*/
|
||||
async function createOAuthOnlyVictim(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string,
|
||||
displayName = email.split('@')[0]
|
||||
): Promise<number> {
|
||||
const res = await page.request.post('/api/admin/users', {
|
||||
data: { email, displayName, role: 'user' }
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const user = await res.json();
|
||||
expect(user.authProvider).toBe('none');
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/** The id of the admin account the authenticated page is signed in as. */
|
||||
async function currentAdminId(page: import('@playwright/test').Page): Promise<number> {
|
||||
const res = await page.request.get('/api/admin/users');
|
||||
expect(res.status()).toBe(200);
|
||||
const users = (await res.json()) as Array<{ id: number; email: string }>;
|
||||
const admin = users.find((u) => u.email === TEST_EMAIL);
|
||||
if (!admin) throw new Error(`admin ${TEST_EMAIL} is missing from the users list`);
|
||||
return admin.id;
|
||||
}
|
||||
|
||||
test.describe.serial('Admin — User Deletion', () => {
|
||||
test('Scenario: Admin deletes a user from the users list', async ({
|
||||
authenticatedPage: page
|
||||
@@ -151,3 +181,780 @@ test.describe.serial('Admin — User Deletion', () => {
|
||||
expect(loginRes.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('Admin — Password Reset', () => {
|
||||
test('Scenario: An account can sign in with a password the admin set', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a password-auth user exists
|
||||
const email = 'reset-target@test.com';
|
||||
const victimId = await createVictim(page, email, 'Reset Target');
|
||||
|
||||
// When the admin sets a new password for that account
|
||||
const newPassword = `reset-${randomUUID()}`;
|
||||
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
|
||||
data: { newPassword }
|
||||
});
|
||||
|
||||
// Then the reset succeeds
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
// And the account can sign in with the new password. A separate browser
|
||||
// context keeps the resulting session cookie out of the admin's jar.
|
||||
const ctx = await browser.newContext();
|
||||
const loginRes = await ctx.request.post('/api/auth/login', {
|
||||
data: { email, password: newPassword }
|
||||
});
|
||||
expect(loginRes.status()).toBe(200);
|
||||
await ctx.close();
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: An account that signs in via OAuth cannot be given a password', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given an OAuth-only user exists
|
||||
const email = 'oauth-only@test.com';
|
||||
const victimId = await createOAuthOnlyVictim(page, email, 'OAuth Only');
|
||||
|
||||
// When the admin attempts to set a password for that account
|
||||
const attemptedPassword = `attempt-${randomUUID()}`;
|
||||
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
|
||||
data: { newPassword: attemptedPassword }
|
||||
});
|
||||
|
||||
// Then the attempt is rejected and the response says why
|
||||
expect(res.status()).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.message).toMatch(/OAuth|password login/i);
|
||||
|
||||
// And the account still cannot sign in with the attempted password.
|
||||
// This is the positive control for the rejection above: it confirms no
|
||||
// usable credential was stored, not merely that the request 400'd.
|
||||
const ctx = await browser.newContext();
|
||||
const loginRes = await ctx.request.post('/api/auth/login', {
|
||||
data: { email, password: attemptedPassword }
|
||||
});
|
||||
expect(loginRes.status()).toBe(401);
|
||||
await ctx.close();
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: An admin cannot reset their own password', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the admin's own account
|
||||
const adminId = await currentAdminId(page);
|
||||
|
||||
// When the admin targets their own account for a password reset
|
||||
const res = await page.request.patch(`/api/admin/users/${adminId}`, {
|
||||
data: { newPassword: `self-${randomUUID()}` }
|
||||
});
|
||||
|
||||
// Then the attempt is rejected and the response says why
|
||||
expect(res.status()).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.message).toMatch(/your own password/i);
|
||||
});
|
||||
|
||||
test('Scenario: A password below the minimum length is rejected for its length, whatever the account', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given an OAuth-only user exists, who is also ineligible for a reset
|
||||
const victimId = await createOAuthOnlyVictim(page, 'short-pw-oauth@test.com', 'Short Pw');
|
||||
|
||||
// When the admin attempts to set a password shorter than the minimum
|
||||
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
|
||||
data: { newPassword: 'short' }
|
||||
});
|
||||
|
||||
// Then the length rule is what the response reports, taking precedence
|
||||
// over the eligibility rule
|
||||
expect(res.status()).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.message).toMatch(/at least 8 characters/i);
|
||||
|
||||
// Clean up
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four row action buttons, in DOM order, named by their accessible name. An
|
||||
* eligible row that is not the signed-in admin's own offers all four.
|
||||
*/
|
||||
const ROW_ACTION_LABELS = [
|
||||
'Promote to admin',
|
||||
'Reset password',
|
||||
'Revoke all sessions',
|
||||
'Delete user'
|
||||
] as const;
|
||||
|
||||
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: Row actions stay full-size square targets at every phone width', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user, whose row therefore offers all four actions
|
||||
const victimId = await createVictim(page, 'ui-strip@test.com', 'UI Strip');
|
||||
|
||||
// The strip needs 188px of room, so it holds one line to 336px and wraps
|
||||
// below. 360px covers the fitting case; 320px covers the case that used to
|
||||
// shrink; 300px is narrow enough that a strip which refused to wrap would be
|
||||
// pushed clear of its row, which the containment check below then catches.
|
||||
for (const [width, fitsOneLine] of [
|
||||
[360, true],
|
||||
[320, false],
|
||||
[300, false]
|
||||
] as const) {
|
||||
// When the admin views the users list on a screen that wide
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await openUsersPage(page);
|
||||
const row = page.getByTestId(`user-row-${victimId}`);
|
||||
|
||||
const boxes = [];
|
||||
for (const label of ROW_ACTION_LABELS) {
|
||||
const box = await row.getByRole('button', { name: label, exact: true }).boundingBox();
|
||||
expect(box, `${label} at ${width}px`).not.toBeNull();
|
||||
boxes.push(box!);
|
||||
}
|
||||
|
||||
// Then every action is a square that clears the 44px minimum. One of them
|
||||
// deletes an account irreversibly, so an action that shrank to fit its row
|
||||
// would put a data-losing target under a thumb.
|
||||
for (const [i, box] of boxes.entries()) {
|
||||
const at = `${ROW_ACTION_LABELS[i]} at ${width}px`;
|
||||
expect(Math.min(box.width, box.height), at).toBeGreaterThanOrEqual(44);
|
||||
expect(Math.abs(box.width - box.height), at).toBeLessThan(1);
|
||||
}
|
||||
|
||||
// And they sit on one line wherever there is room for one
|
||||
const lines = new Set(boxes.map((box) => Math.round(box.y)));
|
||||
if (fitsOneLine) expect(lines.size, `lines at ${width}px`).toBe(1);
|
||||
|
||||
// And where there is not, they wrap rather than spill out of their row or
|
||||
// push the page sideways
|
||||
const rowBox = await row.boundingBox();
|
||||
expect(rowBox, `row at ${width}px`).not.toBeNull();
|
||||
for (const [i, box] of boxes.entries()) {
|
||||
expect(box.x + box.width, `${ROW_ACTION_LABELS[i]} at ${width}px`).toBeLessThanOrEqual(
|
||||
rowBox!.x + rowBox!.width
|
||||
);
|
||||
}
|
||||
const overflows = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
||||
);
|
||||
expect(overflows, `horizontal overflow at ${width}px`).toBe(false);
|
||||
}
|
||||
|
||||
// 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 server-side failure keeps the generated password even when it gives a reason', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user whose reset fails inside the server, with a reason attached —
|
||||
// a reason says why the server is unhappy, never whether it already stored
|
||||
// the password, so the outcome is unknown however readable the body is
|
||||
const victimId = await createVictim(page, 'ui-5xx-reason@test.com', 'UI 5xx Reason');
|
||||
await openUsersPage(page);
|
||||
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue();
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Database is temporarily read-only.' })
|
||||
});
|
||||
});
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the uncertainty is stated rather than the server's reason being taken
|
||||
// as a refusal
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
|
||||
/may or may not/i
|
||||
);
|
||||
|
||||
// And the generated password is kept, because the server may already hold it
|
||||
const reveal = page.getByTestId(`generated-password-${victimId}`);
|
||||
await expect(reveal).toBeVisible();
|
||||
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
// Clean up
|
||||
await page.unroute(`**/api/admin/users/${victimId}`);
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A rejection nobody can read keeps the generated password', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user whose reset comes back as an unreadable error page — what a
|
||||
// reverse proxy returns when it answers instead of the app, so the request may
|
||||
// have reached the server or may not have
|
||||
const victimId = await createVictim(page, 'ui-html-error@test.com', 'UI Html Error');
|
||||
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: 'text/html',
|
||||
body: '<html><body><h1>400 Bad Request</h1></body></html>'
|
||||
});
|
||||
});
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the uncertainty is stated rather than the status alone being read as a
|
||||
// refusal
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
|
||||
/may or may not/i
|
||||
);
|
||||
|
||||
// And the generated password is kept, because nothing said it was not stored
|
||||
const reveal = page.getByTestId(`generated-password-${victimId}`);
|
||||
await expect(reveal).toBeVisible();
|
||||
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
// And the control is not left explaining a reason it never received
|
||||
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toHaveCount(0);
|
||||
|
||||
// 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).toHaveAccessibleName(/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}`);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.32.0</span>
|
||||
<span class="footer-version">v0.33.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user