From 9d9444d0acc8285242819ab881fc469f8bef162a Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 10:39:46 +0200 Subject: [PATCH] feat(web): the settings surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no /indstillinger route at all. The Gmail OAuth callback redirects there and had been answering 404 since it was written. Six pages rather than one long scroll, because the sections have nothing to do with each other and somebody opening API tokens should not have to walk past their own password to get there: the account and its second factor, notifications, integrations, AI, tokens, and data. Everything listed as having no interface in the handoff is reachable from one of them — the calendar feed, the mail-to-task address, Gmail, the AI provider, import, export, templates and the update notice. Two decisions worth naming. The AI key field starts empty and says why: a settings page that repopulates a password field is one that will eventually leak a key into a screenshot, and an empty value means "leave the stored one alone" rather than "clear it". And the form chrome lives in the layout as :global rules, because scoped styles do not reach a child route and six copies of the same twelve selectors is six places for them to drift. Co-Authored-By: Claude Opus 5 --- web/src/lib/api.js | 86 ++++ web/src/lib/components/Sidebar.svelte | 26 ++ web/src/routes/indstillinger/+layout.svelte | 263 +++++++++++++ web/src/routes/indstillinger/+page.svelte | 369 ++++++++++++++++++ web/src/routes/indstillinger/ai/+page.svelte | 188 +++++++++ .../routes/indstillinger/data/+page.svelte | 340 ++++++++++++++++ .../indstillinger/integrationer/+page.svelte | 281 +++++++++++++ .../indstillinger/notifikationer/+page.svelte | 256 ++++++++++++ .../routes/indstillinger/tokens/+page.svelte | 227 +++++++++++ 9 files changed, 2036 insertions(+) create mode 100644 web/src/routes/indstillinger/+layout.svelte create mode 100644 web/src/routes/indstillinger/+page.svelte create mode 100644 web/src/routes/indstillinger/ai/+page.svelte create mode 100644 web/src/routes/indstillinger/data/+page.svelte create mode 100644 web/src/routes/indstillinger/integrationer/+page.svelte create mode 100644 web/src/routes/indstillinger/notifikationer/+page.svelte create mode 100644 web/src/routes/indstillinger/tokens/+page.svelte diff --git a/web/src/lib/api.js b/web/src/lib/api.js index d49240d..cea5fd9 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -90,8 +90,45 @@ async function request(method, path, body, options = {}) { const get = (path, options) => request('GET', path, undefined, options); const post = (path, body, options) => request('POST', path, body ?? {}, options); const patch = (path, body) => request('PATCH', path, body); +const put = (path, body) => request('PUT', path, body); const del = (path) => request('DELETE', path); +/** + * Uploads a file. Not routed through `request`, which serialises JSON and sets a + * Content-Type — a multipart body has to set its own boundary, and the browser + * only does that when nothing has claimed the header. + */ +async function upload(path, file, field = 'file') { + const form = new FormData(); + form.append(field, file); + + let res; + try { + res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' }); + } catch { + throw new ApiError(0, 'offline', 'ingen forbindelse'); + } + + const text = await res.text(); + let payload = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + } + if (!res.ok) { + throw new ApiError( + res.status, + payload?.code ?? 'internal_error', + payload?.error ?? res.statusText, + payload?.fields + ); + } + return payload; +} + export const api = { // --- auth setupState: () => get('/auth/setup'), @@ -100,6 +137,7 @@ export const api = { loginTOTP: (code) => post('/auth/login/totp', { code }), logout: () => post('/auth/logout'), me: () => get('/auth/me'), + updateProfile: (data) => patch('/auth/me', data), signup: (data) => post('/auth/signup', data), forgotPassword: (email) => post('/auth/password/forgot', { email }), resetPassword: (token, password) => post('/auth/password/reset', { token, password }), @@ -175,6 +213,54 @@ export const api = { useTemplate: (id, data) => post(`/templates/${id}/use`, data), deleteTemplate: (id) => del(`/templates/${id}`), + // --- comments and attachments + listComments: (taskId) => get(`/tasks/${taskId}/comments`), + createComment: (taskId, body) => post(`/tasks/${taskId}/comments`, { body }), + updateComment: (id, body) => patch(`/comments/${id}`, { body }), + deleteComment: (id) => del(`/comments/${id}`), + + uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file), + deleteAttachment: (id) => del(`/attachments/${id}`), + attachmentURL: (id) => `/api/v1/attachments/${id}`, + + // --- API tokens + listTokens: () => get('/tokens'), + createToken: (name, expiresInDays) => + post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }), + deleteToken: (id) => del(`/tokens/${id}`), + + // --- notifications and push + listNotifications: () => get('/notifications'), + markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'), + + pushKey: () => get('/push/key'), + subscribePush: (subscription) => post('/push/subscribe', subscription), + unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }), + + // --- integrations + getMailAddress: () => get('/mail-address'), + rotateMailAddress: () => post('/mail-address/rotate'), + + getGmail: () => get('/gmail'), + setGmail: (data) => put('/gmail', data), + disconnectGmail: () => del('/gmail'), + authorizeGmail: () => post('/gmail/authorize'), + syncGmail: () => post('/gmail/sync'), + + getAISettings: () => get('/ai/settings'), + setAISettings: (data) => put('/ai/settings', data), + aiSummary: () => post('/ai/summary'), + aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`), + + version: () => get('/version'), + + // --- import and export + importTodoist: (file) => upload('/import/todoist', file), + importCSV: (data) => post('/import/csv', data), + exportAccountURL: () => '/api/v1/export/account', + exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`, + exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`, + // --- views today: () => get('/today'), upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`), diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 5766eea..6d5043d 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -154,6 +154,21 @@ {/if}
+ + + Indstillinger + + {#if !app.connected} @@ -310,6 +325,17 @@ gap: var(--s2); } + .settings svg { + width: 15px; + height: 15px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + } + .offline { font-size: var(--text-xs); color: var(--ink-faint); diff --git a/web/src/routes/indstillinger/+layout.svelte b/web/src/routes/indstillinger/+layout.svelte new file mode 100644 index 0000000..9f35d65 --- /dev/null +++ b/web/src/routes/indstillinger/+layout.svelte @@ -0,0 +1,263 @@ + + +
+
+

Indstillinger

+
+ + + +
+ {@render children()} +
+
+ + diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte new file mode 100644 index 0000000..be8777f --- /dev/null +++ b/web/src/routes/indstillinger/+page.svelte @@ -0,0 +1,369 @@ + + +
+
+

Profil

+

+ Tidszonen er ikke pynt: alle datoer i appen bliver afgjort i den, så + “i morgen kl. 9” betyder noget andet, når du skifter den. +

+
+ +
+
+ + + {#if profileErrors.name}

{profileErrors.name}

{/if} +
+ +
+ + + +

Kan ikke ændres her.

+
+ +
+ + + {#if profileErrors.timezone}

{profileErrors.timezone}

{/if} +
+ +
+ + +

+ Afgør hvilken grammatik der tolker en linje — “hver mandag” + eller “every monday”. +

+ {#if profileErrors.locale}

{profileErrors.locale}

{/if} +
+ +
+ + {#if profileSaved}Gemt.{/if} +
+
+
+ +
+
+

Adgangskode

+

+ At skifte den logger alle andre sessioner ud — hvilket for det meste er + grunden til at gøre det. +

+
+ +
+
+ + + {#if passwordErrors.current_password} +

{passwordErrors.current_password}

+ {/if} +
+ +
+ + + {#if passwordErrors.new_password}

{passwordErrors.new_password}

{/if} +
+ +
+ + {#if passwordSaved}Skiftet.{/if} +
+
+
+ +
+
+

To-faktor

+

En engangskode fra en authenticator-app oven i adgangskoden.

+
+ + {#if recoveryCodes.length} + +
+

+ Skriv dem ned nu. De vises kun denne ene gang — serveren + har kun deres hash. +

+
    + {#each recoveryCodes as code (code)} +
  • {code}
  • + {/each} +
+
+ +
+
+ {:else if app.user?.totp_enabled} +

+ Slået til.{#if remaining !== null} + {remaining} gendannelseskode{remaining === 1 ? '' : 'r'} tilbage.{/if} +

+ +
+
+ + + {#if totpErrors.password}

{totpErrors.password}

{/if} +

+ Kræves til begge handlinger nedenfor. At slå to-faktor fra er præcis + det, en fremmed med din session ville gøre først. +

+
+ +
+ + +
+
+ {:else if totpSecret} +
+

Scan i din authenticator-app, eller indtast nøglen manuelt.

+

{totpSecret}

+

{totpURI}

+
+ +
+
+ + + {#if totpErrors.code}

{totpErrors.code}

{/if} +

+ To-faktor er først slået til, når en kode har bevist, at appen faktisk + har nøglen. +

+
+ +
+ + +
+
+ {:else} +
+ +
+ {/if} +
+ + diff --git a/web/src/routes/indstillinger/ai/+page.svelte b/web/src/routes/indstillinger/ai/+page.svelte new file mode 100644 index 0000000..78a5124 --- /dev/null +++ b/web/src/routes/indstillinger/ai/+page.svelte @@ -0,0 +1,188 @@ + + +
+
+

AI

+

+ Bruges to steder: at dele en opgave op i undertasks, og at skrive et kort + overblik over det, der er udestående. Nøglen ligger på din server og sendes + kun til den udbyder, du vælger her. +

+
+ + {#if settings === null} +

…

+ {:else} +
+
+ + + {#if errors.provider}

{errors.provider}

{/if} +
+ + {#if settings.provider} +
+ + +
+ + {#if settings.provider === 'compatible'} +
+ + +
+ {/if} + +
+ + +

+ {#if settings.has_key} + Feltet er tomt med vilje. Nøglen sendes aldrig tilbage til browseren + — en indstillingsside, der fylder et kodeordsfelt ud igen, er en side, + der en dag lækker en nøgle ud i et skærmbillede. + {:else} + Gemmes på serveren og vises aldrig igen. + {/if} +

+
+ {/if} + +
+ + {#if saved}Gemt.{/if} +
+
+ {/if} +
+ +{#if settings?.provider && settings?.has_key} +
+
+

Ugentligt overblik

+

En kort note om, hvad der ser vigtigst ud lige nu.

+
+ +
+ +
+ + {#if summary} +

{summary}

+ {/if} +
+{/if} + + diff --git a/web/src/routes/indstillinger/data/+page.svelte b/web/src/routes/indstillinger/data/+page.svelte new file mode 100644 index 0000000..1f9e271 --- /dev/null +++ b/web/src/routes/indstillinger/data/+page.svelte @@ -0,0 +1,340 @@ + + +
+
+

Importér fra Todoist

+

+ Vælg CSV-eksporten af ét projekt. Prioriteterne bliver konverteret, ikke + kopieret: Todoist skriver 4 for det, grænsefladen kalder P1. +

+
+ +
+ + +
+ + {#if importing} +

Importerer …

+ {/if} + + {#if result} +
+

+ {result.tasks} opgave{result.tasks === 1 ? '' : 'r'}, {result.sections} + sektion{result.sections === 1 ? '' : 'er'} og {result.comments} + kommentar{result.comments === 1 ? '' : 'er'} hentet ind. +

+ {#if result.warnings?.length} + +
    + {#each result.warnings as warning, i (i)} +
  • {warning}
  • + {/each} +
+ {/if} + {#if result.project_id} + + {/if} +
+ {/if} +
+ +
+
+

Eksport

+

+ Alt, i den rå form. Det er garantien for, at du kan gå igen — en + eksport, der er blevet pyntet, er en eksport, der har mistet noget. +

+
+ + + +
+ + +
+ + {#if exportProject} + + {/if} +
+ +
+
+

Skabeloner

+

+ Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er + relative, så de bliver regnet ud fra den startdato, du vælger. +

+
+ +
+
+ + +
+ + {#if saveFrom} +
+ + +
+ +
+ +
+ {/if} +
+ + {#if loadingTemplates} +

…

+ {:else if templates.length === 0} +

Ingen skabeloner endnu.

+ {:else} +
    + {#each templates as template (template.id)} +
  • +
    + {template.name} + + {template.task_count} opgave{template.task_count === 1 ? '' : 'r'} + +
    + + +
  • + + {#if usingId === template.id} +
  • +
    +
    + + +
    + +
    + + +

    Dag nul for skabelonens datoer. Tom betyder i dag.

    +
    + +
    + +
    +
    +
  • + {/if} + {/each} +
+ {/if} +
+ + diff --git a/web/src/routes/indstillinger/integrationer/+page.svelte b/web/src/routes/indstillinger/integrationer/+page.svelte new file mode 100644 index 0000000..ce6cf45 --- /dev/null +++ b/web/src/routes/indstillinger/integrationer/+page.svelte @@ -0,0 +1,281 @@ + + +
+
+

Gmail

+

+ Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. + Envejs: at fjerne stjernen gør ikke noget ved opgaven. +

+
+ + {#if callback} +

{callback.text}

+ {/if} + + {#if gmail === null} +

…

+ {:else if !gmail.connected} +
+ +
+

+ Kræver at administratoren har registreret en OAuth-klient hos Google og sat + VERDANDE_GMAIL_CLIENT_ID og + _SECRET. +

+ {:else} +

Forbundet som {gmail.email || 'ukendt konto'}.

+ +
+
+ + +
+ + {#if gmail.trigger === 'label' || gmail.trigger === 'both'} +
+ + +
+ {/if} + +
+ + {#if gmailSaved}Gemt.{/if} + + +
+
+ {/if} +
+ +
+
+

Kalenderfeed

+

+ Abonnér i Apple Kalender, Google eller Thunderbird. En kalenderklient kan + ikke logge ind, så nøglen i adressen er hele adgangskoden — del den + ikke. +

+
+ +
+ + +
+ +
+ + +
+

+ Et nyt link bryder alle eksisterende abonnementer med det samme. Det er + pointen: det er det, man gør, når det gamle er havnet et forkert sted. +

+
+ +
+
+

Mail til opgave

+

+ Send eller videresend en mail hertil, og emnelinjen bliver tolket som en + hurtig tilføjelse — “Fakturer Anders p1 #Firma” virker også her. +

+
+ +
+ + +
+ +
+ + +
+
+ +
+
+

CalDAV

+

+ Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird + kan også skrive tilbage. +

+
+ +
+ + +

+ Brugernavn er din e-mail. Adgangskoden er en + API-token — ikke din rigtige adgangskode. +

+
+
+ + diff --git a/web/src/routes/indstillinger/notifikationer/+page.svelte b/web/src/routes/indstillinger/notifikationer/+page.svelte new file mode 100644 index 0000000..aeaf225 --- /dev/null +++ b/web/src/routes/indstillinger/notifikationer/+page.svelte @@ -0,0 +1,256 @@ + + +
+
+

Notifikationer på enheden

+

+ Påmindelser og beskeder fra andre, også når fanen er lukket. Beskeden + krypteres til din browser undervejs — push-tjenesten kan ikke læse den. +

+
+ + {#if pushState === 'loading'} +

…

+ {:else if pushState === 'unsupported'} +

+ Denne browser understøtter det ikke. Safari kræver, at appen er lagt på + hjemmeskærmen; over almindelig HTTP virker det kun på localhost. +

+ {:else if pushState === 'blocked'} +

+ Browseren har blokeret notifikationer for dette site. Det kan kun laves om i + browserens egne indstillinger — en side kan ikke spørge igen, når svaret har + været nej. +

+ {:else if pushState === 'on'} +

Slået til på denne enhed.

+
+ +
+ {:else} +
+ +
+

Gælder kun den browser, du sidder i nu.

+ {/if} +
+ +
+
+

Seneste

+ {#if unread > 0} +

{unread} ulæst{unread === 1 ? '' : 'e'}.

+ {/if} +
+ + {#if loading} +

…

+ {:else if notifications.length === 0} +

Ingenting endnu.

+ {:else} +
    + {#each notifications as note (note.id)} +
  • +
    + {note.title} + {#if note.body}{note.body}{/if} + + {#if note.actor_name}{note.actor_name} · {/if}{when(note.created_at)} + +
    + {#if note.task_id && note.project_id} + Åbn + {/if} +
  • + {/each} +
+ + {#if unread > 0} +
+ +
+ {/if} + {/if} +
+ +{#if version} +
+
+

Version

+
+ +

+ Kører {version.current}. + {#if version.disabled} + Der bliver ikke set efter opdateringer. + {:else if version.update_available} + {version.latest} er ude. + {:else if version.checked_at} + Ingen nyere version fundet. + {/if} +

+ + {#if version.update_available} + {#if version.notes} +

{version.notes}

+ {/if} + {#if version.url} + + {/if} + {/if} +
+{/if} + + diff --git a/web/src/routes/indstillinger/tokens/+page.svelte b/web/src/routes/indstillinger/tokens/+page.svelte new file mode 100644 index 0000000..c3c887d --- /dev/null +++ b/web/src/routes/indstillinger/tokens/+page.svelte @@ -0,0 +1,227 @@ + + +
+
+

API-tokens

+

+ Det, et script, en CalDAV-klient eller en MCP-forbindelse logger ind med. En + token bærer dine rettigheder præcist — den kan intet, du ikke selv kan. +

+
+ + {#if fresh} +
+

+ Kopiér den nu. Den vises kun denne ene gang — serveren har + kun dens hash. +

+

{fresh.token}

+
+ + {#if copied}Kopieret.{/if} + +
+
+ {/if} + +
+
+ + + {#if errors.name}

{errors.name}

{/if} +
+ +
+ + +

+ En kalenderklient, der har abonneret i årevis, har brug for + “aldrig”. Et script, du kører én gang, har ikke. +

+ {#if errors.expires_in_days}

{errors.expires_in_days}

{/if} +
+ +
+ +
+
+ + {#if loading} +

…

+ {:else if tokens.length === 0} +

Ingen tokens endnu.

+ {:else} +
    + {#each tokens as token (token.id)} +
  • +
    + {token.name} + + {token.prefix}… + {#if token.last_used_at} + · sidst brugt {date(token.last_used_at)} + {:else} + · aldrig brugt + {/if} + {#if token.expires_at} + · {expired(token.expires_at) ? 'udløbet' : 'udløber'} + {date(token.expires_at)} + {/if} + +
    + +
  • + {/each} +
+ {/if} +
+ +