Files
verdande/web/src/routes/indstillinger/+page.svelte
T
Kristian WindandClaude Opus 5 c4e480a0c4
Release / Build and publish image (push) Failing after 10m6s
fix(gmail): stop hiding why it failed, twice over
Reported from use: "Fetch now" said "something went wrong" and the error log was
empty. Both halves were the same bug wearing two hats.

`handleGmailSyncNow` answered 502 with the code `internal_error`, which the
interface maps to a generic sentence — so Google's own words, which are the whole
diagnosis, were replaced by one that says nothing. And `writeError` records
nothing; only `s.internal` does. So there was no message to read and nowhere to
look it up afterwards.

`s.upstream()` is the answer: 502 because this is somebody else's service saying
no rather than this server breaking, a code of its own so the interface can quote
what was said, and a row in the error log — which is the entire reason that log
exists. A Gmail sync failing quietly for a week is otherwise invisible: the
background job writes a line the next restart erases.

The same connection also left the mailbox unnamed. The address is learned at
connect time and that call can fail; a warning went to the log and an empty string
to the database, so the page said "an unknown account" and kept saying it. The only
way back was to disconnect and reconnect, which nobody would guess. The status
endpoint asks again when it finds none, so a wrong answer lasts until the next look
rather than until somebody reconnects.

Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including
every destructive confirmation in the app — the worst possible set to leave in one
language, since it is what somebody reads in the half-second before agreeing to
lose something. The i18n guard reads markup and attributes, and a string argument
lives in the script block. It reads those now, and found four my own grep missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:28:44 +02:00

691 lines
18 KiB
Svelte

<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';
// --- 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>
</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>
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>