Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ All notable changes to this project are documented in this file. The format is b

## [Unreleased]

### Fixed
- Users: show password policy reasons on the reset screen instead of suggesting
that a valid link expired; allow retry with the same token (#667).
- Settings: size module field rows by the editor card width, stack controls in narrow
cards, and wrap long field/ENV identifiers to prevent collapsed inputs and horizontal
overflow with two sidebars (#669).
- Restore keyboard focus to the Search command palette's opener after dismissal, including Ctrl/Cmd+K toggles, without stealing focus after command navigation (#668).
- Raise shared muted sidebar text contrast above 4.5:1 on both the default and
hover backgrounds in light and dark modes, preserving secondary text hierarchy (#670).

## [0.0.40] — 2026-10-24

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions modules/settings/settings/pages/components/FieldSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ export function FieldSource({ field, onReset }: { field: FieldMeta; onReset: ()
const showDefault = !isBlank(field.default) && String(field.default) !== String(field.value);
return (
// Wraps rather than clips: `SM_FILE_STORAGE_QUOTA_BYTES` has no space to
// break at, so a 210px column cut it mid-word with nothing to say so.
// `break-words` breaks only the tokens that do not fit.
// break at, and as an unbreakable token it would set the column's minimum
// width; `overflow-wrap:anywhere` lets it break inside the identifier.
<span
className="block break-words font-mono text-[11.5px] leading-snug text-muted-foreground"
className="block [overflow-wrap:anywhere] font-mono text-[11.5px] leading-snug text-muted-foreground"
title={
field.env_readable
? t(keys.settings.modules.env_var_hint)
Expand Down
22 changes: 11 additions & 11 deletions modules/settings/settings/pages/components/ModuleFieldRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ interface Props {
/**
* One settings row: label, control, trailing meta.
*
* Three columns rather than a stacked label block, because the trailing column
* is what makes the screen readable at a glance — every row answers "where is
* this value from?" in the same place, so the exceptions stand out instead of
* having to be hunted for.
* Three columns when the editor card has room, because the trailing column is
* what makes the screen readable at a glance — every row answers "where is
* this value from?" in the same place, so the exceptions stand out. The tracks
* follow the card's width (container query), not the viewport: two sidebars
* can leave a narrow card on a wide screen, and the row stacks instead.
*/
export function ModuleFieldRow({
field,
Expand All @@ -37,15 +38,14 @@ export function ModuleFieldRow({
const showDescription = field.description && field.type !== 'bool';

return (
// `minmax(170px, auto)`: a fixed 170px track cannot grow, so a long field
// name overflowed it and printed straight over the input beside it.
<div className="grid gap-3 sm:grid-cols-[minmax(170px,auto)_1fr] lg:grid-cols-[minmax(170px,auto)_1fr_210px] lg:items-center">
// Query the editor card: two sidebars can leave little space at a wide viewport.
<div className="grid grid-cols-1 gap-3 @md:grid-cols-[minmax(0,140px)_minmax(180px,1fr)] @xl:grid-cols-[minmax(0,140px)_minmax(180px,1fr)_minmax(0,160px)] @xl:items-center">
<div className="min-w-0">
<label htmlFor={id} className="block break-words text-[13px] font-medium">
<label htmlFor={id} className="block [overflow-wrap:anywhere] text-[13px] font-medium">
{field.name}
</label>
{showDescription && (
<p className="mt-0.5 text-[11.5px] leading-snug text-muted-foreground">
<p className="mt-0.5 [overflow-wrap:anywhere] text-[11.5px] leading-snug text-muted-foreground">
{field.description}
</p>
)}
Expand All @@ -65,7 +65,7 @@ export function ModuleFieldRow({
</fieldset>
</div>

<div className="sm:col-span-2 lg:col-span-1 lg:justify-self-start">
<div className="min-w-0 [overflow-wrap:anywhere] @md:col-span-2 @xl:col-span-1 @xl:justify-self-start">
<FieldSource field={field} onReset={onReset} />
</div>

Expand All @@ -75,7 +75,7 @@ export function ModuleFieldRow({
{error && (
<p
data-testid={`field-error-${field.name}`}
className="break-words text-xs text-destructive sm:col-span-1 sm:col-start-2 lg:col-span-2"
className="min-w-0 [overflow-wrap:anywhere] text-xs text-destructive @md:col-span-1 @md:col-start-2 @xl:col-span-2"
>
{error}
</p>
Expand Down
4 changes: 2 additions & 2 deletions modules/settings/settings/pages/components/ModuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
}

return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Outside the card: the heading names the pane, and the deck keeps the
card for the fields alone. */}
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
Expand Down Expand Up @@ -178,7 +178,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
</div>
)}

<Card className="flex flex-1 flex-col gap-4 border-border p-6">
<Card className="@container flex min-w-0 flex-1 flex-col gap-4 border-border p-6">
{/* No inner scroller: the card grows and the page scrolls, which is
one scrollbar for the screen instead of three nested ones. */}
<div className="flex flex-col gap-4">
Expand Down
2 changes: 1 addition & 1 deletion modules/settings/tests-js/ModuleForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('ModuleForm', () => {
// control's cell, where it wrapped a word per line.
const error = screen.getByText(/item rejected/);
expect(error.contains(screen.getByLabelText('retention_days'))).toBe(false);
expect(error.className).toContain('lg:col-span-2');
expect(error.className).toContain('@xl:col-span-2');
});

test('shows an error whose loc names no field at form level', async () => {
Expand Down
62 changes: 62 additions & 0 deletions modules/settings/tests-js/settings_ModuleFieldLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { configureI18n } from '@simple-module-py/i18n';
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import type { FieldMeta } from '../settings/pages/components/FieldInput';
import { ModuleFieldRow } from '../settings/pages/components/ModuleFieldRow';
import { ModuleForm } from '../settings/pages/components/ModuleForm';

configureI18n({ locale: 'en', messages: {} });

const field: FieldMeta = {
name: 'very_long_underscore_separated_setting_name',
env_var: 'SM_FILE_STORAGE_VERY_LONG_UNDERSCORE_SEPARATED_SETTING_NAME',
type: 'string',
value: '',
default: '',
description: '',
is_secret: false,
requires_restart: false,
group: null,
};

// jsdom has no layout engine: these guard the responsive CSS contract.
test('the editor card supplies the field rows container width', () => {
const { container } = render(
<ModuleForm
module={{
module_name: 'FileStorage',
package: 'file_storage',
env_prefix: 'SM_FILE_STORAGE_',
class_name: 'FileStorageSettings',
fields: [field],
}}
/>,
);
expect(container.querySelector('[data-slot="card"]')).toHaveClass('@container', 'min-w-0');
});

test('rows bound their tracks and wrap identifiers without viewport breakpoints', () => {
const { container } = render(
<ModuleFieldRow
field={field}
package="file_storage"
value=""
modified={false}
error="Invalid setting"
onChange={() => {}}
onReset={() => {}}
/>,
);
const row = container.firstElementChild;
expect(row).toHaveClass(
'grid-cols-1',
'@md:grid-cols-[minmax(0,140px)_minmax(180px,1fr)]',
'@xl:grid-cols-[minmax(0,140px)_minmax(180px,1fr)_minmax(0,160px)]',
);
expect(row?.className).not.toMatch(/(?:^|\s)(?:sm|lg):/);
expect(screen.getByText(field.name)).toHaveClass('[overflow-wrap:anywhere]');
const source = screen.getByText(field.env_var);
expect(source).toHaveClass('[overflow-wrap:anywhere]');
expect(source.parentElement).toHaveClass('min-w-0', '@md:col-span-2', '@xl:col-span-1');
expect(screen.getByText('Invalid setting')).toHaveClass('@md:col-start-2', '@xl:col-span-2');
});
114 changes: 114 additions & 0 deletions modules/users/tests-js/users_ResetPassword.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { configureI18n, keys } from '@simple-module-py/i18n';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { json, sent, stubFetch } from './users_fixtures';

vi.mock('@inertiajs/react', () => ({
Head: () => null,
router: { visit: vi.fn() },
usePage: () => ({ props: { branding: { appName: 'Acme', logoUrl: null } } }),
}));

import ResetPassword from '../users/pages/ResetPassword';

configureI18n({
locale: 'en',
messages: {
[keys.users.common.error_try_again]: 'Please try again.',
[keys.users.reset_password.error_failed]: 'The reset link may have expired.',
[keys.users.reset_password.saved_title]: 'Password saved',
[keys.users.reset_password.submit]: 'Save password',
},
});

afterEach(() => vi.unstubAllGlobals());

function reset() {
render(
<ResetPassword
token="same-token"
email={null}
expired={false}
reset_link_lifetime_minutes={60}
/>,
);
}

function submit(password: string) {
for (const id of ['password', 'confirm']) {
fireEvent.change(document.getElementById(id) as HTMLElement, { target: { value: password } });
}
fireEvent.submit(document.querySelector('form') as HTMLFormElement);
}

describe('ResetPassword password policy errors (#667)', () => {
it.each([
['short', 'Password must be at least 8 characters, not counting spaces'],
['12345678', 'Password cannot be all numbers'],
])('shows the policy reason for %s and retries with the same token', async (weak, reason) => {
const fetch = stubFetch(() =>
json(400, { detail: { code: 'RESET_PASSWORD_INVALID_PASSWORD', reason } }),
);
reset();
submit(weak);
expect(await screen.findByText(reason)).toBeInTheDocument();
expect(screen.queryByText('The reset link may have expired.')).toBeNull();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Save password' })).toBeEnabled(),
);
fetch.mockImplementation(async () => json(200, null));
submit('CompliantPass1!');
expect(await screen.findByRole('heading', { name: 'Password saved' })).toBeInTheDocument();
expect(screen.queryByText(reason)).toBeNull();
expect(JSON.parse((await sent(fetch, 0)).text)).toEqual({
token: 'same-token',
password: weak,
});
expect(JSON.parse((await sent(fetch, 1)).text)).toEqual({
token: 'same-token',
password: 'CompliantPass1!',
});
});

it.each([
null,
{ code: 'UNRELATED', reason: 'Do not display' },
{ code: 'RESET_PASSWORD_INVALID_PASSWORD', reason: 42 },
{ code: 'RESET_PASSWORD_INVALID_PASSWORD', reason: '' },
{ code: 'RESET_PASSWORD_INVALID_PASSWORD', reason: ' ' },
['RESET_PASSWORD_INVALID_PASSWORD'],
])('falls back safely for malformed or unrelated detail %j', async (detail) => {
stubFetch(() => json(400, { detail }));
reset();
submit('short');
expect(await screen.findByText('The reset link may have expired.')).toBeInTheDocument();
expect(screen.queryByText('Do not display')).toBeNull();
});

it('does not treat a server failure as a password policy rejection', async () => {
stubFetch(() =>
json(500, {
detail: { code: 'RESET_PASSWORD_INVALID_PASSWORD', reason: 'Do not display' },
}),
);
reset();
submit('short');
expect(await screen.findByText('The reset link may have expired.')).toBeInTheDocument();
expect(screen.queryByText('Do not display')).toBeNull();
});

it('preserves bad-token details', async () => {
stubFetch(() => json(400, { detail: 'RESET_PASSWORD_BAD_TOKEN' }));
reset();
submit('CompliantPass1!');
expect(await screen.findByText('RESET_PASSWORD_BAD_TOKEN')).toBeInTheDocument();
});

it('keeps network failures separate from expired links', async () => {
stubFetch(() => Promise.reject(new TypeError('Offline')));
reset();
submit('CompliantPass1!');
expect(await screen.findByText('Please try again.')).toBeInTheDocument();
expect(screen.queryByText('The reset link may have expired.')).toBeNull();
});
});
40 changes: 40 additions & 0 deletions modules/users/tests/test_users_reset_password_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""A password policy rejection must leave a fresh reset token usable (#667)."""

from urllib.parse import parse_qs, urlsplit

import pytest
from _users_app_builders import _make_standard_user


@pytest.mark.parametrize(
("password", "reason"),
[
("short", "Password must be at least 8 characters, not counting spaces"),
("12345678", "Password cannot be all numbers"),
],
)
async def test_users_reset_rejected_password_allows_same_token_retry(
admin_client, anon_client, users_app, password, reason
):
user = await _make_standard_user(users_app)
link = await admin_client.post(f"/api/users/admin/{user.id}/reset-password-link")
assert link.status_code == 200, link.text
token = parse_qs(urlsplit(link.json()["link"]).query)["token"][0]

rejected = await anon_client.post(
"/api/users/auth/reset-password", json={"token": token, "password": password}
)
assert rejected.status_code == 400, rejected.text
assert rejected.json()["detail"] == {
"code": "RESET_PASSWORD_INVALID_PASSWORD",
"reason": reason,
}

accepted = await anon_client.post(
"/api/users/auth/reset-password", json={"token": token, "password": "CompliantPass1!"}
)
assert accepted.status_code == 200, accepted.text
login = await anon_client.post(
"/api/users/auth/login", data={"username": user.email, "password": "CompliantPass1!"}
)
assert login.status_code == 204, login.text
13 changes: 13 additions & 0 deletions modules/users/users/auth_local/resetPasswordError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Extract only fastapi-users' password-policy reason; other failures keep their fallback. */
export function resetPasswordReason(error: unknown): string | null {
if (!error || typeof error !== 'object' || !('detail' in error)) return null;
const detail = error.detail;
if (!detail || typeof detail !== 'object' || !('code' in detail) || !('reason' in detail)) {
return null;
}
return detail.code === 'RESET_PASSWORD_INVALID_PASSWORD' &&
typeof detail.reason === 'string' &&
detail.reason.trim() !== ''
? detail.reason
: null;
}
4 changes: 3 additions & 1 deletion modules/users/users/pages/ResetPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CheckCircle2, TimerOff } from 'lucide-react';
import { useState } from 'react';
import { AuthStateCard } from '../auth_local/components/AuthStateCard';
import { PasswordFields } from '../auth_local/components/PasswordFields';
import { resetPasswordReason } from '../auth_local/resetPasswordError';
import { api } from './api';
import { signIn } from './signIn';

Expand Down Expand Up @@ -64,7 +65,8 @@ function ResetPassword({
return;
}
if (res.status !== 200 && res.status !== 204) {
setError(res.detail ?? t(keys.users.reset_password.error_failed));
const reason = res.status === 400 ? resetPasswordReason(res.error) : null;
setError(reason ?? res.detail ?? t(keys.users.reset_password.error_failed));
return;
}
if (await signedIn()) {
Expand Down
Loading
Loading