Files
verdande/web/src/routes/indstillinger/+page.svelte
T
Kristian WindandClaude Opus 5 90bdf4b725 feat(look): en akse mere — hvordan det læses, ikke hvor lyst det er
Fire udseender ved siden af de fem temaer: Verdande, Rolig (serif, mere luft),
Tæt (flere rækker, mindre luft) og Terminal (én bredde til det hele). De ganges ud
til tyve udseender fra otte små blokke CSS frem for at kræve en blok hver.

To akser, fordi det er to spørgsmål. Den, der vil have en serif om natten, skal
ikke opgive den mørke palet for at få den — og prøven holder netop det fast: at
vælge en skrift må ikke trække temaet med sig.

Udseendet sættes før første optegning, ligesom temaet. Ellers bliver siden sat op
i én skrift og derefter i en anden, og det er et synligt spring.

Det er også det ærlige svar på at ligne noget andet. Standarden er bevidst
genkendelig — det var det, der blev bedt om — men et program med ét muligt
udseende har lånt en form, det ikke kan give tilbage. Med udseender er ligheden en
indstilling blandt flere frem for det eneste, det kan være.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:07:39 +02:00

754 lines
20 KiB
Svelte

<script>
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import { app, theme, THEMES, look, LOOKS } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
import { supported, register } from '$lib/passkey.js';
import { ago } from '$lib/when.js';
// --- profile ---------------------------------------------------------------
let name = $state('');
let timezone = $state('');
let locale = $state('da');
// --- passkeys -------------------------------------------------------------------
let passkeys = $state([]);
let passkeyName = $state('');
let passkeyError = $state('');
let registering = $state(false);
let passkeySupported = $state(true);
$effect(() => {
passkeySupported = supported();
if (!passkeySupported) return;
api
.listPasskeys()
.then((r) => {
passkeys = r.passkeys;
// The server has the other half of the answer: this browser can do
// passkeys, and this deployment may still be on an address no
// authenticator will accept.
passkeySupported = r.available;
})
.catch(() => {});
});
async function addPasskey(event) {
event.preventDefault();
passkeyError = '';
registering = true;
try {
passkeys = [...passkeys, await register(passkeyName.trim())];
passkeyName = '';
} catch (e) {
// A cancelled prompt is not a failure — the person changed their mind,
// and an error message for that reads as though something broke.
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
passkeyError = humanMessage(e);
}
} finally {
registering = false;
}
}
async function removePasskey(key) {
if (!confirm(t('passkey.removeQuestion', { name: key.name }))) return;
const previous = passkeys;
passkeys = passkeys.filter((k) => k.id !== key.id);
try {
await api.deletePasskey(key.id);
} catch (e) {
passkeys = previous;
app.toast(humanMessage(e));
}
}
let profileErrors = $state({});
let profileSaved = $state(false);
let savingProfile = $state(false);
let loaded = $state(false);
// Seeded once. Binding straight to app.user would overwrite what somebody is
// halfway through typing every time the store reloads.
$effect(() => {
if (loaded || !app.user) return;
name = app.user.name;
timezone = app.user.timezone;
locale = app.user.locale;
loaded = true;
});
// Whatever the browser knows, plus the current value in case it is not in the
// list — a server configured with a zone this browser has never heard of must
// not have it silently replaced on the next save.
let zones = $derived.by(() => {
let all = [];
try {
all = Intl.supportedValuesOf('timeZone');
} catch {
all = ['Europe/Copenhagen', 'Europe/London', 'UTC'];
}
return timezone && !all.includes(timezone) ? [timezone, ...all] : all;
});
async function saveProfile(event) {
event.preventDefault();
savingProfile = true;
profileErrors = {};
profileSaved = false;
try {
app.user = await api.updateProfile({ name, timezone, locale });
// Applied straight away rather than on the next load. `i18n.locale` is
// $state, so every component that calls t() redraws — which is the whole
// reason the module is .svelte.js.
i18n.set(app.user.locale);
profileSaved = true;
} catch (e) {
profileErrors = e.fields ?? {};
if (!Object.keys(profileErrors).length) app.toast(humanMessage(e));
} finally {
savingProfile = false;
}
}
// --- password ---------------------------------------------------------------
let currentPassword = $state('');
let newPassword = $state('');
let passwordErrors = $state({});
let passwordSaved = $state(false);
async function changePassword(event) {
event.preventDefault();
passwordErrors = {};
passwordSaved = false;
try {
await api.changePassword(currentPassword, newPassword);
currentPassword = '';
newPassword = '';
passwordSaved = true;
} catch (e) {
passwordErrors = e.fields ?? {};
if (!Object.keys(passwordErrors).length) app.toast(humanMessage(e));
}
}
// --- two-factor --------------------------------------------------------------
let totpSecret = $state('');
let totpURI = $state('');
let totpCode = $state('');
let totpErrors = $state({});
let recoveryCodes = $state([]);
let remaining = $state(null);
let disablePassword = $state('');
$effect(() => {
if (app.user?.totp_enabled) {
api.recoveryCodes().then((r) => (remaining = r.remaining)).catch(() => {});
}
});
async function beginTOTP() {
try {
const r = await api.totpSetup();
totpSecret = r.secret;
totpURI = r.uri;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function confirmTOTP(event) {
event.preventDefault();
totpErrors = {};
try {
const r = await api.totpConfirm(totpCode);
recoveryCodes = r.recovery_codes;
totpSecret = '';
totpURI = '';
totpCode = '';
app.user = await api.me();
} catch (e) {
totpErrors = e.fields ?? {};
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
}
}
async function disableTOTP(event) {
event.preventDefault();
totpErrors = {};
try {
await api.totpDisable(disablePassword);
disablePassword = '';
recoveryCodes = [];
remaining = null;
app.user = await api.me();
} catch (e) {
totpErrors = e.fields ?? {};
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
}
}
async function regenerate(event) {
event.preventDefault();
totpErrors = {};
try {
const r = await api.regenerateRecoveryCodes(disablePassword);
recoveryCodes = r.recovery_codes;
remaining = r.recovery_codes.length;
disablePassword = '';
} catch (e) {
totpErrors = e.fields ?? {};
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
}
}
// --- sessions --------------------------------------------------------------
let sessions = $state([]);
$effect(() => {
api.listSessions().then((r) => (sessions = r.sessions)).catch(() => {});
});
async function endSession(session) {
if (session.current) {
if (!confirm(t('account.signOutThisDevice'))) return;
await api.endSession(session.id);
// A full reload rather than clearing the store by hand: the session is
// gone, so every open socket and every cached answer in this tab is now
// about an account nobody is signed in to.
location.href = '/';
return;
}
const previous = sessions;
sessions = sessions.filter((s) => s.id !== session.id);
try {
await api.endSession(session.id);
} catch (e) {
sessions = previous;
app.toast(humanMessage(e));
}
}
/**
* "for 2 minutter siden".
*
* Relative rather than a timestamp, because the question is "was that me, just
* now?" and an absolute time makes the reader do the subtraction. Anything
* older than a week gets its date instead: "for 23 dage siden" is a number
* nobody can place.
*/
</script>
<section class="panel">
<header>
<h2>{t('account.profile')}</h2>
<p class="hint">
{t('account.profileHint')}
</p>
</header>
<form onsubmit={saveProfile}>
<div class="field">
<label for="name">{t('account.name')}</label>
<input
id="name"
bind:value={name}
aria-invalid={profileErrors.name ? 'true' : undefined}
/>
{#if profileErrors.name}<p class="error">{profileErrors.name}</p>{/if}
</div>
<div class="field">
<label for="email">{t('account.email')}</label>
<!-- Read-only: the address identifies the account and is what invitations
were sent to. Changing it is a re-verification flow, not a field. -->
<input id="email" value={app.user?.email ?? ''} readonly disabled />
<p class="hint">{t('account.emailFixed')}</p>
</div>
<div class="field">
<label for="timezone">{t('account.timezone')}</label>
<select id="timezone" bind:value={timezone}>
{#each zones as zone (zone)}
<option value={zone}>{zone}</option>
{/each}
</select>
{#if profileErrors.timezone}<p class="error">{profileErrors.timezone}</p>{/if}
</div>
<div class="field">
<label for="locale">{t('account.parseLanguage')}</label>
<select id="locale" bind:value={locale}>
<option value="da">{t('account.danish')}</option>
<option value="en">{t('account.english')}</option>
</select>
<p class="hint">
{t('account.parseHint')}
</p>
{#if profileErrors.locale}<p class="error">{profileErrors.locale}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={savingProfile}>{t('account.save')}</button>
{#if profileSaved}<span class="saved">{t('account.saved')}</span>{/if}
</div>
</form>
</section>
<section class="panel">
<header>
<h2>{t('account.appearance')}</h2>
<p class="hint">
{t('account.appearanceHint')}
</p>
</header>
<div class="themes">
{#each THEMES as option (option.id)}
<button
class="theme-card"
class:chosen={theme.current === option.id}
onclick={() => theme.set(option.id)}
aria-pressed={theme.current === option.id}
>
<!-- data-theme goes on the swatch and not on the card: it redefines
--ink too, and a card wearing a dark theme's ink on a light page
has a name nobody can read. The swatch shows the colours; the
label stays in the page's. -->
<span class="swatch" data-theme={option.id} aria-hidden="true">
<span class="swatch-line"></span>
<span class="swatch-line short"></span>
<span class="swatch-dot"></span>
</span>
<span class="theme-name">{t(option.name)}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
</div>
<!-- The second axis. A theme says how bright, a look says how it reads — and
they are separate settings because they are separate questions. -->
<div class="looks">
<h3>{t('account.look')}</h3>
<p class="hint">{t('account.lookHint')}</p>
<div class="look-row">
{#each LOOKS as option (option.id)}
<button
class="look-card"
class:chosen={look.current === option.id}
onclick={() => look.set(option.id)}
aria-pressed={look.current === option.id}
>
<!-- Each card is written in the face it selects, because that is the
only part of the choice a name cannot carry. -->
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
<span class="theme-name">{t(option.name)}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
</div>
</div>
</section>
<section class="panel">
<header>
<h2>{t('account.password')}</h2>
<p class="hint">
{t('account.passwordHint')}
</p>
</header>
<form onsubmit={changePassword}>
<div class="field">
<label for="current">{t('account.currentPassword')}</label>
<input
id="current"
type="password"
autocomplete="current-password"
bind:value={currentPassword}
aria-invalid={passwordErrors.current_password ? 'true' : undefined}
/>
{#if passwordErrors.current_password}
<p class="error">{passwordErrors.current_password}</p>
{/if}
</div>
<div class="field">
<label for="new">{t('account.newPassword')}</label>
<input
id="new"
type="password"
autocomplete="new-password"
bind:value={newPassword}
aria-invalid={passwordErrors.new_password ? 'true' : undefined}
/>
{#if passwordErrors.new_password}<p class="error">{passwordErrors.new_password}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit">{t('account.changePassword')}</button>
{#if passwordSaved}<span class="saved">{t('account.passwordChanged')}</span>{/if}
</div>
</form>
</section>
<section class="panel">
<header>
<h2>{t('account.totp')}</h2>
<p class="hint">{t('account.totpHint')}</p>
</header>
{#if recoveryCodes.length}
<!-- Shown once, and the interface has to say so: only the hashes are kept,
so there is no later screen that could show them again. -->
<div class="field">
<p class="hint">
<strong>{t('account.writeThemDown')}</strong> {t('account.recoveryHint')}
</p>
<ul class="codes mono">
{#each recoveryCodes as code (code)}
<li>{code}</li>
{/each}
</ul>
<div class="row">
<button class="secondary" onclick={() => (recoveryCodes = [])}>{t('account.gotThem')}</button>
</div>
</div>
{:else if app.user?.totp_enabled}
<p class="hint">
{t('account.totpEnabled')}{#if remaining !== null}
{plural(remaining, 'account.recoveryLeftOne', 'account.recoveryLeftMany')}{/if}
</p>
<form onsubmit={disableTOTP}>
<div class="field">
<label for="disable-pw">{t('account.password')}</label>
<input
id="disable-pw"
type="password"
autocomplete="current-password"
bind:value={disablePassword}
aria-invalid={totpErrors.password ? 'true' : undefined}
/>
{#if totpErrors.password}<p class="error">{totpErrors.password}</p>{/if}
<p class="hint">
{t('account.passwordRequired')}
</p>
</div>
<div class="row">
<button class="secondary" onclick={regenerate}>{t('account.newRecoveryCodes')}</button>
<button class="danger" type="submit">{t('account.totpOff')}</button>
</div>
</form>
{:else if totpSecret}
<div class="field">
<p class="hint">{t('account.scanHint')}</p>
<p class="mono secret">{totpSecret}</p>
<p class="hint mono uri">{totpURI}</p>
</div>
<form onsubmit={confirmTOTP}>
<div class="field">
<label for="totp">{t('account.codeFromApp')}</label>
<input
id="totp"
bind:value={totpCode}
inputmode="numeric"
autocomplete="one-time-code"
aria-invalid={totpErrors.code ? 'true' : undefined}
/>
{#if totpErrors.code}<p class="error">{totpErrors.code}</p>{/if}
<p class="hint">
{t('account.totpProofHint')}
</p>
</div>
<div class="row">
<button class="primary" type="submit">{t('account.confirm')}</button>
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
</div>
</form>
{:else}
<div class="row">
<button class="primary" onclick={beginTOTP}>{t('account.totpOn')}</button>
</div>
{/if}
</section>
<!-- Between two-factor and the device list, because it belongs to both: a passkey
is a way in, and it is a thing on a device you might later want to revoke. -->
<section class="panel">
<header>
<h2>{t('passkey.title')}</h2>
<p class="hint">{t('passkey.hint')}</p>
</header>
{#if !passkeySupported}
<p class="hint">{t('passkey.unavailable')}</p>
{:else}
{#if passkeys.length}
<ul class="list">
{#each passkeys as key (key.id)}
<li>
<div class="what">
<span class="primary-line">{key.name}</span>
<span class="secondary">
{key.user_verified ? t('passkey.bothFactors') : t('passkey.possessionOnly')}
·
{key.last_used_at ? t('passkey.lastUsed', { when: ago(key.last_used_at) }) : t('passkey.neverUsed')}
</span>
</div>
<button class="secondary" onclick={() => removePasskey(key)}>{t('passkey.remove')}</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">{t('passkey.none')}</p>
{/if}
<form class="row" onsubmit={addPasskey}>
<input bind:value={passkeyName} placeholder={t('passkey.namePlaceholder')} aria-label={t('passkey.name')} />
<button class="primary" type="submit" disabled={registering}>
{registering ? t('passkey.registering') : t('passkey.add')}
</button>
</form>
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
{/if}
</section>
<section class="panel">
<header>
<h2>{t('account.devices')}</h2>
<p class="hint">
{t('account.devicesHint')}
</p>
</header>
<ul class="sessions">
{#each sessions as session (session.id)}
<li>
<div class="what">
<span class="device">
{session.device}
{#if session.current}<span class="badge">{t('account.thisDevice')}</span>{/if}
</span>
<!-- The address and the time, small: they are what settles "was that
me?", and they are not what you read first. -->
<span class="when" title={session.user_agent}>
{ago(session.last_seen_at)}{#if session.ip}{' · ' + session.ip}{/if}
</span>
</div>
<button class="secondary" onclick={() => endSession(session)}>{t('account.signOutDevice')}</button>
</li>
{/each}
</ul>
</section>
<style>
.looks {
margin-top: var(--s4);
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
.looks h3 {
font-size: var(--text-sm);
margin-bottom: var(--s1);
}
.look-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--s2);
margin-top: var(--s3);
}
.look-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s1);
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
text-align: left;
}
.look-card.chosen {
border-color: var(--accent);
}
.look-sample {
font-family: var(--font);
font-size: 1.5rem;
line-height: 1;
color: var(--ink);
}
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.sessions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s1);
}
.sessions li {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s2) 0;
border-bottom: 1px solid var(--line);
}
.sessions li:last-child {
border-bottom: 0;
}
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.device {
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: var(--s2);
}
.badge {
font-size: var(--text-xs);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius-full);
padding: 0 var(--s2);
}
.when {
font-size: var(--text-xs);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codes {
list-style: none;
margin: 0;
padding: var(--s3);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--s2);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.themes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--s3);
}
.theme-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s1);
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
text-align: left;
transition: border-color var(--fast) var(--ease);
}
.theme-card:hover {
border-color: var(--line-strong);
}
.theme-card.chosen {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
/* Painted in the card's own theme rather than the page's — that is the whole
point of putting data-theme on the button. */
.swatch {
width: 100%;
height: 54px;
border-radius: var(--radius-sm);
background: var(--ground);
border: 1px solid var(--line);
padding: var(--s2);
display: flex;
flex-direction: column;
gap: 5px;
justify-content: center;
position: relative;
margin-bottom: var(--s1);
}
.swatch-line {
height: 5px;
width: 70%;
border-radius: var(--radius-full);
background: var(--ink-muted);
}
.swatch-line.short {
width: 45%;
background: var(--ink-faint);
}
.swatch-dot {
position: absolute;
right: var(--s2);
top: var(--s2);
width: 12px;
height: 12px;
border-radius: var(--radius-full);
background: var(--accent);
}
.theme-name {
font-size: var(--text-sm);
color: var(--ink);
}
.theme-note {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.secret {
margin: 0;
padding: var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
letter-spacing: 0.08em;
overflow-wrap: anywhere;
}
.uri {
overflow-wrap: anywhere;
}
</style>