dorfteich/apps/web/src/pages/SettingsPage.tsx
Claude Opus 5 f938ee9880 #301: stop /settings scrolling horizontally at 320px
WCAG 2.1 SC 1.4.10 asks for no two-dimensional scrolling down to 320px,
which is also what 400% zoom on a 1280px screen produces. The layout
skeleton was already hardened for this in #165; the overflow came from
content inside the sections.

- The sessions table cannot shrink below its min-content width — four
  columns, one of them the full user-agent string. It now scrolls inside
  its own container rather than pushing the page. The container is
  focusable with a role and a name, because a scroll area that only a
  mouse can reach trades one barrier for another.
- `.settings-checkbox` rows may wrap. The accent swatches have a fixed
  size and cannot shrink, so an unwrappable row set a floor for the whole
  page width.

Adds a reflow guard to the a11y pack. axe does not cover 1.4.10 — the
criterion is not derivable from the DOM — so this is a separate check,
and it names the overflowing elements when it trips instead of only
reporting that something overflows.
2026-08-01 12:31:30 +02:00

448 lines
16 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod';
import {
changePasswordInputSchema,
DEFAULT_THEME_PRESET_ID,
deriveAccentTokens,
THEME_PRESETS,
updateProfileInputSchema,
} from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout';
import { useDataExport } from '../export/use-data-export';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
import { WatchesSection } from '../watches/WatchesSection';
import { useDocumentTitle } from '../lib/use-document-title';
import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts';
import { usePersistentState } from '../lib/use-persistent-state';
import { AccentSwatches } from '../theme/AccentSwatches';
import { useAccentChoice } from '../theme/apply-theme';
import { useThemeMode, type ThemeMode } from '../theme/theme';
interface SessionView {
id: string;
createdAt: string;
lastSeenAt: string;
userAgent: string | null;
current: boolean;
}
export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation();
useDocumentTitle(t('settings:title'));
return (
<>
<h1>{t('settings:title')}</h1>
<SettingsLayout>
<ProfileSection />
<PasswordSection />
<SessionsSection />
<WatchesSection />
<ApiTokensSection />
<FeedTokensSection />
<AppearanceSection />
<InteractionSection />
<DataExportSection />
</SettingsLayout>
</>
);
}
/** Erscheinungsbild (issue #180): Hell/Dunkel/System — wie
* InteractionSection eine lokale Geräte-Einstellung, kein Server-Zustand. */
function AppearanceSection(): React.JSX.Element {
const { t } = useTranslation();
// Shared hook instead of usePersistentState: keeps the radios and the
// top-bar theme toggle (issue #182) in sync within the same document.
const [mode, setMode] = useThemeMode();
const options: { value: ThemeMode; label: string }[] = [
{ value: 'light', label: t('settings:appearance.light') },
{ value: 'dark', label: t('settings:appearance.dark') },
{ value: 'system', label: t('settings:appearance.system') },
];
return (
<section className="settings-section">
<h2>{t('settings:appearance.title')}</h2>
<fieldset className="settings-fieldset">
<legend>{t('settings:appearance.legend')}</legend>
{options.map((option) => (
<label key={option.value} className="settings-checkbox">
<input
type="radio"
name="theme-mode"
value={option.value}
checked={mode === option.value}
onChange={() => setMode(option.value)}
/>
{option.label}
</label>
))}
</fieldset>
<p className="field__hint">{t('settings:appearance.hint')}</p>
<AccentFieldset />
</section>
);
}
/** Akzentfarbe (issue #184, ADR 0018 stage B): presets and a free color as
* ONE mechanism — both run through deriveAccentTokens, so any pick stays
* readable by construction. Same section as the mode radios (the jump-nav
* fence pins the section count). */
function AccentFieldset(): React.JSX.Element {
const { t } = useTranslation();
const [choice, setChoice] = useAccentChoice();
const isCustom = typeof choice === 'object';
// The color input keeps the last custom pick while a preset is selected,
// so re-selecting "custom" restores it instead of jumping to a default.
const [customHex, setCustomHex] = useState(isCustom ? choice.custom : '#2f6f4f');
const derivedPair = (accent: string): { light: string; dark: string } => ({
light: deriveAccentTokens(accent, 'light').accent,
dark: deriveAccentTokens(accent, 'dark').accent,
});
return (
<fieldset className="settings-fieldset">
<legend>{t('settings:appearance.accentLegend')}</legend>
{THEME_PRESETS.map((preset) => (
<label key={preset.id} className="settings-checkbox">
<input
type="radio"
name="theme-accent"
value={preset.id}
checked={choice === preset.id}
onChange={() => setChoice(preset.id)}
/>
{t(`settings:appearance.presets.${preset.id}`)}
<AccentSwatches
// The default preset applies NO override preview the
// hand-tuned tokens.css pair instead of a derived stand-in.
{...(preset.id === DEFAULT_THEME_PRESET_ID
? { light: '#2f6f4f', dark: '#5cb88a' }
: derivedPair(preset.accent))}
/>
</label>
))}
<div className="settings-checkbox">
<input
type="radio"
id="theme-accent-custom"
name="theme-accent"
value="custom"
checked={isCustom}
onChange={() => setChoice({ custom: customHex })}
/>
<label htmlFor="theme-accent-custom">{t('settings:appearance.custom')}</label>
<input
type="color"
className="accent-color-input"
value={isCustom ? choice.custom : customHex}
aria-label={t('settings:appearance.customPick')}
onChange={(event) => {
setCustomHex(event.target.value);
setChoice({ custom: event.target.value });
}}
/>
{isCustom && <AccentSwatches {...derivedPair(choice.custom)} />}
</div>
<p className="field__hint">{t('settings:appearance.accentHint')}</p>
</fieldset>
);
}
/** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel
* abschaltbar machen — lokale Geräte-Einstellung, kein Server-Zustand. */
function InteractionSection(): React.JSX.Element {
const { t } = useTranslation();
const [disabled, setDisabled] = usePersistentState(SINGLE_KEY_SHORTCUTS_KEY, false);
return (
<section className="settings-section">
<h2>{t('settings:interaction.title')}</h2>
<label className="settings-checkbox">
<input
type="checkbox"
checked={disabled}
onChange={(event) => setDisabled(event.target.checked)}
/>
{t('settings:interaction.disableSingleKey')}
</label>
<p className="field__hint">{t('settings:interaction.disableSingleKeyHint')}</p>
</section>
);
}
function DataExportSection(): React.JSX.Element {
const { t, i18n } = useTranslation();
const { status, expiresAt, request, download } = useDataExport();
const formatTime = (iso: string): string =>
new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(iso),
);
return (
<section className="settings-section">
<h2>{t('settings:dataExport.title')}</h2>
<p className="sidebar__hint">{t('settings:dataExport.description')}</p>
{status === 'error' && (
<p className="form-banner form-banner--error" role="alert">
{t('settings:dataExport.failed')}
</p>
)}
{status === 'rateLimited' && (
<p className="form-banner form-banner--error" role="alert">
{t('settings:dataExport.rateLimited')}
</p>
)}
{status === 'ready' ? (
<>
<FormSuccess message={t('settings:dataExport.ready')} />
<button type="button" className="button" onClick={download}>
{t('settings:dataExport.download')}
</button>
{expiresAt && (
<p className="sidebar__hint">
{t('settings:dataExport.expiresHint', { when: formatTime(expiresAt) })}
</p>
)}
</>
) : (
<button type="button" className="button" onClick={request} disabled={status === 'busy'}>
{status === 'busy'
? t('settings:dataExport.preparing')
: t('settings:dataExport.request')}
</button>
)}
</section>
);
}
function ProfileSection(): React.JSX.Element {
const { t, i18n } = useTranslation();
const { user, refresh } = useAuth();
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const form = useForm<{
displayName?: string;
locale?: 'de' | 'en';
autoWatchOwnPages?: boolean;
autoWatchOnComment?: boolean;
digestFrequency?: 'hourly' | 'daily' | 'off';
}>({
resolver: zodResolver(updateProfileInputSchema),
values: {
displayName: user?.displayName,
locale: user?.locale,
autoWatchOwnPages: user?.autoWatchOwnPages,
autoWatchOnComment: user?.autoWatchOnComment,
digestFrequency: user?.digestFrequency,
},
});
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
setSaved(false);
try {
await apiPatch('/users/me', input);
// The new language applies immediately, before the refetch lands.
if (input.locale) await i18n.changeLanguage(input.locale);
await refresh();
setSaved(true);
} catch (err) {
setError(err);
applyFieldErrors(err, (name, fieldError) =>
form.setError(name as 'displayName' | 'locale', fieldError),
);
}
});
return (
<section className="settings-section">
<h2>{t('settings:profile.title')}</h2>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('settings:profile.saved') : null} />
<Field
label={t('settings:profile.displayName')}
error={form.formState.errors.displayName?.message}
>
<input type="text" autoComplete="name" {...form.register('displayName')} />
</Field>
<Field label={t('settings:profile.locale')}>
<select {...form.register('locale')}>
<option value="de">{t('settings:profile.locales.de')}</option>
<option value="en">{t('settings:profile.locales.en')}</option>
</select>
</Field>
<label className="settings-checkbox">
<input type="checkbox" {...form.register('autoWatchOwnPages')} />
{t('watches:prefs.autoWatchOwnPages')}
</label>
<label className="settings-checkbox">
<input type="checkbox" {...form.register('autoWatchOnComment')} />
{t('watches:prefs.autoWatchOnComment')}
</label>
<Field label={t('notifications:digest.label')}>
<select {...form.register('digestFrequency')}>
<option value="hourly">{t('notifications:digest.hourly')}</option>
<option value="daily">{t('notifications:digest.daily')}</option>
<option value="off">{t('notifications:digest.off')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:profile.save')}
</button>
</form>
</section>
);
}
function PasswordSection(): React.JSX.Element {
const { t } = useTranslation();
const [error, setError] = useState<unknown>(null);
const [changed, setChanged] = useState(false);
const form = useForm<{ currentPassword: string; newPassword: string }>({
resolver: zodResolver(changePasswordInputSchema),
});
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
setChanged(false);
try {
await apiPost('/users/me/change-password', input);
form.reset();
setChanged(true);
} catch (err) {
setError(err);
}
});
return (
<section className="settings-section">
<h2>{t('settings:password.title')}</h2>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<FormSuccess message={changed ? t('settings:password.changed') : null} />
<Field
label={t('settings:password.current')}
error={form.formState.errors.currentPassword?.message}
>
<input
type="password"
autoComplete="current-password"
{...form.register('currentPassword')}
/>
</Field>
<Field
label={t('settings:password.new')}
hint={t('auth:signup.passwordHint')}
error={form.formState.errors.newPassword?.message}
>
<input type="password" autoComplete="new-password" {...form.register('newPassword')} />
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:password.submit')}
</button>
</form>
</section>
);
}
function SessionsSection(): React.JSX.Element {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const sessions = useQuery({
queryKey: ['sessions'],
queryFn: () => apiGet<SessionView[]>('/users/me/sessions'),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['sessions'] });
const revoke = useMutation({
mutationFn: (id: string) => apiDelete(`/users/me/sessions/${id}`),
onSuccess: invalidate,
});
const revokeOthers = useMutation({
mutationFn: () => apiDelete('/users/me/sessions'),
onSuccess: invalidate,
});
const formatTime = (iso: string) =>
new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(iso),
);
const others = (sessions.data ?? []).filter((s) => !s.current);
return (
<section className="settings-section">
<h2>{t('settings:sessions.title')}</h2>
{/* A table cannot shrink below its min-content width, so the user-agent
column pushed the whole page into horizontal scrolling at 320px
(issue #301, WCAG 1.4.10). It scrolls inside its own box instead —
the content stays reachable, which `overflow: hidden` would not. */}
<div
className="table-scroll"
// A scroll container is only operable by keyboard once it is
// focusable; role+name keep it from being an unlabelled stop.
tabIndex={0}
role="region"
aria-label={t('settings:sessions.title')}
>
<table className="table">
<thead>
<tr>
<th>{t('settings:sessions.device')}</th>
<th>{t('settings:sessions.created')}</th>
<th>{t('settings:sessions.lastSeen')}</th>
<th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr>
</thead>
<tbody>
{(sessions.data ?? []).map((session) => (
<tr key={session.id}>
<td>
{session.userAgent ?? '—'}
{session.current && (
<span className="badge">{t('settings:sessions.current')}</span>
)}
</td>
<td>{formatTime(session.createdAt)}</td>
<td>{formatTime(session.lastSeenAt)}</td>
<td>
{!session.current && (
<button
type="button"
className="linklike"
onClick={() => revoke.mutate(session.id)}
>
{t('settings:sessions.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{others.length > 0 ? (
<button type="button" className="button" onClick={() => revokeOthers.mutate()}>
{t('settings:sessions.revokeAll')}
</button>
) : (
<p className="sidebar__hint">{t('settings:sessions.empty')}</p>
)}
</section>
);
}