Files
verdande/web/src/routes/indstillinger/+page.svelte
T

691 lines
18 KiB
Svelte
Raw Normal View History

2026-08-17 10:39:46 +02:00
<script>
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import { app, theme, THEMES } 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';
2026-08-17 10:39:46 +02:00
// --- 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));
}
}
2026-08-17 10:39:46 +02:00
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);
2026-08-17 10:39:46 +02:00
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('Det er denne enhed. Du bliver logget ud.')) 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.
*/
2026-08-17 10:39:46 +02:00
</script>
<section class="panel">
<header>
<h2>{t('account.profile')}</h2>
2026-08-17 10:39:46 +02:00
<p class="hint">
{t('account.profileHint')}
2026-08-17 10:39:46 +02:00
</p>
</header>
<form onsubmit={saveProfile}>
<div class="field">
<label for="name">{t('account.name')}</label>
2026-08-17 10:39:46 +02:00
<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>
2026-08-17 10:39:46 +02:00
<!-- 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>
2026-08-17 10:39:46 +02:00
</div>
<div class="field">
<label for="timezone">{t('account.timezone')}</label>
2026-08-17 10:39:46 +02:00
<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>
2026-08-17 10:39:46 +02:00
<select id="locale" bind:value={locale}>
<option value="da">{t('account.danish')}</option>
<option value="en">{t('account.english')}</option>
2026-08-17 10:39:46 +02:00
</select>
<p class="hint">
{t('account.parseHint')}
2026-08-17 10:39:46 +02:00
</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}
2026-08-17 10:39:46 +02:00
</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>
</section>
2026-08-17 10:39:46 +02:00
<section class="panel">
<header>
<h2>{t('account.password')}</h2>
2026-08-17 10:39:46 +02:00
<p class="hint">
{t('account.passwordHint')}
2026-08-17 10:39:46 +02:00
</p>
</header>
<form onsubmit={changePassword}>
<div class="field">
<label for="current">{t('account.currentPassword')}</label>
2026-08-17 10:39:46 +02:00
<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>
2026-08-17 10:39:46 +02:00
<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}
2026-08-17 10:39:46 +02:00
</div>
</form>
</section>
<section class="panel">
<header>
<h2>{t('account.totp')}</h2>
<p class="hint">{t('account.totpHint')}</p>
2026-08-17 10:39:46 +02:00
</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')}
2026-08-17 10:39:46 +02:00
</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>
2026-08-17 10:39:46 +02:00
</div>
</div>
{:else if app.user?.totp_enabled}
<p class="hint">
{t('account.totpEnabled')}{#if remaining !== null}
{plural(remaining, 'account.recoveryLeftOne', 'account.recoveryLeftMany')}{/if}
2026-08-17 10:39:46 +02:00
</p>
<form onsubmit={disableTOTP}>
<div class="field">
<label for="disable-pw">{t('account.password')}</label>
2026-08-17 10:39:46 +02:00
<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')}
2026-08-17 10:39:46 +02:00
</p>
</div>
<div class="row">
<button class="secondary" onclick={regenerate}>{t('account.newRecoveryCodes')}</button>
<button class="danger" type="submit">{t('account.totpOff')}</button>
2026-08-17 10:39:46 +02:00
</div>
</form>
{:else if totpSecret}
<div class="field">
<p class="hint">{t('account.scanHint')}</p>
2026-08-17 10:39:46 +02:00
<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>
2026-08-17 10:39:46 +02:00
<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')}
2026-08-17 10:39:46 +02:00
</p>
</div>
<div class="row">
<button class="primary" type="submit">{t('account.confirm')}</button>
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
2026-08-17 10:39:46 +02:00
</div>
</form>
{:else}
<div class="row">
<button class="primary" onclick={beginTOTP}>{t('account.totpOn')}</button>
2026-08-17 10:39:46 +02:00
</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>
2026-08-17 10:39:46 +02:00
<style>
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;
}
2026-08-17 10:39:46 +02:00
.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);
}
2026-08-17 10:39:46 +02:00
.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>