All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m44s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 10m50s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 4m47s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Successful in 10m7s
CI / Import/export fidelity gate (push) Successful in 56s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 1m0s
Prod deploy / Deploy the released images to Prod (push) Successful in 17s
pondSettingsSchema gains theme = { accent: '#rrggbb' | null } (null =
inherit the viewer's theme), exposed as a top-level key of the flat
updatePondInputSchema and included in the PondsService settings merge
(the known silent-no-op pitfall). The server validates only the hex;
conformance arises at render time: PondThemeScope (mounted around the
page content next to PondFontScope) derives the accent pair for the
EFFECTIVE mode via useEffectiveTheme and sets it as inline custom
properties — inline beats both tokens.css and the user-theme <style>,
which IS the cascade precedence pond > user > default.
Pond settings get a PondThemeSection (inherit | presets | custom color
with per-mode preview swatches, explicit save like the font manager);
AccentSwatches extracted for reuse; i18n de+en. The no-JS public shell
stays deliberately un-themed (ADR 0018 amendment).
Tests: pond DB test (theme merge keeps fonts, invalid hex 400), e2e
pond-theme.spec (scope boundary content vs. chrome, per-mode
re-derivation, axe on the pond settings page; resets the fixture pond).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
433 lines
15 KiB
TypeScript
433 lines
15 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>
|
|
<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>
|
|
{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>
|
|
);
|
|
}
|