Compare commits

...
19 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 0dbea0ea4b feat: make it possible to see which version is actually running
Release / Build and publish image (push) Failing after 9m56s
"I have updated and restarted, but I cannot tell whether it worked" is
not a question the panel could answer. The version was on the startup
line and in the settings page, and nowhere you can reach from outside.

/healthz carries it now. That is the one endpoint answering without a
session, which makes it the only way to ask the question from a terminal
or from the panel itself. The "listening" line repeats it too, because
that is the line the Rune's done_regex matches and therefore the one
sitting at the bottom of the console after a deploy.

The update notice could also never have worked. It reads GitHub's
releases/latest, and this repository had only tags — a tag is not a
release, and that endpoint answers 404 for a repository without any. The
release workflow creates one now, after the image is pushed so a release
never announces a version that failed to build.

VERDANDE_UPDATE_CHECK is a Rune variable, so it can be switched on
without editing the container. Still off by default: it is an outbound
request, and that is a choice to make rather than a default to inherit.

Rune version 1 -> 2, so the panel offers the update.

Note that the check still cannot see anything while the repository is
private — releases/latest is 404 anonymously, exactly as the GHCR package
was. Making the repo public, or teaching the check to carry a token, is a
separate decision.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:35:36 +02:00
Kristian WindandClaude Opus 5 78ec9b08a1 fix: say the true thing when Gmail, AI or mail is not set up
Two bugs with one shape: the interface confidently told you something
that was not so.

Connecting Gmail without a registered OAuth client said "Det er der
allerede." Thirteen different situations answered a plain `conflict`, and
the frontend renders that as "that already exists" — which for four of
them is not vague but wrong. An unconfigured Gmail is not a Gmail that is
already connected; a second factor that is off is not one that is on; the
Inbox is not a project that already exists somewhere else. Those four
have their own codes now, and their own sentences.

The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM
defaults to verdande@localhost and the domain was taken from it
unconditionally, so an instance with no mail server handed out an address
that looks real, can never receive anything, and gives no hint which of
the two it is. The default no longer wins over the public hostname, and
the response now carries whether a mail server exists at all — because
even with the right domain, nothing routes to it until one does.

errors.go has always claimed the code list is kept in one place "so the
frontend's mapping can be checked against it". Nothing checked it, and
the mapping was missing bad_request. Now a test reads both files and
fails on a code with no message.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:35:36 +02:00
Kristian WindandClaude Opus 5 7e28d8bb92 chore: untrack a vite temp file, and stop it coming back
Release / Build and publish image (push) Failing after 9m56s
vite.config.js.timestamp-*.mjs is written while bundling the config and
removed again — unless the process is killed, which is exactly what
happens during a hung build. One had been committed by accident; five
more appeared while working out why the build was hanging.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 e63d852847 feat(mcp): let the connector address carry its own key
verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.

Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.

So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.

It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.

The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 d7e250f860 docs(rune): a rune does not update itself, and the server need not be deleted
Two corrections after checking against Yggdrasil's own behaviour rather
than guessing at it.

The advice to delete the failed server and make another was wrong. The
image reference is read from the rune at container-create time, and both
Start and Restart recreate the container — so once the image can be
fetched, pressing Start on the server you already have is enough.

And a rune does not pick up a changed manifest on its own. The panel
writes the rune row from a UI action only; neither a restart nor a timer
does it. So a new manifest needs Runes → verdande → Update, and the
catalogue list is cached in memory for ten minutes, which is long enough
that two attempts in a row can both hand back the old version.

The host-pull fallback now says what it costs: the panel re-pulls on
every recreate, fails quietly, and carries on with the local image, so a
new release looks like it rolled out when it did not. It is a stopgap for
keeping the package private, not a way to run this.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 12:27:02 +02:00
Kristian WindandClaude Opus 5 a8a29a963d docs: the registry has to be public, and docker login was the wrong advice
Installing the Rune still failed after v0.2.0 was published, with the same
message as before:

    create container: Error response from daemon:
    No such image: ghcr.io/kristianwind/verdande:latest

The advice in these pages — log the host's Docker daemon in — cannot
work, and Yggdrasil's source says why. Its PullImage calls the Docker
SDK's ImagePull with an empty image.PullOptions{}; RegistryAuth is never
set, and there are no registry credentials anywhere in the panel. A
private image answers 401. pullImageRetry discards that error on purpose
("second failure surfaces at create"), and ContainerCreate — which only
ever looks locally — then reports the image as missing. The message is a
liar: the tag is in the registry the whole time.

docker login does not change this. It is client-side: the daemon stores
no credentials, the CLI sends them itself with each request, and
Yggdrasil is a different client that sends none.

So the package has to be public, and the pages now say that rather than
offering a login that does nothing. Keeping it private is still possible
but is now described as what it is: putting the image on the host by hand
before creating the server and again after every release.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 11:55:56 +02:00
Kristian WindandClaude Opus 5 25f1e2ff82 fix(e2e): wait for the sidebar before counting its links
Release / Build and publish image (push) Failing after 9m55s
The suite passed on a laptop and failed on the runner with zero links
found. locator.all() is a snapshot and does not auto-wait: calling it
straight after goto() measures how fast the machine is, not what the page
contains.

An assertion on the first link waits for hydration, and the hrefs are
collected once and re-resolved on each pass rather than held as element
handles — navigating re-renders the sidebar, so a handle from before the
click points at an element that no longer exists.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 5b1a05348f chore(shots): capture the settings surface too
The tool exists so the images can be regenerated when the interface
changes, and the interface just gained six pages that nothing in the list
would ever photograph.

The integrations page rather than the account one: it shows the calendar
feed and the mail-to-task address, which are the parts people ask what
they look like. Account settings are a password form.

The PNGs in site/screenshots are not regenerated here — that wants a
running instance with realistic data, which is a decision about
presentation rather than a build step.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 1eb6fd930d docs: the registry is private, and the handoff is current again
Installing the Rune failed with

    create container: Error response from daemon:
    No such image: ghcr.io/kristianwind/verdande:latest

which was two things wearing one message. No semver tag had ever been
pushed, so release.yml had never run and the image did not exist. And now
that v0.1.0 is published, the package is private because the repository
is — and an anonymous pull reports that as "no such image" rather than as
a permission error, which sends you looking in the wrong place. The
Docker daemon on the host has to be logged in once, as whichever user
runs it. Said in rune.md, self-hosting.md, the README and the handoff,
because whichever one you are holding is the one that has to say it.

The handoff's seven gaps are down to the ones that need real credentials
and real services: Gmail against an account, Web Push against a push
service, PNG icons for iOS, the AI adapters against a key. Added in their
place are the traps this round cost the most time on — that the embedded
frontend is a copy that npm run build does not touch, that Playwright
re-runs its config in every worker, and that svelte-check is not a gate
in a codebase with no type annotations.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 cb77990458 docs(api): an OpenAPI spec, checked against the router
The brief asked for one. docs/api.md is complete and hand-written, and
there was nothing a tool could read.

Written by hand rather than generated from annotations, which would have
meant every handler carrying a second copy of itself in a comment. The
risk with a hand-written spec is that it goes stale, so it does not
depend on anybody remembering: openapi_test.go walks the chi router and
fails both on a route nothing describes and on a description with no
route behind it. CI separately checks it is a valid OpenAPI document.

The test reads the file with a forty-line scanner rather than pulling in
a YAML library. It needs two things — which paths are listed and which
methods each has — and both sit at fixed indentation in a document the
test also owns, which is worth more than a dependency in a project whose
whole shape is one static binary.

The CalDAV routes are excluded deliberately: PROPFIND and REPORT are
outside what OpenAPI can describe, and docs/caldav.md covers them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 1424f43a40 test(e2e): smoke tests that drive the real binary
The brief asked for these under Definition of done and they were never
written. 610 Go tests, none of which opens a browser.

Six flows: create the first account and sign in, quick add and tick a
task off, click every link in the sidebar, open a task and edit it, walk
every settings tab, and check that the manifest's icons and the service
worker can actually be fetched. Every assertion is on rendered content
rather than on a URL — the binary serves the SPA shell for anything it
does not recognise, so toHaveURL passes on precisely the bug this exists
to catch.

The suite builds the frontend, copies it into cmd/verdande/webbuild and
starts the real binary, every run. Merely starting the server would test
whatever frontend was copied there last, which during development is
nearly always the wrong one, and a green report on code that is not
running is worse than no report.

Chromium only. A smoke test catches a build that does not work at all;
running it in three engines finds the same breakage three times.

They found four bugs the first time they ran, fixed in the commits before
this one: concurrent writes returning SQLITE_BUSY, a completed task
keeping its row, the same task landing in the list twice, and a database
failure presenting as everybody being logged out.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 1bb28ce862 feat(web): the service worker, and subscribing to Web Push
The server side has been finished for a while — VAPID keys, RFC 8291
encryption, delivery on reminders and assignments — and nothing in the
browser ever asked for a subscription. web/static/ had a manifest and an
icon and no sw.js, so a subscription could not be created at all.

The worker handles push and notificationclick and nothing else. There is
deliberately no offline cache: the app is a live view of a list other
people are editing, and a cached shell serving yesterday's data is worse
than the browser saying it cannot connect. Adding one later means
versioning and invalidating it, which is a feature rather than a few
lines here.

Unsubscribing tells the server first. If the browser unsubscribed and
then the request failed, the server would keep pushing to an endpoint
nothing is listening on, and only a 410 from the push service would ever
clean it up.

Still not verified against a live push service — this is the client half
of a chain whose other half has only ever been tested against the RFC.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 c2a664d891 feat(web): drag to reorder in the list view
The board has had it since it was written; the list has not, and
POST /tasks/{id}/move was already tested and working.

Where the board shows the target column, a list has to show the target
gap — the whole decision is "above this one or below it" — so a line is
drawn where the row would land, chosen by which half of the row the
pointer is in rather than by counting rows, which is what keeps it where
the eye expects it when rows have different heights.

The move sends both neighbours. The API's after_id is the task above,
which reads as the opposite of "after" in a vertical list, so the locals
here are named for where they end up on screen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 d288606827 feat(web): open a task
TaskRow has had an onedit prop that nothing passed. There was no way to
see a task's description, sub-tasks, comments, files or reminders, all of
which the API has supported from the start.

A drawer rather than a page, so the list you came from stays behind it
and ticking a sub-task off and closing lands you where you were rather
than at the top of a re-fetched list. Mounted in the root layout and
driven by an id in the store, so a task opens the same way from Today, a
project, a label or a saved filter — and so an edit arriving over the
socket reaches the drawer without a second source of truth to keep in
step.

Text fields save on blur. A PATCH per keystroke is forty requests for one
sentence, and the last to arrive is not necessarily the last one sent.

The navigation shortcuts are suppressed while it is open: with a task in
front of you, "t" means the letter t.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 9d9444d0ac feat(web): the settings surface
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 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 fc4dba8549 fix(web): every change to the task list builds a new array
Ticking a task off never removed its row. The optimistic update ran, the
request went out, the server answered 200 — and the view did not move.
The same applied to a change arriving over the websocket, which is how
somebody else's edit was supposed to show up. It had never worked.

The common factor was tasks[i] = value. push and whole-array assignment
both reached the views; index assignment did not. Rather than depend on
which mutations the reactivity happens to see through, every write now
goes through app.replace() or app.upsert(), which build a new array. The
lists are at most a few hundred rows and the copy is not worth a moment's
thought next to the class of bug it removes.

upsert exists because the same task arrives twice: once as the response
to the request that created it, and once over the socket, which publishes
to the whole project including whoever did it. Either can land first, and
appending both puts two rows with one id into a keyed {#each} — not a
duplicated row but a thrown error that stops the view rendering.

complete, reopen and update were three copies of the same twenty lines;
they are now one private helper.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 68d82a1cb6 feat(api): personal API tokens, and let people change their own profile
docs/api.md has told people to make a token under Settings since it was
written. The store has held CreateAPIToken, ListAPITokens and
DeleteAPIToken since the first migration. There were no routes: nothing
between the two, and no way to make one.

GET, POST and DELETE under /tokens, all behind a new requireSession
middleware. A bearer token is refused there with 403 even though it is
accepted everywhere else — otherwise a leaked token is permanent, because
whoever has it issues a second one and revoking the first changes
nothing. The plaintext comes back from POST and never again; only the
hash is stored, so the interface has to say so at that moment rather than
offering a reveal that cannot work.

PATCH /auth/me alongside it. Name, timezone and language could not be
changed anywhere — not through the API and not through the interface —
and an account created from an invite inherits a default timezone. That
is not cosmetic: every date in the app is resolved in it, so somebody in
another country had every due date land an hour out with no way to say
so. The zone is validated against the running system's database rather
than a list kept here, which is the same database the dates are resolved
with. The email address stays read-only: it identifies the account and is
what invites were sent to.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 06ab10a276 fix(httpapi): a broken database must not look like a failed login
authenticate() cannot tell "no such session" from "the session lookup
failed" on its own — both arrive as an error. Answering 401 for both
meant a disk or file-permission problem presented as every session
ending at once: the frontend clears its state and shows the sign-in
screen to everybody, and the logs record a wave of failed logins rather
than the fault.

Now only a missing or unknown credential is 401. Anything else is a 500
and is logged as what it is.

Found while writing the smoke tests, where a deleted database file
produced SQLITE_CANTOPEN on the connections that had not been opened
yet — so most requests succeeded and a few reported the user as signed
out, which reads exactly like a flaky auth bug.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 40a679edb6 fix(store): write transactions take the lock at BEGIN, not partway through
Two people saving at the same moment got a 500 after two milliseconds.

The DSN has set busy_timeout since the first commit, and it covered none
of the contention it looks like it covers. With a plain BEGIN a write
transaction starts as a reader and asks for the write lock at its first
UPDATE — and SQLite refuses that upgrade with SQLITE_BUSY immediately,
without applying busy_timeout to it. Backing off cannot help: the
transaction is already holding a read snapshot the other writer is
invalidating.

_txlock=immediate takes the lock at BEGIN instead, where the timeout does
apply and the second writer waits its turn. The driver leaves read-only
transactions deferred, so readers still run concurrently, and every one
of the twenty callers of db.Tx is a write.

Found by opening a task and saving a description, a sub-task and a
comment at once — three writes, one of which came back a 500. The test
fails without the flag.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00
55 changed files with 7365 additions and 115 deletions
+56
View File
@@ -45,6 +45,62 @@ jobs:
- name: Build
run: CGO_ENABLED=0 go build ./...
spec:
name: OpenAPI is valid
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
# Structural validity only. That the spec matches the *router* is checked by
# a Go test, which is the half that actually goes stale.
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
e2e:
name: End-to-end
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install
working-directory: web
run: npm ci --no-audit --no-fund
# Chromium only. A smoke test exists to catch a build that does not work at
# all; running it three times in three engines finds the same breakage three
# times and costs three times as much.
- name: Install Chromium
working-directory: web
run: npx playwright install --with-deps chromium
# The suite builds the frontend, embeds it and starts the real binary — see
# playwright.config.js. There is nothing to build here first.
- name: Test
working-directory: web
run: npm run test:e2e
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
web/test-results/
web/playwright-report/
retention-days: 7
docker:
name: Docker image builds
runs-on: ubuntu-latest
+15 -1
View File
@@ -6,7 +6,9 @@ on:
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
permissions:
contents: read
# write rather than read: the job creates the GitHub Release the update notice
# reads. packages: write is for pushing the image.
contents: write
packages: write
jobs:
@@ -50,3 +52,15 @@ jobs:
VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
# A tag is not a release. The in-app update notice reads GitHub's
# releases/latest, which answers 404 for a repository that only has tags —
# so without this step the check can never find anything, however correctly
# it is switched on.
#
# Created after the image, so a release never announces a version that
# failed to build.
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
make_latest: true
+12
View File
@@ -31,5 +31,17 @@ node_modules/
/web/build/
/web/.svelte-kit/
# End-to-end runs: the throwaway instance the suite starts, the saved session it
# signs in with, and whatever a failure left behind.
/web/.playwright-data/
/web/e2e/.auth/
/web/test-results/
/web/playwright-report/
# Built documentation
/site_build/
# Vite writes these while bundling vite.config.js and removes them again — unless
# the process is killed, which during a hung build is exactly what happens. One
# was committed by accident.
/web/vite.config.js.timestamp-*.mjs
+11 -4
View File
@@ -63,6 +63,9 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
| Task detail: description, sub-tasks, comments, files, reminders | Done |
| Settings: account, notifications, integrations, AI, tokens, data | Done |
| Drag to reorder, in both the list and the board | Done |
| Recurring tasks (RRULE, Danish + English) | Done, tested |
| Reminders, absolute and relative | Done, tested |
| Saved filters with a query language | Done, tested |
@@ -78,13 +81,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
| Update notice for administrators | Done, tested |
| Personal API tokens | Done, tested |
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
**Not finished:** Web Push, the AI providers and the Gmail API calls are
implemented against their specifications but have only been exercised there, not
against live services. And the interface does not yet expose comments,
attachments, reminders, templates, import/export or the settings pages — all of it
works through the API.
implemented against their specifications and have only ever been exercised there,
not against live services. Everything else has an interface: opening a task shows
its description, sub-tasks, comments, files and reminders, and the settings pages
cover the account, notifications, integrations, AI, API tokens, import, export and
templates.
Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an
accented `o` and a Danish-first app that cannot do this is broken for its own users.
+5 -1
View File
@@ -77,7 +77,11 @@ func run() error {
errc := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Addr)
// The version is repeated here rather than only on the "starting" line
// above: this is the line the Rune's done_regex matches, so it is the one
// sitting at the bottom of the panel console after a deploy — which is
// exactly when somebody wants to know what actually started.
log.Info("listening", "addr", cfg.Addr, "version", version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
+30
View File
@@ -19,6 +19,16 @@ curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks
Make one under **Settings → API tokens**. It carries your permissions exactly —
there is no separate service identity.
## The machine-readable spec
[`openapi.yaml`](openapi.yaml) describes the same API as an OpenAPI 3.1 document —
for generating a client, for importing into Bruno or Insomnia, or for reading in a
tool that renders it.
It is written by hand, and a test walks the router on every build and fails if an
endpoint is routed but not described, or described but no longer routed. So it is
as current as this page is, which is to say: as current as CI is green.
## Errors
Every failure has the same shape:
@@ -42,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
| `forbidden` | 403 | Cross-site request, or admin needed. |
| `not_found` | 404 | Does not exist, or you cannot see it. |
| `conflict` | 409 | Already exists, or the state does not allow it. |
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
| `ai_not_configured` | 409 | No AI provider has been set up. |
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
| `validation_failed` | 422 | Check `fields`. |
| `rate_limited` | 429 | Too many attempts. |
| `internal_error` | 500 | Our fault. |
@@ -118,6 +132,22 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `GET` | `/export/projects/{id}.csv` | Todoist-compatible. |
| `GET` | `/export/projects/{id}.ics` | A calendar file. |
### API tokens
| Method | Path | |
|---|---|---|
| `GET` `POST` | `/tokens` | List, or mint one. `name` is required; `expires_in_days` is optional, and `0` means it never expires. |
| `DELETE` | `/tokens/{id}` | Revoked immediately. |
The plaintext token comes back from `POST` and never again — only its hash is
stored. The listing shows a `prefix`, which is enough to tell two of your own
tokens apart.
!!! warning "These three need a signed-in session"
A bearer token is refused here with `403`, even though it is accepted
everywhere else. Otherwise a leaked token would be permanent: whoever had it
could issue a second one, and revoking the first would change nothing.
### Everything else
| Method | Path | |
+27 -4
View File
@@ -12,17 +12,40 @@ conversation.
## Connecting it
**Make a token.** **Settings → API tokens → New token**. It is shown once.
**Make a token** under **Settings → API tokens**. The page then shows the
connector address to go with it — token and all — which is the thing to copy:
**Add the connector.** In Claude, add a custom connector with:
```
https://todo.example.dk/mcp?key=vrd_…
```
- **URL**: `https://todo.example.dk/api/v1/mcp`
- **Authentication**: Bearer token, using the token you just made
**Add the connector** in Claude with that address, and nothing else. The dialog
asks for a name and a URL; leave the OAuth fields empty.
!!! note "Why the key is in the address"
The dialog has no field for a bearer token — only OAuth client credentials —
so a header cannot be configured from it. The address *is* the credential
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
Treat it like a password: anyone holding it reaches what you reach. The
server logs request paths without their query strings, so it does not end up
in the log.
!!! warning "It has to be reachable from the internet"
Claude connects from Anthropic's side, not from your browser. An instance only
reachable on your LAN or over a VPN cannot be used this way.
### With a header instead
Anything that *can* send a header should, and use the API path:
```bash
curl -H "Authorization: Bearer vrd_…" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
https://todo.example.dk/api/v1/mcp
```
Both endpoints run the same server and reach the same things.
## What Claude can do
| Tool | What it does |
+2282
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -3,6 +3,54 @@
verdande ships with a Rune manifest for
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
## If the image will not pull
```
create container: Error response from daemon:
No such image: ghcr.io/kristianwind/verdande:latest
```
This message is a liar. It does not mean the tag is missing from the registry —
it means the image is not on the host, and the panel's attempt to fetch it failed
without saying so.
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
looks locally — then reports the image as missing.
!!! warning "`docker login` on the host does not help"
`docker login` is client-side. The daemon stores no credentials; the CLI
sends them itself with each request. Yggdrasil is a different client and
sends none, so logging in changes nothing for the panel.
**Make the package public.** Under
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
→ *Change visibility*. The repository stays private; only the built image becomes
readable, which is what the manifest and these pages have always assumed.
Once it is public, press **Start** on the server you already have. There is no
need to delete it and make another: the image reference is read from the rune at
container-create time, and both Start and Restart recreate the container.
To keep the package private you have to put the image on the host yourself,
before creating the server, and again after every release:
```bash
sudo docker login ghcr.io -u kristianwind
sudo docker pull ghcr.io/kristianwind/verdande:latest
```
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
panel's own pull still fails silently; create simply finds the image already
there.
!!! danger "This hides updates"
The panel re-pulls on every recreate, fails quietly, and carries on with the
local image. A new release then *looks* like it rolled out and did not. For
something you intend to update, public is the right answer and host-pull is
the stopgap.
## Installing
1. **Runes → Carve a rune (upload)** and pick
@@ -12,6 +60,16 @@ verdande ships with a Rune manifest for
3. Fill in the settings below.
4. Start it, open the URL, create the first account.
!!! note "Updating the rune is a manual step"
A rune does not update itself when this manifest changes. The panel writes
the rune row from a UI action only — neither a restart nor a timer does it —
so pick up a new manifest with **Runes → verdande → Update**, or install it
again from Browse GitHub.
The catalogue list is cached in memory for ten minutes, so two attempts in a
row can both hand you the old version. Recent panel versions show the list's
age with a *fetch again* link.
## Settings
| Setting | What to put |
+7
View File
@@ -5,6 +5,13 @@ cache, no queue and no second process.
## Docker
!!! note "If the pull is refused"
The GHCR package is public, so this needs no credentials. If it is ever set
back to private, `docker login ghcr.io` with a token carrying the
`read:packages` scope before pulling — and see
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
fails differently and much more confusingly under Yggdrasil.
```bash
docker run -d --name verdande \
--restart unless-stopped \
+66
View File
@@ -156,6 +156,72 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, toUserJSON(userFrom(r.Context())))
}
type updateProfileRequest struct {
Name *string `json:"name"`
Timezone *string `json:"timezone"`
Locale *string `json:"locale"`
}
// handleUpdateProfile changes name, timezone and language.
//
// The timezone is not cosmetic: every date in the app is resolved in it, so "i
// morgen kl 9" means a different instant after this call than before. Which is
// exactly why it has to be changeable — an account created from an invite
// inherits a default, and somebody in another country would otherwise have every
// due date land an hour out with no way to say so.
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
var req updateProfileRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
name, timezone, locale := user.Name, user.Timezone, user.Locale
fields := map[string]string{}
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
if name == "" {
fields["name"] = "required"
} else if utf8.RuneCountInString(name) > 80 {
fields["name"] = "must be at most 80 characters"
}
}
if req.Timezone != nil {
timezone = strings.TrimSpace(*req.Timezone)
// Checked against the running system's zone database rather than a list
// kept here: a hardcoded list goes stale every time a country moves its
// clocks, and this one is the same database the dates are resolved with.
if _, err := time.LoadLocation(timezone); err != nil || timezone == "" {
fields["timezone"] = "must be a zone like Europe/Copenhagen"
}
}
if req.Locale != nil {
locale = strings.TrimSpace(*req.Locale)
// The locale picks which quick-add grammar parses a line, so an unknown
// one would quietly stop "i morgen" being understood.
if locale != "da" && locale != "en" {
fields["locale"] = "must be da or en"
}
}
if len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
s.storeError(w, "update profile", err)
return
}
updated, err := s.db.UserByID(r.Context(), user.ID)
if err != nil {
s.internal(w, "reload user", err)
return
}
writeJSON(w, http.StatusOK, toUserJSON(updated))
}
// --- creating accounts --------------------------------------------------------
type signupRequest struct {
+75
View File
@@ -686,3 +686,78 @@ func mustHash(t *testing.T, password string) string {
}
return hash
}
// A database that cannot be read must not look like a failed login.
//
// authenticate() cannot tell the two apart on its own: both come back as an error
// from the session lookup. Answering 401 for both means a disk problem presents as
// every session ending at once — the frontend clears its state and shows the
// sign-in screen to everybody — and the logs record a wave of failed logins rather
// than the fault. Found by the end-to-end suite, where a deleted database file
// turned into exactly that.
func TestDatabaseFailureIsNotALogout(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Still signed in, and the session cookie is in the jar.
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("me: status %d", resp.StatusCode)
}
// Pull the database out from under the server. Every query now fails with
// something that is not "no such session".
if err := ts.db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode == http.StatusUnauthorized {
t.Fatal("a broken database signed the user out instead of reporting a fault")
}
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("status %d, want 500", resp.StatusCode)
}
if body["code"] != CodeInternal {
t.Errorf("code = %v, want %q", body["code"], CodeInternal)
}
}
// The other half of the same contract: no cookie at all is still a plain 401.
func TestMissingCredentialsAreStillUnauthorized(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := anonymous.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status %d, want 401", resp.StatusCode)
}
if body["code"] != CodeUnauthorized {
t.Errorf("code = %v, want %q", body["code"], CodeUnauthorized)
}
}
// And a cookie that is not a session is a 401 too, not a 500 — an unknown token is
// a wrong credential, which is the client's problem and not the server's.
func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "verdande_session", Value: "ikke-en-rigtig-token"})
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status %d, want 401", resp.StatusCode)
}
}
+149
View File
@@ -0,0 +1,149 @@
package httpapi
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// The comment above the code list in errors.go says they are kept in one place
// "so the frontend's mapping can be checked against it rather than discovered
// from whatever a handler happened to write". Nothing checked it, and the
// frontend was quietly missing entries — which is not a cosmetic gap: a code
// with no message falls through to the server's English log prose, and a code
// mapped to the wrong sentence tells the person something untrue. Connecting
// Gmail without an OAuth client said "Det er der allerede."
var (
codeConst = regexp.MustCompile(`(?m)^\s*Code\w+\s*=\s*"([a-z_]+)"`)
messageKey = regexp.MustCompile(`(?m)^\s*([a-z_]+):\s*$|^\s*([a-z_]+):\s*'`)
messagesTop = "const MESSAGES = {"
)
func TestEveryErrorCodeHasAMessageInTheFrontend(t *testing.T) {
codes := serverErrorCodes(t)
messages := frontendMessageKeys(t)
var missing []string
for _, code := range codes {
if !messages[code] {
missing = append(missing, code)
}
}
sort.Strings(missing)
for _, code := range missing {
t.Errorf("%q is returned by the API and has no message in web/src/lib/api.js — "+
"a person would be shown the server's English log prose", code)
}
}
func serverErrorCodes(t *testing.T) []string {
t.Helper()
source, err := os.ReadFile("errors.go")
if err != nil {
t.Fatalf("read errors.go: %v", err)
}
var codes []string
for _, m := range codeConst.FindAllStringSubmatch(string(source), -1) {
codes = append(codes, m[1])
}
if len(codes) == 0 {
t.Fatal("no codes were read out of errors.go — the scanner and the file disagree")
}
return codes
}
// frontendMessageKeys reads the keys of the MESSAGES object. A small scanner
// rather than a JS parser, for the same reason the OpenAPI check uses one: it
// needs one thing out of a file this test also owns.
func frontendMessageKeys(t *testing.T) map[string]bool {
t.Helper()
source, err := os.ReadFile(filepath.Join("..", "..", "web", "src", "lib", "api.js"))
if err != nil {
t.Fatalf("read api.js: %v", err)
}
body := string(source)
start := strings.Index(body, messagesTop)
if start < 0 {
t.Fatalf("could not find %q in api.js", messagesTop)
}
body = body[start+len(messagesTop):]
end := strings.Index(body, "\n};")
if end < 0 {
t.Fatal("the MESSAGES object does not end where this scanner expects")
}
keys := map[string]bool{}
for _, line := range strings.Split(body[:end], "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
name, _, ok := strings.Cut(line, ":")
if ok {
keys[strings.TrimSpace(name)] = true
}
}
if len(keys) == 0 {
t.Fatal("no message keys were read out of api.js")
}
return keys
}
// The four that used to be indistinguishable from "that already exists".
func TestTheMisleadingConflictsHaveTheirOwnCodes(t *testing.T) {
for _, code := range []string{
CodeGmailNotConfigured,
CodeAINotConfigured,
CodeTOTPNotEnabled,
CodeInboxProtected,
} {
if code == CodeConflict {
t.Errorf("%q is still plain conflict", code)
}
}
messages := frontendMessageKeys(t)
for _, code := range []string{"gmail_not_configured", "ai_not_configured",
"totp_not_enabled", "inbox_protected"} {
if !messages[code] {
t.Errorf("%q has no Danish message", code)
}
}
}
// The Inbox is the one project that cannot be deleted, and saying "that already
// exists" about it helps nobody.
func TestDeletingTheInboxSaysWhy(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
list, _ := projects["projects"].([]any)
var inboxID string
for _, raw := range list {
p := raw.(map[string]any)
if p["is_inbox"] == true {
inboxID = p["id"].(string)
}
}
if inboxID == "" {
t.Fatal("no inbox was found")
}
resp, body := ts.do(t, "DELETE", "/api/v1/projects/"+inboxID, nil)
if resp.StatusCode != 409 {
t.Fatalf("status %d, want 409", resp.StatusCode)
}
if body["code"] != CodeInboxProtected {
t.Errorf("code = %v, want %q", body["code"], CodeInboxProtected)
}
}
+11
View File
@@ -36,6 +36,17 @@ const (
CodeRateLimited = "rate_limited"
CodeInternal = "internal_error"
CodePayloadTooLarge = "payload_too_large"
// Four situations that used to answer plain `conflict`, which the frontend
// renders as "that already exists". For these that is not merely vague, it is
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
// second factor that is off is not one that is on. A code exists so the
// interface can say the true thing — the server's own prose is English for a
// log, and deciding what a person reads is the frontend's job.
CodeGmailNotConfigured = "gmail_not_configured"
CodeAINotConfigured = "ai_not_configured"
CodeTOTPNotEnabled = "totp_not_enabled"
CodeInboxProtected = "inbox_protected"
)
func writeError(w http.ResponseWriter, status int, code, message string) {
+2 -2
View File
@@ -30,8 +30,8 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
cfg := s.gmailConfig()
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeConflict,
"Gmail er ikke sat op på denne server — administratoren skal registrere en OAuth-klient hos Google")
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
"no Google OAuth client is registered on this server")
return
}
+27 -7
View File
@@ -18,6 +18,12 @@ import (
type mailAddressResponse struct {
Address string `json:"address"`
// Configured says whether a mail server has been set up at all. Without one
// the address is still minted and still valid — the token is real, and the
// endpoint that receives mail works — but nothing on the internet routes to
// it yet, and an interface that shows the address without saying so is
// promising something that will not happen.
Configured bool `json:"configured"`
}
// handleGetMailAddress returns the personal address that turns an email into a
@@ -34,7 +40,10 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
s.internal(w, "mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
writeJSON(w, http.StatusOK, mailAddressResponse{
Address: s.mailAddress(token),
Configured: s.cfg.SMTP.Host != "",
})
}
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
@@ -47,7 +56,10 @@ func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request)
s.internal(w, "set mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
writeJSON(w, http.StatusOK, mailAddressResponse{
Address: s.mailAddress(token),
Configured: s.cfg.SMTP.Host != "",
})
}
func (s *Server) mailAddress(token string) string {
@@ -55,7 +67,15 @@ func (s *Server) mailAddress(token string) string {
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
// The mail server's own domain, which is the one that can actually route
// this — the web address may well be a tunnel hostname that receives no mail.
domain = from[strings.Index(from, "@")+1:]
//
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
// verdande@localhost, so an instance with no mail server at all was
// handing out todo+…@localhost: an address that looks real, cannot
// possibly receive anything, and gives no hint which of the two it is.
// The public hostname is at least a domain that exists.
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
domain = d
}
}
return "todo+" + token + "@" + domain
}
@@ -251,8 +271,8 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
}
if !cfg.Configured() {
// Not an error the user did anything to cause: the feature is simply off.
writeError(w, http.StatusConflict, CodeConflict,
"der er ikke sat en AI-udbyder op under indstillinger")
writeError(w, http.StatusConflict, CodeAINotConfigured,
"no AI provider is configured")
return
}
@@ -299,8 +319,8 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
return
}
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeConflict,
"der er ikke sat en AI-udbyder op under indstillinger")
writeError(w, http.StatusConflict, CodeAINotConfigured,
"no AI provider is configured")
return
}
+158
View File
@@ -771,3 +771,161 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
t.Error("the current version is hidden from members")
}
}
// --- MCP with the key in the URL --------------------------------------------------
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
// token — so this is the only shape that can be configured from it.
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
if err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
var decoded map[string]any
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
t.Fatalf("the response was not JSON-RPC: %v", err)
}
result, _ := decoded["result"].(map[string]any)
if result == nil || result["protocolVersion"] == nil {
t.Errorf("no protocol version was announced: %v", decoded)
}
}
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
// SPA fallback and answered 200 with the app shell. A connector pointed at it
// reported success and then failed to parse a page of HTML.
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
}
}
}
// A revoked key stops working, like any other.
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
var id string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
t.Fatal(err)
}
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
t.Fatal(err)
}
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
}
}
// The session cookie must NOT work here. This route sits outside the CSRF check,
// so a cookie-authenticated POST would be a cross-site request that acts as
// whoever is signed in — the exact hole the key-only rule exists to close.
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// ts.client holds the session from bootstrap. No key in the URL.
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
resp.StatusCode)
}
}
// --- mail to task ------------------------------------------------------------------
// The address used to come out as todo+…@localhost on any instance without a
// mail server, because VERDANDE_SMTP_FROM defaults to verdande@localhost and the
// domain was taken from it unconditionally. That address looks real, cannot
// receive anything, and gives no hint which of the two it is.
func TestMailAddressDoesNotClaimToBeLocalhost(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://todo.example.dk"
// Left at the default, as an instance with no mail server has it.
c.SMTP.From = "verdande@localhost"
})
ts.bootstrap(t)
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
address, _ := body["address"].(string)
if strings.HasSuffix(address, "@localhost") {
t.Errorf("address = %q — that can never receive mail", address)
}
if !strings.HasSuffix(address, "@todo.example.dk") {
t.Errorf("address = %q, want the public hostname", address)
}
if body["configured"] != false {
t.Error("no SMTP host is set, so configured should be false")
}
}
// A real sender address still wins: the mail server's own domain is the one that
// can actually route this, and the web address may be a tunnel hostname.
func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://tunnel-abc123.example.net"
c.SMTP.Host = "mail.firma.dk"
c.SMTP.From = "[email protected]"
})
ts.bootstrap(t)
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
address, _ := body["address"].(string)
if !strings.HasSuffix(address, "@firma.dk") {
t.Errorf("address = %q, want the sender's domain", address)
}
if body["configured"] != true {
t.Error("an SMTP host is set, so configured should be true")
}
}
+38
View File
@@ -3,11 +3,13 @@ package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/mcp"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
@@ -124,6 +126,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
// handleMCPWithKey is the same endpoint for a client that can only be given a
// URL: `POST /mcp?key=vrd_…`.
//
// Claude's custom-connector dialog asks for a name, an address and — under
// advanced settings — an OAuth client id and secret. There is no field for a
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
// address is. The calendar feed already carries its credential in the URL for
// the same reason, and the request logger records the path without the query, so
// the key does not end up in the log.
//
// The key is the *only* thing accepted here. Reading a session cookie instead
// would make this a state-changing POST outside the CSRF check — which is to say
// a cross-site request that acts as whoever is signed in.
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key == "" || !auth.IsAPIToken(key) {
// 401 rather than 404: unlike a task, the existence of this endpoint is
// not a secret — it is written in the documentation.
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"add ?key=<api token> to the URL")
return
}
user, err := s.db.UserByAPIToken(r.Context(), key)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
s.internal(w, "mcp key", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
return
}
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
}
// --- tool implementations ------------------------------------------------------------
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+14 -1
View File
@@ -78,6 +78,14 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, session, err := s.authenticate(r)
if err != nil {
// A database that could not be read is not a person who is not signed
// in. Answering 401 for both means an infrastructure problem shows up
// as everybody being logged out at once — the frontend clears its state
// and shows the sign-in screen — and nothing in the logs says why.
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
s.internal(w, "authenticate", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
return
}
@@ -123,6 +131,11 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler {
// Both are accepted everywhere. An API token belongs to a person and carries their
// permissions exactly — there is no separate service identity to reason about, and
// a script therefore cannot reach anything its owner could not.
// errNoCredentials means the request carried nothing to authenticate with, as
// opposed to carrying something that turned out to be wrong — or to the lookup
// itself failing, which callers must not treat as a failed login.
var errNoCredentials = errors.New("no credentials")
func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, error) {
if token := bearerToken(r); token != "" && auth.IsAPIToken(token) {
user, err := s.db.UserByAPIToken(r.Context(), token)
@@ -134,7 +147,7 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err
cookie, err := r.Cookie(s.cookieName())
if err != nil || cookie.Value == "" {
return nil, nil, errors.New("no credentials")
return nil, nil, errNoCredentials
}
session, user, err := s.db.SessionByToken(r.Context(), cookie.Value)
if err != nil {
+158
View File
@@ -0,0 +1,158 @@
package httpapi
import (
"bufio"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// docs/openapi.yaml is written by hand, which means it can drift from the router
// the moment somebody adds an endpoint. This walks the real router and compares
// the two in both directions: a route with nothing written about it, and a path
// documented that no longer exists.
//
// The spec is read with a small scanner rather than a YAML library. It needs two
// things — which paths are listed and which methods each one has — and both live
// at fixed indentation in a document this file also owns. That is worth more than
// a dependency in a project whose whole shape is "one static binary".
// specRoutes reads "method path" pairs out of the `paths:` section.
func specRoutes(t *testing.T) map[string]bool {
t.Helper()
f, err := os.Open(filepath.Join("..", "..", "docs", "openapi.yaml"))
if err != nil {
t.Fatalf("open spec: %v", err)
}
defer f.Close()
methods := map[string]bool{
"get": true, "post": true, "put": true, "patch": true, "delete": true,
"head": true, "options": true, "trace": true,
}
routes := map[string]bool{}
inPaths := false
path := ""
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
continue
}
// A top-level key ends the paths section.
if !strings.HasPrefix(line, " ") {
inPaths = strings.HasPrefix(line, "paths:")
continue
}
if !inPaths {
continue
}
switch {
// Two spaces: a path.
case strings.HasPrefix(line, " /"):
path = strings.TrimSuffix(strings.TrimSpace(line), ":")
// Four spaces: a key under it, which is a method or `parameters`.
case path != "" && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " "):
key := strings.TrimSuffix(strings.TrimSpace(line), ":")
if methods[key] {
routes[strings.ToUpper(key)+" "+path] = true
}
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("read spec: %v", err)
}
if len(routes) == 0 {
t.Fatal("no paths were read out of the spec — the scanner and the file disagree")
}
return routes
}
// undocumented is what the spec is not expected to describe.
func undocumented(method, path string) bool {
switch {
// The SPA fallback and static assets: not an API.
case path == "/*", path == "/":
return true
// CalDAV speaks PROPFIND and REPORT, which OpenAPI has no way to describe.
// The discovery URL is in the spec; the rest is documented in docs/caldav.md.
case strings.HasPrefix(path, "/caldav"):
return true
// chi registers OPTIONS and HEAD of its own accord in places; neither carries
// behaviour worth writing down.
case method == http.MethodOptions || method == http.MethodHead:
return true
}
return false
}
func TestOpenAPICoversEveryRoute(t *testing.T) {
spec := specRoutes(t)
// The server itself rather than the test harness's httptest wrapper: this
// needs the router, and nothing here sends a request.
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
srv := New(&config.Config{
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(),
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
routed := map[string]bool{}
err = chi.Walk(srv.router,
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
// chi writes the index of a sub-router as a trailing slash; the spec
// names it without one.
if route != "/" {
route = strings.TrimSuffix(route, "/")
}
if !undocumented(method, route) {
routed[method+" "+route] = true
}
return nil
})
if err != nil {
t.Fatalf("walk routes: %v", err)
}
var missing, stale []string
for route := range routed {
if !spec[route] {
missing = append(missing, route)
}
}
for route := range spec {
if !routed[route] {
stale = append(stale, route)
}
}
sort.Strings(missing)
sort.Strings(stale)
for _, route := range missing {
t.Errorf("routed but not in docs/openapi.yaml: %s", route)
}
for _, route := range stale {
t.Errorf("in docs/openapi.yaml but not routed: %s", route)
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
writeError(w, http.StatusConflict, CodeConflict, "the Inbox cannot be deleted")
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
return
}
s.storeError(w, "delete project", err)
+27 -1
View File
@@ -90,6 +90,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// authenticated by the token in the recipient address.
r.Post("/inbound/mail", s.handleInboundMail)
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
// it at all. Same reasoning as the calendar feed above: a client that cannot
// be told to send a header means the token in the URL is the credential.
//
// Outside /api/v1 deliberately, because it must not be reachable with a
// session cookie. A cookie-authenticated POST with no CSRF check is a
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
// nothing else.
r.Post("/mcp", s.handleMCPWithKey)
// Google sends the browser here after consent. Outside /api/v1 because it is a
// top-level navigation rather than a fetch, but still behind the session — the
// tokens it exchanges belong to whoever is signed in.
@@ -131,6 +143,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireAuth)
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Post("/totp/setup", s.handleTOTPSetup)
@@ -190,6 +203,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
// Personal API tokens. Behind requireSession rather than the ambient
// auth: a token must not be able to mint or revoke another.
r.Route("/tokens", func(r chi.Router) {
r.Use(s.requireSession)
r.Get("/", s.handleListAPITokens)
r.Post("/", s.handleCreateAPIToken)
r.Delete("/{tokenID}", s.handleDeleteAPIToken)
})
r.Route("/ai", func(r chi.Router) {
r.Get("/settings", s.handleGetAISettings)
r.Put("/settings", s.handleSetAISettings)
@@ -326,7 +348,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
// The version travels with the health check because this is the one endpoint
// that answers without a session. "Which version is actually running?" is
// otherwise a question you cannot ask from outside — and after a deploy it is
// the first question there is.
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": Version})
}
// serveWeb serves the PWA, falling back to index.html so that client-side routes
+143
View File
@@ -0,0 +1,143 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// Personal API tokens: what a script, a CalDAV client or an MCP connector
// authenticates with. The store has held them since the first migration; this is
// the surface that lets somebody actually make one.
//
// Every route here needs a *session*, not merely an authenticated caller — see
// requireSession. Managing credentials is something a person does in a browser.
type apiTokenJSON struct {
ID string `json:"id"`
Name string `json:"name"`
// Prefix is the first few characters, which is all that is ever shown again.
Prefix string `json:"prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// createdTokenJSON is the only response that carries the token itself, and only
// the once. The store keeps a hash, so there is nothing to reveal later even if
// the interface offered to.
type createdTokenJSON struct {
apiTokenJSON
Token string `json:"token"`
}
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list api tokens", err)
return
}
out := make([]apiTokenJSON, 0, len(tokens))
for _, t := range tokens {
j := apiTokenJSON{
ID: t.ID, Name: t.Name, Prefix: t.Prefix,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
}
if t.LastUsedAt != nil {
j.LastUsedAt = t.LastUsedAt.Format(time.RFC3339)
}
if t.ExpiresAt != nil {
j.ExpiresAt = t.ExpiresAt.Format(time.RFC3339)
}
out = append(out, j)
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}
type createTokenRequest struct {
Name string `json:"name"`
// ExpiresInDays is optional; 0 means the token does not expire, which is what
// a calendar client subscribed for years actually needs.
ExpiresInDays int `json:"expires_in_days"`
}
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeFieldErrors(w, map[string]string{"name": "required"})
return
}
if len([]rune(name)) > 80 {
writeFieldErrors(w, map[string]string{"name": "must be at most 80 characters"})
return
}
// A negative lifetime would mint a token that is already dead — accepted
// silently it looks like the feature is broken rather than the input.
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
writeFieldErrors(w, map[string]string{
"expires_in_days": "must be between 0 and 3650, where 0 means no expiry",
})
return
}
var expires *time.Time
if req.ExpiresInDays > 0 {
at := time.Now().UTC().AddDate(0, 0, req.ExpiresInDays)
expires = &at
}
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
if err != nil {
s.internal(w, "create api token", err)
return
}
j := createdTokenJSON{
apiTokenJSON: apiTokenJSON{
ID: token.ID, Name: token.Name, Prefix: token.Prefix,
CreatedAt: token.CreatedAt.Format(time.RFC3339),
},
Token: plaintext,
}
if expires != nil {
j.ExpiresAt = expires.Format(time.RFC3339)
}
s.log.Info("api token created", "user", userFrom(r.Context()).ID, "name", name)
writeJSON(w, http.StatusCreated, j)
}
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
if err != nil {
s.storeError(w, "delete api token", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// requireSession refuses a caller who authenticated with an API token.
//
// authenticate() accepts a bearer token everywhere else on purpose: a token
// carries its owner's permissions exactly. Minting tokens is the one thing that
// must not follow that rule, because it is how a single leaked token becomes
// permanent access — the thief issues a second one and revoking the first
// changes nothing. Deleting is held to the same standard so a token cannot
// revoke the session-holder's other credentials either.
func (s *Server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if sessionFrom(r.Context()) == nil {
writeError(w, http.StatusForbidden, CodeForbidden,
"API tokens are managed while signed in, not with an API token")
return
}
next.ServeHTTP(w, r)
})
}
+166
View File
@@ -0,0 +1,166 @@
package httpapi
import (
"net/http"
"strings"
"testing"
)
func TestAPITokenLifecycle(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "GET", "/api/v1/tokens", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list: status %d, body %v", resp.StatusCode, body)
}
if tokens, _ := body["tokens"].([]any); len(tokens) != 0 {
t.Errorf("a fresh account already has tokens: %v", tokens)
}
resp, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{
"name": "min server", "expires_in_days": 30,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: status %d, body %v", resp.StatusCode, created)
}
plaintext, _ := created["token"].(string)
if !strings.HasPrefix(plaintext, "vrd_") {
t.Fatalf("token %q does not carry the scanner-visible prefix", plaintext)
}
if created["expires_at"] == nil {
t.Error("an expiry was asked for and none came back")
}
prefix, _ := created["prefix"].(string)
if !strings.HasPrefix(plaintext, prefix) {
t.Errorf("prefix %q is not the start of the token", prefix)
}
// The token authenticates as its owner.
req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+plaintext)
me, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("bearer request: %v", err)
}
defer me.Body.Close()
if me.StatusCode != http.StatusOK {
t.Errorf("the new token does not authenticate: status %d", me.StatusCode)
}
// Listing shows it, without the secret.
_, body = ts.do(t, "GET", "/api/v1/tokens", nil)
tokens, _ := body["tokens"].([]any)
if len(tokens) != 1 {
t.Fatalf("want one token listed, got %v", tokens)
}
listed := tokens[0].(map[string]any)
if _, leaked := listed["token"]; leaked {
t.Error("the listing carries the token itself")
}
if listed["name"] != "min server" {
t.Errorf("name = %v", listed["name"])
}
id, _ := created["id"].(string)
resp, _ = ts.do(t, "DELETE", "/api/v1/tokens/"+id, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
// And is dead the moment it is deleted.
req, _ = http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
me, err = (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("bearer request: %v", err)
}
defer me.Body.Close()
if me.StatusCode != http.StatusUnauthorized {
t.Errorf("a deleted token still works: status %d", me.StatusCode)
}
}
// A token must not be able to mint another one. Otherwise a leaked token is
// permanent: revoking it leaves behind the one it issued.
func TestAPITokenCannotMintAnother(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "første"})
plaintext, _ := created["token"].(string)
for _, tc := range []struct{ method, path string }{
{"GET", "/api/v1/tokens"},
{"POST", "/api/v1/tokens"},
{"DELETE", "/api/v1/tokens/" + created["id"].(string)},
} {
req, err := http.NewRequest(tc.method, ts.URL+tc.path, strings.NewReader(`{"name":"anden"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+plaintext)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with a bearer token: status %d, want 403",
tc.method, tc.path, resp.StatusCode)
}
}
}
func TestAPITokenValidation(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, tc := range []struct {
name string
body map[string]any
field string
}{
{"no name", map[string]any{"name": " "}, "name"},
{"negative lifetime", map[string]any{"name": "x", "expires_in_days": -1}, "expires_in_days"},
{"absurd lifetime", map[string]any{"name": "x", "expires_in_days": 4000}, "expires_in_days"},
} {
t.Run(tc.name, func(t *testing.T) {
resp, body := ts.do(t, "POST", "/api/v1/tokens", tc.body)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, want 422", resp.StatusCode)
}
fields, _ := body["fields"].(map[string]any)
if fields[tc.field] == nil {
t.Errorf("no error on %q: %v", tc.field, body)
}
})
}
}
// Somebody else's token id must delete nothing, and must not confirm it exists.
func TestAPITokenDeleteIsScopedToOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "min"})
id := created["id"].(string)
other := ts.newUser(t, "[email protected]", "Anden")
resp, _ := other.do(t, "DELETE", "/api/v1/tokens/"+id, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404 for a token belonging to somebody else", resp.StatusCode)
}
// Still there for its owner.
_, body := ts.do(t, "GET", "/api/v1/tokens", nil)
if tokens, _ := body["tokens"].([]any); len(tokens) != 1 {
t.Errorf("the owner's token was affected: %v", tokens)
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
user := userFrom(r.Context())
if !user.TOTPEnabled {
writeError(w, http.StatusConflict, CodeConflict,
writeError(w, http.StatusConflict, CodeTOTPNotEnabled,
"two-factor authentication is not switched on")
return
}
+14
View File
@@ -38,6 +38,20 @@ func Open(path string) (*DB, error) {
"_pragma=busy_timeout(5000)",
"_pragma=foreign_keys(ON)",
"_pragma=synchronous(NORMAL)",
// Every write transaction takes the write lock up front.
//
// Without this, `BEGIN` is deferred: a transaction starts as a reader and
// asks for the write lock at its first UPDATE. SQLite refuses that upgrade
// with SQLITE_BUSY *immediately* and does not apply busy_timeout to it —
// backing off cannot help, because the transaction is already holding a
// read snapshot that the other writer is invalidating. So the timeout above
// looks like it covers writer contention and covers none of it: two people
// saving at the same moment get a 500 in two milliseconds.
//
// `immediate` takes the lock at BEGIN instead, where busy_timeout does
// apply and the second writer waits its turn. The driver leaves read-only
// transactions deferred, so readers still run concurrently.
"_txlock=immediate",
// Keep the temp b-trees FTS5 and ORDER BY build in memory rather than
// spilling them into the data volume.
"_pragma=temp_store(MEMORY)",
+46
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"fmt"
"path/filepath"
"testing"
)
@@ -95,6 +96,51 @@ func TestPragmasAreSet(t *testing.T) {
}
}
// Two people saving at the same moment must both succeed.
//
// This is a regression test for a specific and badly-behaved failure: with the
// default deferred BEGIN, a write transaction starts as a reader and asks for the
// write lock partway through. SQLite refuses that upgrade with SQLITE_BUSY at once
// and does *not* apply busy_timeout to it — so the contention surfaced as a 500
// two milliseconds after the request arrived, while every single-threaded test
// passed. `_txlock=immediate` is what makes the timeout actually cover writers.
func TestConcurrentWritersDoNotHitSQLiteBusy(t *testing.T) {
db := openTest(t)
ctx := context.Background()
seedTaskFixtures(t, db)
const writers = 8
errs := make(chan error, writers)
start := make(chan struct{})
for i := range writers {
go func(i int) {
<-start // all of them ask for the write lock at once
errs <- db.CreateTask(ctx, &Task{
ProjectID: "p1",
Content: fmt.Sprintf("opgave %d", i),
CreatedBy: "u1",
}, nil)
}(i)
}
close(start)
for range writers {
if err := <-errs; err != nil {
t.Fatalf("concurrent write failed: %v", err)
}
}
var n int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM tasks WHERE project_id = 'p1'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != writers {
t.Errorf("wrote %d tasks, want %d", n, writers)
}
}
// The FTS index is kept in step with `tasks` by triggers rather than by application
// code, so a task written by any path at all is searchable. That contract is worth
// a test of its own.
+18
View File
@@ -146,6 +146,24 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
})
}
// UpdateProfile changes the things a person may change about themselves.
//
// The email address is not among them: it identifies the account, it is what
// invites were sent to, and changing it is a re-verification flow rather than a
// field. Neither is is_admin — an account cannot promote itself.
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
res, err := db.ExecContext(ctx,
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
name, timezone, locale, time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
var value any
if secret != "" {
+2
View File
@@ -56,6 +56,8 @@ nav:
- Mail to task: mail.md
- Claude (MCP): mcp.md
- AI features: ai.md
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
# file, and api.md links to it. A nav entry expects a page to render.
- Reference:
- API: api.md
+9 -1
View File
@@ -11,7 +11,7 @@ gameskill:
invites and reminders, then start it and open the URL to create the first
admin account.
author: "kristianwind"
version: 1
version: 2
icon: "app"
# The image is distroless — no shell — so keep_entrypoint runs the binary the
@@ -70,6 +70,14 @@ gameskill:
pattern: '^[0-9]+(ns|us|ms|s|m|h)$'
hint: "must be a duration like 720h, 30m or 45s"
# Off by default because it is an outbound request nobody asked for. It sends
# no identifier and reads one public URL — GitHub's releases API — but that is
# a choice to make rather than a default to inherit.
- key: VERDANDE_UPDATE_CHECK
name: "Look for new releases (asks GitHub once a day; sends nothing about you)"
type: bool
default: false
install:
# The image has no shell, so the install step borrows one. It only has to make
# the directory layout exist; verdande creates and migrates its own database on
+4
View File
@@ -108,6 +108,10 @@ func run(base, cookie, out string, width, height int) error {
{name: "today", path: "/", theme: "dark", wait: 2500 * time.Millisecond},
{name: "today-light", path: "/", theme: "light", wait: 1500 * time.Millisecond},
{name: "upcoming", path: "/upcoming", theme: "dark", wait: 2000 * time.Millisecond},
// The integrations page rather than the account one: it shows the feed
// address and the mail-to-task address, which are the parts people ask
// what they look like. Account settings are a password form.
{name: "settings", path: "/indstillinger/integrationer", theme: "dark", wait: 2000 * time.Millisecond},
}
if project := os.Getenv("SHOT_PROJECT"); project != "" {
shots = append(shots,
+36
View File
@@ -0,0 +1,36 @@
import { test as setup, expect } from '@playwright/test';
/**
* Creates the first account and saves the session for every other test.
*
* This is also the sign-in smoke test: if the bootstrap form, the session cookie
* or the app shell is broken, nothing below runs and the failure names this file.
*/
export const USER = {
name: 'Kristian',
email: '[email protected]',
password: 'et langt kodeord til test'
};
const AUTH_FILE = 'e2e/.auth/user.json';
setup('opret den første konto og log ind', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Opret den første konto')).toBeVisible();
// Regexes, not exact strings: the password label carries a hint beside it, so
// its accessible name is the label plus that sentence.
await page.getByLabel(/Navn/).fill(USER.name);
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Opret konto' }).click();
// The shell, not just a redirect: a 200 that renders nothing would pass a
// URL assertion and fail a person.
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
await page.context().storageState({ path: AUTH_FILE });
});
+199
View File
@@ -0,0 +1,199 @@
import { test, expect } from '@playwright/test';
/**
* The four flows a broken build must not survive: add a task, tick it off, reach
* every page in the sidebar, and open a task.
*
* Every test asserts on *rendered* content rather than on a URL. A route that no
* longer exists still answers 200 here — the Go binary serves the SPA shell for
* anything it does not recognise — so `expect(page).toHaveURL()` would pass on
* precisely the bug this suite exists to catch.
*/
/**
* Collects anything that went wrong quietly: a thrown exception, a console error,
* or an API call that came back a failure.
*
* Failed responses are recorded with their URL and status. "A console error
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is.
*/
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
// The browser logs its own line for every failed fetch. It carries no URL,
// so it is noise next to the response listener below.
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
trouble.push(message.text());
}
});
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (url.includes('/api/') && !response.ok()) {
trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
}
});
return trouble;
}
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// "i dag" rather than "i morgen": this is the Today view, and a task parsed
// onto tomorrow is correctly absent from it — which would look like the quick
// add had failed.
await box.fill('køb kaffe i dag p1');
await box.press('Enter');
// The server parses the line, so this asserts the whole round trip: "i dag"
// and "p1" are gone from the title because they became a date and a priority.
const task = page.getByText('køb kaffe', { exact: true });
await expect(task).toBeVisible();
await page.getByRole('button', { name: 'Markér som færdig' }).first().click();
await expect(task).toBeHidden();
expect(trouble).toEqual([]);
});
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
// Wait for one link before counting them. `all()` is a snapshot with no
// auto-waiting — it returns whatever matches at that instant — so counting
// straight after goto() measures how fast the machine is. It passed on a
// laptop and found zero links on a CI runner.
await expect(sidebar.getByRole('link').first()).toBeVisible();
const hrefs = await sidebar
.getByRole('link')
.evaluateAll((links) => links.map((a) => a.getAttribute('href')));
expect(hrefs.length).toBeGreaterThanOrEqual(4); // I dag, Kommende, Indbakke, Indstillinger
// Re-resolved by href on each pass rather than held as handles: navigating
// re-renders the sidebar, and a handle from before the click is a handle to
// an element that no longer exists.
for (const href of hrefs) {
await sidebar.locator(`a[href="${href}"]`).first().click();
// Something with a heading rendered. A route written outside the routes
// tree lands on the SPA shell and renders nothing — which is exactly how
// the label route broke, and it answered 200 the whole time.
await expect(
page.locator('main h1, main h2').first(),
`${href} renderede ingenting`
).toBeVisible({ timeout: 5000 });
}
expect(trouble).toEqual([]);
});
test('en opgave kan åbnes og redigeres', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// Dated, so it lands in this view: an undated task goes to the Inbox and is
// correctly absent here.
await box.fill('skriv rapport i dag');
await box.press('Enter');
await page.getByText('skriv rapport', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await expect(drawer).toBeVisible();
// A sub-task, a comment and a description at once — which is also the shape
// that used to fail: three concurrent writes met SQLite's deferred BEGIN and
// one of them came back a 500.
await drawer.getByLabel('Beskrivelse').fill('Inden fredag.');
await drawer.getByLabel('Ny undertask').fill('lav dispositionen');
await drawer.getByLabel('Ny undertask').press('Enter');
await drawer.getByLabel('Ny kommentar').fill('Anders har materialet.');
await drawer.getByRole('button', { name: 'Skriv' }).click();
await expect(drawer.getByText('lav dispositionen')).toBeVisible();
await expect(drawer.getByText('Anders har materialet.')).toBeVisible();
// Nothing rolled back: a toast here means the server refused a write.
await expect(page.locator('.toast')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(drawer).toBeHidden();
// The description reached the row behind the drawer, so it was really saved.
await expect(page.getByText('Inden fredag.')).toBeVisible();
expect(trouble).toEqual([]);
});
test('hver fane under indstillinger renderer', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const tabs = page.getByRole('navigation', { name: 'Indstillinger' });
await expect(tabs).toBeVisible();
for (const label of [
'Konto',
'Notifikationer',
'Integrationer',
'AI',
'API-tokens',
'Data og skabeloner'
]) {
await tabs.getByRole('link', { name: label, exact: true }).click();
await expect(
page.locator('section.panel').first(),
`fanen ${label} renderede ingen sektion`
).toBeVisible({ timeout: 5000 });
}
expect(trouble).toEqual([]);
});
/**
* The PWA's own files. Both of the bugs found last in this project were of this
* kind — a manifest promising icons that were never generated — and they are
* invisible to every other test in the repository.
*/
test('manifestet og service workeren findes, og ikonerne findes også', async ({ request }) => {
const manifestResponse = await request.get('/manifest.webmanifest');
expect(manifestResponse.ok()).toBeTruthy();
const manifest = await manifestResponse.json();
for (const icon of manifest.icons ?? []) {
const iconResponse = await request.get(icon.src);
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
}
// The service worker is what Web Push needs; without it a subscription cannot
// be created at all.
const worker = await request.get('/sw.js');
expect(worker.ok()).toBeTruthy();
expect(await worker.text()).toContain("addEventListener('push'");
});
/**
* The MCP connector address must not fall through to the app shell.
*
* It did: `/mcp` was not a route, so the SPA fallback answered 200 with a page
* of HTML. A connector pointed at it reported a successful connection and then
* failed to parse the response — which looks like a broken client rather than a
* missing route. A 401 in JSON is the honest answer.
*/
test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
const response = await request.post('/mcp', {
data: { jsonrpc: '2.0', id: 1, method: 'initialize' }
});
expect(response.status(), 'uden nøgle skal den afvise, ikke servere siden').toBe(401);
expect(response.headers()['content-type']).toContain('application/json');
expect((await response.json()).code).toBe('unauthorized');
});
+64
View File
@@ -8,6 +8,7 @@
"name": "verdande-web",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "^1.62.1",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
@@ -477,6 +478,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1304,6 +1321,53 @@
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+3 -1
View File
@@ -7,9 +7,11 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./jsconfig.json"
"check": "svelte-check --tsconfig ./jsconfig.json",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
+101
View File
@@ -0,0 +1,101 @@
import { defineConfig, devices } from '@playwright/test';
import { rmSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
/**
* End-to-end smoke tests.
*
* These drive the *whole* application — the Go binary with the frontend embedded
* in it — rather than the dev server. That is deliberate: the two bugs that
* survived longest in this project were a route written outside the routes tree
* and a manifest promising files that were never generated. Neither is visible to
* a Go test, and neither is visible to a Vite build either. Only something that
* opens the real binary and clicks the real links finds them.
*
* `go run` rather than a prebuilt binary: the build cache makes the second run
* fast, and there is no separate build step to forget.
*/
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, '..');
const dataDir = join(here, '.playwright-data');
// A clean instance every run: the first test creates the first account, which only
// works on an empty database.
//
// Only in the main process. Playwright re-imports this file in every worker, and a
// wipe there deletes the database out from under the running server — which does
// not fail loudly. Connections already open keep working against the unlinked
// inode, so most requests still succeed and only the ones that need a *new*
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
if (process.env.TEST_WORKER_INDEX === undefined) {
rmSync(dataDir, { recursive: true, force: true });
}
const PORT = 8097;
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: './e2e',
// One worker, and not parallel: there is one server, one database and one
// account. Parallelism here would only test whether the tests race.
workers: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: process.env.CI ? [['github'], ['list']] : 'list',
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
// Pinned, because the app reads both. The sign-up form sends
// `navigator.language` as the account's locale — which decides whether
// "i morgen" or "tomorrow" parses — and every due date is resolved in the
// account's zone. On a CI runner in UTC with an en-US browser, an
// unpinned suite tests a different application than the one people use.
locale: 'da-DK',
timezoneId: 'Europe/Copenhagen'
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.js/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
}
],
webServer: {
// The whole chain, every run.
//
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
VERDANDE_BASE_URL: baseURL,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
}
}
});
+107 -1
View File
@@ -34,9 +34,29 @@ const MESSAGES = {
validation_failed: 'Tjek felterne herunder.',
payload_too_large: 'Det er for stort.',
internal_error: 'Noget gik galt. Prøv igen.',
offline: 'Ingen forbindelse til serveren.'
bad_request: 'Anmodningen kunne ikke læses.',
// Client-only: a network failure is not an HTTP status, so the server has no
// code for it.
offline: 'Ingen forbindelse til serveren.',
// Situations that used to arrive as a plain `conflict` and were therefore
// shown as "Det er der allerede." — which was not merely vague but wrong. An
// unconfigured Gmail is not a Gmail that is already connected.
gmail_not_configured:
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
totp_not_enabled: 'To-faktor er ikke slået til.',
inbox_protected: 'Indbakken kan ikke slettes.'
};
/**
* What to show a person for a failed request.
*
* A code with no message here falls back to the server's prose, which is English
* and written for a log — not good, but better than swallowing the only
* explanation there is. A code that turns up in that fallback is a code this
* table is missing.
*/
export function humanMessage(err) {
if (!(err instanceof ApiError)) return MESSAGES.offline;
return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error;
@@ -90,8 +110,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 +157,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 +233,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}` : ''}`),
+12 -13
View File
@@ -51,30 +51,29 @@
draggingId = null;
if (!id || !canEdit) return;
const task = app.tasks.find((t) => t.id === id);
const task = app.get(id);
if (!task || (task.section_id ?? '') === sectionId) return;
// Optimistic: the card is in the new column before the request goes out,
// because a card that snaps back for 200ms reads as a failed drop.
const previous = { ...task };
const index = app.tasks.findIndex((t) => t.id === id);
app.tasks[index] = { ...task, section_id: sectionId };
app.replace(id, { ...task, section_id: sectionId });
try {
// Dropped at the end of the column: the task after it is nothing, and
// the one before it is whatever was last there.
const existing = tasksIn(sectionId).filter((t) => t.id !== id);
const after = existing.at(-1);
const updated = await api.moveTask(id, {
project_id: project.id,
section_id: sectionId,
after_id: after?.id ?? ''
});
const i = app.tasks.findIndex((t) => t.id === id);
if (i >= 0) app.tasks[i] = updated;
} catch (e) {
const i = app.tasks.findIndex((t) => t.id === id);
if (i >= 0) app.tasks[i] = previous;
app.replace(
id,
await api.moveTask(id, {
project_id: project.id,
section_id: sectionId,
after_id: after?.id ?? ''
})
);
} catch {
app.replace(id, previous);
app.toast('Kunne ikke flytte opgaven.');
}
}
+26
View File
@@ -154,6 +154,21 @@
{/if}
<div class="foot">
<a
href="/indstillinger"
class="settings"
class:active={current.startsWith('/indstillinger')}
onclick={onnavigate}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
Indstillinger
</a>
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
{#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);
+765
View File
@@ -0,0 +1,765 @@
<script>
/**
* One task, opened.
*
* A drawer rather than a page: the list you came from stays visible behind it,
* so ticking a sub-task off and closing lands you back where you were rather
* than at the top of a re-fetched list.
*
* Text fields save on blur, not on every keystroke. A PATCH per character would
* be forty requests for one sentence, and the last one to arrive is not
* necessarily the last one sent.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let { task, onclose } = $props();
let content = $state('');
let description = $state('');
let priority = $state(4);
let dueDate = $state('');
let labels = $state('');
let subtasks = $state([]);
let comments = $state([]);
let attachments = $state([]);
let reminders = $state([]);
let newSubtask = $state('');
let newComment = $state('');
let newReminder = $state('');
let posting = $state(false);
let uploading = $state(false);
// Re-seeded whenever a different task is opened. Keyed on the id rather than on
// the object, so a websocket update to the same task does not throw away what
// somebody is typing.
let seededId = $state(null);
$effect(() => {
if (!task || seededId === task.id) return;
seededId = task.id;
content = task.content;
description = task.description ?? '';
priority = task.priority;
dueDate = task.due_date ?? '';
labels = (task.labels ?? []).join(', ');
subtasks = [];
comments = [];
attachments = [];
reminders = [];
const id = task.id;
api.listTasks({ parent_id: id, completed: 'include' })
.then((r) => id === seededId && (subtasks = r.tasks))
.catch(() => {});
api.listComments(id)
.then((r) => {
if (id !== seededId) return;
comments = r.comments;
attachments = r.attachments ?? [];
})
.catch(() => {});
api.listReminders(id)
.then((r) => id === seededId && (reminders = r.reminders))
.catch(() => {});
});
/** Sends a patch through the store, so the row behind the drawer updates too. */
async function save(patch) {
await app.update(task.id, patch);
}
function saveContent() {
const trimmed = content.trim();
if (!trimmed || trimmed === task.content) {
content = task.content;
return;
}
save({ content: trimmed });
}
function saveDescription() {
if (description === (task.description ?? '')) return;
save({ description });
}
function saveLabels() {
const list = labels
.split(',')
.map((l) => l.trim())
.filter(Boolean);
if (list.join() === (task.labels ?? []).join()) return;
save({ labels: list });
}
// --- sub-tasks ----------------------------------------------------------------
async function addSubtask(event) {
event.preventDefault();
const text = newSubtask.trim();
if (!text) return;
newSubtask = '';
try {
const created = await api.createTask({
content: text,
project_id: task.project_id,
parent_id: task.id
});
subtasks = [...subtasks, created];
} catch (e) {
app.toast(humanMessage(e));
}
}
async function toggleSubtask(subtask) {
const previous = subtasks;
subtasks = subtasks.map((s) =>
s.id === subtask.id ? { ...s, completed: !s.completed } : s
);
try {
const updated = subtask.completed
? await api.reopenTask(subtask.id)
: await api.completeTask(subtask.id);
subtasks = subtasks.map((s) => (s.id === updated.id ? updated : s));
} catch (e) {
subtasks = previous;
app.toast(humanMessage(e));
}
}
async function removeSubtask(subtask) {
const previous = subtasks;
subtasks = subtasks.filter((s) => s.id !== subtask.id);
try {
await api.deleteTask(subtask.id);
} catch (e) {
subtasks = previous;
app.toast(humanMessage(e));
}
}
// --- comments and files ---------------------------------------------------------
async function addComment(event) {
event.preventDefault();
const body = newComment.trim();
if (!body) return;
posting = true;
try {
const created = await api.createComment(task.id, body);
comments = [...comments, created];
newComment = '';
} catch (e) {
app.toast(e.fields?.body ?? humanMessage(e));
} finally {
posting = false;
}
}
async function removeComment(comment) {
if (!confirm('Slet kommentaren?')) return;
const previous = comments;
comments = comments.filter((c) => c.id !== comment.id);
try {
await api.deleteComment(comment.id);
} catch (e) {
comments = previous;
app.toast(humanMessage(e));
}
}
async function upload(event) {
const file = event.target.files?.[0];
if (!file) return;
event.target.value = '';
uploading = true;
try {
attachments = [...attachments, await api.uploadAttachment(task.id, file)];
} catch (e) {
app.toast(e.fields?.file ?? humanMessage(e));
} finally {
uploading = false;
}
}
async function removeAttachment(attachment) {
const previous = attachments;
attachments = attachments.filter((a) => a.id !== attachment.id);
try {
await api.deleteAttachment(attachment.id);
} catch (e) {
attachments = previous;
app.toast(humanMessage(e));
}
}
// --- reminders --------------------------------------------------------------------
async function addReminder(event) {
event.preventDefault();
if (!newReminder) return;
try {
// The input gives local wall-clock time; the API wants an instant.
const created = await api.createReminder(task.id, {
remind_at: new Date(newReminder).toISOString()
});
reminders = [...reminders, created];
newReminder = '';
} catch (e) {
app.toast(e.fields?.remind_at ?? humanMessage(e));
}
}
async function removeReminder(reminder) {
const previous = reminders;
reminders = reminders.filter((r) => r.id !== reminder.id);
try {
await api.deleteReminder(reminder.id);
} catch (e) {
reminders = previous;
app.toast(humanMessage(e));
}
}
function onkeydown(event) {
if (event.key === 'Escape') onclose?.();
}
const kb = (bytes) =>
bytes < 1024 * 1024
? `${Math.max(1, Math.round(bytes / 1024))} kB`
: `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
const stamp = (iso) =>
new Date(iso).toLocaleString('da-DK', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
</script>
<svelte:window {onkeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="scrim" onclick={onclose} role="presentation"></div>
<aside class="drawer" aria-label="Opgave">
<header>
<button class="close" onclick={onclose} aria-label="Luk">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</header>
<div class="scroll">
<div class="field">
<!-- svelte-ignore a11y_autofocus -->
<input
class="title"
bind:value={content}
onblur={saveContent}
aria-label="Opgavens tekst"
/>
</div>
<div class="field">
<label for="description">Beskrivelse</label>
<textarea
id="description"
rows="4"
bind:value={description}
onblur={saveDescription}
placeholder="Detaljer, links, hvad der skal huskes"
></textarea>
</div>
<div class="grid">
<div class="field">
<label for="priority">Prioritet</label>
<select
id="priority"
bind:value={priority}
onchange={() => save({ priority: Number(priority) })}
>
<option value={1}>P1 — haster</option>
<option value={2}>P2</option>
<option value={3}>P3</option>
<option value={4}>Ingen</option>
</select>
</div>
<div class="field">
<label for="due">Forfalder</label>
<input
id="due"
type="date"
bind:value={dueDate}
onchange={() => save({ due_date: dueDate })}
/>
</div>
</div>
<div class="field">
<label for="labels">Etiketter</label>
<input
id="labels"
bind:value={labels}
onblur={saveLabels}
placeholder="adskilt af komma"
/>
</div>
{#if task.recurrence_text}
<p class="repeats">Gentages {task.recurrence_text}.</p>
{/if}
<section>
<h3>Undertasks</h3>
{#each subtasks as subtask (subtask.id)}
<div class="subtask" class:done={subtask.completed}>
<button
class="check"
class:checked={subtask.completed}
onclick={() => toggleSubtask(subtask)}
aria-label={subtask.completed ? 'Genåbn' : 'Markér som færdig'}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 12.5l4 4 8-8.5" />
</svg>
</button>
<span class="subtask-text">{subtask.content}</span>
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label="Slet">
×
</button>
</div>
{/each}
<form onsubmit={addSubtask}>
<input bind:value={newSubtask} placeholder="Tilføj en undertask" aria-label="Ny undertask" />
</form>
</section>
<section>
<h3>Filer</h3>
{#each attachments as attachment (attachment.id)}
<div class="file">
<!-- Always a download, never rendered inline: an uploaded SVG shown
in place would run its own script on this origin. -->
<a href={attachment.url} download={attachment.filename}>{attachment.filename}</a>
<span class="size">{kb(attachment.size)}</span>
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label="Slet">
×
</button>
</div>
{/each}
<label class="upload">
<input type="file" onchange={upload} disabled={uploading} />
<span>{uploading ? 'Lægger op …' : 'Vedhæft en fil'}</span>
</label>
</section>
<section>
<h3>Påmindelser</h3>
{#each reminders as reminder (reminder.id)}
<div class="reminder">
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
{#if reminder.sent}<span class="sent">sendt</span>{/if}
<button class="remove" onclick={() => removeReminder(reminder)} aria-label="Slet">
×
</button>
</div>
{/each}
<form onsubmit={addReminder}>
<input
type="datetime-local"
bind:value={newReminder}
aria-label="Ny påmindelse"
/>
<button class="add" type="submit">Tilføj</button>
</form>
</section>
<section>
<h3>Kommentarer</h3>
{#each comments as comment (comment.id)}
<article class="comment">
<span class="avatar" style="background: {comment.user_color}">
{comment.user_name?.[0]?.toUpperCase() ?? '?'}
</span>
<div class="said">
<span class="who">
{comment.user_name}
<span class="when">{stamp(comment.created_at)}</span>
</span>
<p>{comment.body}</p>
{#each comment.attachments ?? [] as attachment (attachment.id)}
<a class="inline-file" href={attachment.url} download={attachment.filename}>
{attachment.filename}
</a>
{/each}
</div>
{#if comment.user_id === app.user?.id}
<button class="remove" onclick={() => removeComment(comment)} aria-label="Slet">
×
</button>
{/if}
</article>
{/each}
<form onsubmit={addComment}>
<textarea
rows="2"
bind:value={newComment}
placeholder="Skriv en kommentar"
aria-label="Ny kommentar"
></textarea>
<button class="add" type="submit" disabled={posting}>Skriv</button>
</form>
</section>
</div>
</aside>
<style>
.scrim {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.4);
z-index: 70;
}
.drawer {
position: fixed;
inset: 0 0 0 auto;
width: min(480px, 100vw);
z-index: 80;
display: flex;
flex-direction: column;
background: var(--ground);
border-left: 1px solid var(--line);
box-shadow: var(--shadow-lg);
animation: slide var(--medium) var(--ease-out);
}
@keyframes slide {
from {
transform: translateX(16px);
opacity: 0;
}
}
header {
display: flex;
justify-content: flex-end;
padding: var(--s3) var(--s4);
border-bottom: 1px solid var(--line);
flex: none;
}
.close {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--radius);
color: var(--ink-muted);
}
.close:hover {
color: var(--ink);
background: var(--surface);
}
.close svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
}
.scroll {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--s4) var(--s4) var(--s8);
display: flex;
flex-direction: column;
gap: var(--s4);
padding-bottom: max(var(--s8), env(safe-area-inset-bottom));
}
.field {
display: flex;
flex-direction: column;
gap: var(--s2);
}
label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
}
input,
textarea,
select {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
transition: border-color var(--fast) var(--ease);
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
}
textarea {
resize: vertical;
font-family: inherit;
line-height: 1.5;
}
/* The title is the one field that should not look like a field until you are in
it — it is a heading that happens to be editable. */
.title {
font-size: var(--text-lg);
font-weight: 560;
letter-spacing: -0.015em;
background: transparent;
border-color: transparent;
padding-left: 0;
}
.title:hover {
border-color: var(--line);
padding-left: var(--s3);
}
.title:focus {
background: var(--surface);
padding-left: var(--s3);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s3);
}
.repeats {
margin: 0;
font-size: var(--text-sm);
color: var(--ink-muted);
}
section {
display: flex;
flex-direction: column;
gap: var(--s2);
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
h3 {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
font-weight: 560;
}
.subtask,
.file,
.reminder {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
}
.subtask-text {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.subtask.done .subtask-text {
color: var(--ink-faint);
text-decoration: line-through;
}
.check {
flex: none;
width: 16px;
height: 16px;
border: 1.5px solid var(--line-strong);
border-radius: var(--radius-full);
display: grid;
place-items: center;
transition:
background var(--fast) var(--ease),
border-color var(--fast) var(--ease);
}
.check:hover {
border-color: var(--accent);
}
.check.checked {
background: var(--accent);
border-color: var(--accent);
}
.check svg {
width: 11px;
height: 11px;
fill: none;
stroke: var(--accent-ink);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.file a,
.inline-file {
color: var(--accent);
text-decoration: none;
overflow-wrap: anywhere;
}
.file a:hover,
.inline-file:hover {
text-decoration: underline;
}
.file a {
flex: 1;
min-width: 0;
}
.size,
.sent,
.when {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.reminder span:first-child {
flex: 1;
}
.remove {
flex: none;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
color: var(--ink-faint);
line-height: 1;
}
.remove:hover {
color: var(--danger);
background: var(--danger-sunken);
}
.upload {
font-size: var(--text-sm);
color: var(--accent);
cursor: pointer;
text-transform: none;
letter-spacing: normal;
}
.upload input {
display: none;
}
form {
display: flex;
gap: var(--s2);
align-items: flex-start;
margin-top: var(--s1);
}
.add {
flex: none;
padding: var(--s2) var(--s3);
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius);
font-size: var(--text-sm);
}
.add:disabled {
opacity: 0.5;
}
.comment {
display: flex;
gap: var(--s2);
align-items: flex-start;
padding: var(--s2) 0;
}
.avatar {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 560;
color: #fff;
flex: none;
}
.said {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.who {
font-size: var(--text-xs);
color: var(--ink-muted);
display: flex;
gap: var(--s2);
}
.said p {
margin: 0;
font-size: var(--text-sm);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 560px) {
.drawer {
width: 100vw;
border-left: 0;
}
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script>
/**
* A list of tasks that can be reordered by dragging.
*
* The board view drags cards between columns; this drags rows within one. Both
* use the native HTML drag-and-drop API for the same reason — it works with a
* trackpad, a mouse and the browser's own affordances without reimplementing
* any of them.
*
* Where the board shows the target *column*, a list has to show the target
* *gap*: the whole decision here is "above this one or below it", so a line is
* drawn where the row would land.
*/
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import TaskRow from './TaskRow.svelte';
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
let draggingId = $state(null);
/** The row the line is drawn at, and which side of it. */
let overId = $state(null);
let overBelow = $state(false);
function onDragStart(event, task) {
if (!canEdit) return;
draggingId = task.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', task.id);
}
function onDragOver(event, task) {
if (!canEdit || !draggingId || draggingId === task.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
// Which half of the row the pointer is in decides the gap. Comparing against
// the row's own midpoint rather than counting rows is what keeps the line
// where the eye expects it when rows have different heights.
const box = event.currentTarget.getBoundingClientRect();
overId = task.id;
overBelow = event.clientY > box.top + box.height / 2;
}
function clearOver() {
overId = null;
}
async function onDrop(event, target) {
event.preventDefault();
const id = draggingId;
const below = overBelow;
draggingId = null;
overId = null;
if (!id || !canEdit || id === target.id) return;
const ordered = [...tasks].sort((a, b) => a.sort_order - b.sort_order);
const without = ordered.filter((t) => t.id !== id);
const at = without.findIndex((t) => t.id === target.id);
if (at < 0) return;
// The gap the row lands in, named by where they end up on screen — the API's
// after_id is the task above, which is the opposite of what "after" reads as
// in a vertical list.
const index = below ? at + 1 : at;
const above = without[index - 1];
const beneath = without[index];
const task = app.get(id);
if (!task) return;
const previous = { ...task };
// Optimistic: the row is in its new place before the request goes out. The
// midpoint is a local guess — the server owns the real value and sends it
// back, and if the gap has run out of precision it respaces the section and
// tries again.
const order =
above && beneath
? (above.sort_order + beneath.sort_order) / 2
: above
? above.sort_order + 1024
: (beneath?.sort_order ?? 1024) - 1024;
app.replace(id, { ...task, sort_order: order });
try {
app.replace(
id,
await api.moveTask(id, {
project_id: projectId,
section_id: sectionId,
after_id: above?.id ?? '',
before_id: beneath?.id ?? ''
})
);
} catch {
app.replace(id, previous);
app.toast('Kunne ikke flytte opgaven.');
}
}
let ordered = $derived([...tasks].sort((a, b) => a.sort_order - b.sort_order));
</script>
{#each ordered as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sortable"
class:dragging={draggingId === task.id}
class:drop-above={overId === task.id && !overBelow}
class:drop-below={overId === task.id && overBelow}
draggable={canEdit}
ondragstart={(e) => onDragStart(e, task)}
ondragend={() => {
draggingId = null;
overId = null;
}}
ondragover={(e) => onDragOver(e, task)}
ondragleave={clearOver}
ondrop={(e) => onDrop(e, task)}
>
<TaskRow {task} />
</div>
{/each}
<style>
.sortable {
position: relative;
transition: opacity var(--fast) var(--ease);
}
/* Only the handle-less body drags; the checkbox stays clickable because the
drag needs a few pixels of movement before the browser calls it a drag. */
.sortable[draggable='true'] {
cursor: grab;
}
.sortable.dragging {
opacity: 0.4;
cursor: grabbing;
}
/* A line in the gap rather than a highlighted row: the row is not the target,
the space between two rows is. */
.sortable::before {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
opacity: 0;
pointer-events: none;
transition: opacity var(--fast) var(--ease);
}
.sortable.drop-above::before {
top: -1px;
opacity: 1;
}
.sortable.drop-below::before {
bottom: -1px;
opacity: 1;
}
</style>
+3 -1
View File
@@ -72,7 +72,9 @@
</svg>
</button>
<button class="body" onclick={() => onedit?.(task)}>
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
want the click to mean something else. -->
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
<span class="content">{task.content}</span>
{#if task.description}
+108
View File
@@ -0,0 +1,108 @@
/**
* Web Push, from the browser's side.
*
* Three parties have to agree before a notification arrives: the browser (which
* needs permission and a registered service worker), the push service it chooses
* (Google's, Mozilla's), and this server (which holds the VAPID pair and does the
* RFC 8291 encryption). This module is the first of those, and nothing else.
*/
import { api } from './api.js';
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
* can any browser on a page served over plain HTTP from a non-localhost host. */
export function supported() {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
export function permission() {
return supported() ? Notification.permission : 'unsupported';
}
async function registration() {
// `ready` waits for an active worker; register() alone resolves while the
// worker is still installing, and subscribing against that fails.
await navigator.serviceWorker.register('/sw.js');
return navigator.serviceWorker.ready;
}
/** The current subscription, or null. Cheap: it reads local state, no network. */
export async function current() {
if (!supported()) return null;
try {
const reg = await navigator.serviceWorker.getRegistration();
return (await reg?.pushManager.getSubscription()) ?? null;
} catch {
return null;
}
}
/**
* Asks permission, subscribes, and tells the server.
*
* Throws with a message meant to be shown. The failures here are not the API's
* error shape — a refused permission prompt is not an HTTP status — so they are
* plain Errors with Danish text.
*/
export async function subscribe() {
if (!supported()) throw new Error('Denne browser kan ikke vise notifikationer.');
const granted = await Notification.requestPermission();
if (granted !== 'granted') {
throw new Error(
granted === 'denied'
? 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.'
: 'Notifikationer blev ikke slået til.'
);
}
const reg = await registration();
const { public_key } = await api.pushKey();
if (!public_key) throw new Error('Serveren har ingen VAPID-nøgle.');
const subscription = await reg.pushManager.subscribe({
// Not optional in Chrome: a subscription that is not userVisibleOnly is
// refused outright.
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key)
});
const raw = subscription.toJSON();
await api.subscribePush({
endpoint: raw.endpoint,
keys: { p256dh: raw.keys.p256dh, auth: raw.keys.auth }
});
return subscription;
}
/** Unsubscribes here and forgets it on the server. */
export async function unsubscribe() {
const subscription = await current();
if (!subscription) return;
// Server first: if the browser unsubscribes and then the request fails, the
// server keeps pushing to an endpoint nothing is listening on, and only a
// 410 from the push service will ever clean it up.
await api.unsubscribePush(subscription.endpoint);
await subscription.unsubscribe();
}
/**
* The VAPID public key travels as base64url text and `applicationServerKey` wants
* raw bytes. Neither `atob` nor the browser does this conversion — every Web Push
* client on the internet carries some version of these six lines.
*/
function urlBase64ToUint8Array(base64url) {
const padding = '='.repeat((4 - (base64url.length % 4)) % 4);
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
}
+80 -45
View File
@@ -22,6 +22,13 @@ class AppState {
toasts = $state([]);
connected = $state(false);
/**
* The task whose detail drawer is open, as an id rather than a copy — so an
* edit made here, or arriving over the socket from somebody else, is reflected
* in the drawer without a second source of truth to keep in step.
*/
detailId = $state(null);
#socket = null;
#reconnectDelay = 1000;
@@ -33,6 +40,18 @@ class AppState {
return this.projects.find((p) => p.id === id);
}
get detailTask() {
return this.tasks.find((t) => t.id === this.detailId) ?? null;
}
openDetail(id) {
this.detailId = id;
}
closeDetail() {
this.detailId = null;
}
async load() {
this.loading = true;
try {
@@ -85,13 +104,9 @@ class AppState {
case 'task.updated':
case 'task.completed':
case 'task.reopened':
case 'task.moved': {
if (!task?.id) return;
const i = this.tasks.findIndex((t) => t.id === task.id);
if (i >= 0) this.tasks[i] = task;
else this.tasks.push(task);
case 'task.moved':
this.upsert(task);
break;
}
case 'task.deleted':
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
break;
@@ -106,58 +121,78 @@ class AppState {
return tasks;
}
/**
* Swaps one task for a new version of it. **The single place a task in the list
* is changed** — the optimistic updates below, the reconciliation after them,
* the rollbacks, the websocket, and the drag handlers in BoardView and TaskList
* all come through here.
*
* It builds a new array rather than writing `tasks[i] = next`. In-place index
* assignment did not reach the views: a ticked-off task kept its row, and a
* change arriving over the socket did not show at all, while `push` and whole
* -array assignment both worked. Rather than depend on which mutations the
* reactivity happens to see through, every write here produces a new array,
* which it certainly does see. The lists are at most a few hundred rows; the
* copy is not worth a moment's thought next to the class of bug it removes.
*/
replace(id, next) {
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
}
/**
* Adds a task, or replaces it if the list already has it.
*
* Adding is never unconditional, because the same task arrives twice: once as
* the response to the request that created it, and once over the socket, which
* publishes to the whole project including whoever did it. Either can land
* first. Appending both puts two rows with one id into an `{#each}` keyed by
* id, which is not a duplicated row but a thrown error that stops the view
* rendering.
*/
upsert(task) {
if (!task?.id) return;
if (this.tasks.some((t) => t.id === task.id)) this.replace(task.id, task);
else this.tasks = [...this.tasks, task];
}
get(id) {
return this.tasks.find((t) => t.id === id);
}
/**
* Ticks a task off. The row is struck through and gone before the request
* leaves — which is the single most-repeated action in the app, and the one
* place latency would be felt all day.
*/
async complete(id) {
const index = this.tasks.findIndex((t) => t.id === id);
if (index < 0) return;
const previous = this.tasks[index];
this.tasks[index] = { ...previous, completed: true };
try {
const updated = await api.completeTask(id);
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = updated;
} catch (e) {
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = previous;
this.toast(humanMessage(e));
}
await this.#optimistic(id, { completed: true }, () => api.completeTask(id));
}
async reopen(id) {
const index = this.tasks.findIndex((t) => t.id === id);
if (index < 0) return;
const previous = this.tasks[index];
this.tasks[index] = { ...previous, completed: false };
try {
const updated = await api.reopenTask(id);
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = updated;
} catch (e) {
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = previous;
this.toast(humanMessage(e));
}
await this.#optimistic(id, { completed: false }, () => api.reopenTask(id));
}
async update(id, patch) {
const index = this.tasks.findIndex((t) => t.id === id);
if (index < 0) return;
const previous = this.tasks[index];
await this.#optimistic(id, patch, () => api.updateTask(id, patch));
}
this.tasks[index] = { ...previous, ...patch };
/**
* Applies `patch` at once, then reconciles with whatever the server says.
*
* The rollback restores the version captured before the change rather than
* inverting the patch: the server may have declined for a reason that has
* nothing to do with the fields sent, and putting back what was there is the
* only correction that is right in every case.
*/
async #optimistic(id, patch, request) {
const previous = this.get(id);
if (!previous) return;
this.replace(id, { ...previous, ...patch });
try {
const updated = await api.updateTask(id, patch);
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = updated;
this.replace(id, await request());
} catch (e) {
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = previous;
this.replace(id, previous);
this.toast(humanMessage(e));
}
}
@@ -185,7 +220,7 @@ class AppState {
async quickAdd(text, projectId) {
try {
const task = await api.quickAdd(text, projectId);
this.tasks.push(task);
this.upsert(task);
return task;
} catch (e) {
this.toast(humanMessage(e));
@@ -198,7 +233,7 @@ class AppState {
async createProject(name) {
try {
const project = await api.createProject({ name });
this.projects.push(project);
this.projects = [...this.projects, project];
return project;
} catch (e) {
this.toast(humanMessage(e));
+10
View File
@@ -6,6 +6,7 @@
import Sidebar from '$lib/components/Sidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import TaskDetail from '$lib/components/TaskDetail.svelte';
let { children } = $props();
@@ -34,6 +35,9 @@
return;
}
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
// The navigation shortcuts are for the list. With a task open, "t" means the
// letter t — the drawer is a place you are, not a place you pass through.
if (app.detailId) return;
switch (event.key) {
case 'q':
@@ -111,6 +115,12 @@
<CommandPalette bind:open={paletteOpen} />
<!-- Mounted here rather than in each view, so a task opens the same way from
Today, a project, a label or a saved filter. -->
{#if app.detailTask}
<TaskDetail task={app.detailTask} onclose={() => app.closeDetail()} />
{/if}
<!-- Toasts are for things that failed after the interface already said they
had succeeded. Nothing else earns an interruption. -->
<div class="toasts" role="status" aria-live="polite">
+263
View File
@@ -0,0 +1,263 @@
<script>
/**
* The settings surface.
*
* Six pages rather than one long scroll: the sections have nothing to do with
* each other, and a person opening "API-tokens" should not have to walk past
* their own password to get there.
*
* The form chrome lives here as `:global` rules. Scoped styles do not reach a
* child route, and the alternative — six copies of the same twelve selectors —
* is six places for them to drift apart.
*/
import { page } from '$app/stores';
let { children } = $props();
const SECTIONS = [
{ href: '/indstillinger', label: 'Konto' },
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
{ href: '/indstillinger/ai', label: 'AI' },
{ href: '/indstillinger/tokens', label: 'API-tokens' },
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
];
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
</script>
<div class="settings">
<header>
<h1>Indstillinger</h1>
</header>
<nav aria-label="Indstillinger">
{#each SECTIONS as section (section.href)}
<a href={section.href} class:active={current === section.href}>{section.label}</a>
{/each}
</nav>
<div class="body">
{@render children()}
</div>
</div>
<style>
.settings {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
h1 {
font-size: var(--text-2xl);
margin-bottom: var(--s5);
}
/* A scrolling strip rather than a wrapping row: six labels wrap to two lines on
a phone, and a two-line tab bar looks like a mistake. */
nav {
display: flex;
gap: var(--s1);
overflow-x: auto;
border-bottom: 1px solid var(--line);
margin-bottom: var(--s5);
scrollbar-width: none;
}
nav::-webkit-scrollbar {
display: none;
}
nav a {
flex: none;
padding: var(--s2) var(--s3);
font-size: var(--text-sm);
color: var(--ink-muted);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
white-space: nowrap;
transition: color var(--fast) var(--ease);
}
nav a:hover {
color: var(--ink);
}
nav a.active {
color: var(--ink);
border-bottom-color: var(--accent);
}
.body {
display: flex;
flex-direction: column;
gap: var(--s5);
}
/* --- shared form chrome, for the child routes ------------------------------- */
.settings :global(section.panel) {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
}
.settings :global(.panel > header) {
display: flex;
flex-direction: column;
gap: var(--s1);
}
.settings :global(.panel h2) {
font-size: var(--text-lg);
}
.settings :global(.panel .hint) {
margin: 0;
font-size: var(--text-sm);
color: var(--ink-muted);
line-height: 1.5;
}
.settings :global(.field) {
display: flex;
flex-direction: column;
gap: var(--s2);
}
.settings :global(.field > label) {
font-size: var(--text-sm);
color: var(--ink-muted);
}
.settings :global(.field input),
.settings :global(.field select),
.settings :global(.field textarea) {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
transition: border-color var(--fast) var(--ease);
}
.settings :global(.field input:focus),
.settings :global(.field select:focus),
.settings :global(.field textarea:focus) {
border-color: var(--accent);
}
/* The field error sits under its own input rather than in a summary at the top:
the server answers with a map of field to message, and putting each one where
it belongs is the whole point of that shape. */
.settings :global(.field .error) {
margin: 0;
font-size: var(--text-xs);
color: var(--danger);
}
.settings :global(.field input[aria-invalid='true']) {
border-color: var(--danger);
}
.settings :global(.row) {
display: flex;
gap: var(--s3);
align-items: center;
flex-wrap: wrap;
}
.settings :global(button.primary) {
padding: var(--s2) var(--s4);
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius);
font-size: var(--text-sm);
font-weight: 500;
transition: background var(--fast) var(--ease);
}
.settings :global(button.primary:hover) {
background: var(--accent-hover);
}
.settings :global(button.primary:disabled) {
opacity: 0.5;
cursor: default;
}
.settings :global(button.secondary) {
padding: var(--s2) var(--s4);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink-muted);
transition:
color var(--fast) var(--ease),
border-color var(--fast) var(--ease);
}
.settings :global(button.secondary:hover) {
color: var(--ink);
border-color: var(--ink-faint);
}
.settings :global(button.danger) {
padding: var(--s2) var(--s4);
border: 1px solid var(--danger);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--danger);
transition: background var(--fast) var(--ease);
}
.settings :global(button.danger:hover) {
background: var(--danger-sunken);
}
/* Saved-confirmations are deliberately quiet and inline. A toast for "saved"
would interrupt to say nothing went wrong. */
.settings :global(.saved) {
font-size: var(--text-sm);
color: var(--accent);
}
.settings :global(.list) {
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.settings :global(.list > li) {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3);
border-bottom: 1px solid var(--line);
}
.settings :global(.list > li:last-child) {
border-bottom: 0;
}
.settings :global(.mono) {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.settings :global(.empty) {
margin: 0;
font-size: var(--text-sm);
color: var(--ink-faint);
}
</style>
+369
View File
@@ -0,0 +1,369 @@
<script>
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
// --- profile ---------------------------------------------------------------
let name = $state('');
let timezone = $state('');
let locale = $state('da');
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 });
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));
}
}
</script>
<section class="panel">
<header>
<h2>Profil</h2>
<p class="hint">
Tidszonen er ikke pynt: alle datoer i appen bliver afgjort i den, så
&ldquo;i morgen kl. 9&rdquo; betyder noget andet, når du skifter den.
</p>
</header>
<form onsubmit={saveProfile}>
<div class="field">
<label for="name">Navn</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">E-mail</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">Kan ikke ændres her.</p>
</div>
<div class="field">
<label for="timezone">Tidszone</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">Sprog i hurtig tilføjelse</label>
<select id="locale" bind:value={locale}>
<option value="da">Dansk</option>
<option value="en">English</option>
</select>
<p class="hint">
Afgør hvilken grammatik der tolker en linje — &ldquo;hver mandag&rdquo;
eller &ldquo;every monday&rdquo;.
</p>
{#if profileErrors.locale}<p class="error">{profileErrors.locale}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={savingProfile}>Gem</button>
{#if profileSaved}<span class="saved">Gemt.</span>{/if}
</div>
</form>
</section>
<section class="panel">
<header>
<h2>Adgangskode</h2>
<p class="hint">
At skifte den logger alle andre sessioner ud — hvilket for det meste er
grunden til at gøre det.
</p>
</header>
<form onsubmit={changePassword}>
<div class="field">
<label for="current">Nuværende adgangskode</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">Ny adgangskode</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">Skift adgangskode</button>
{#if passwordSaved}<span class="saved">Skiftet.</span>{/if}
</div>
</form>
</section>
<section class="panel">
<header>
<h2>To-faktor</h2>
<p class="hint">En engangskode fra en authenticator-app oven i adgangskoden.</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>Skriv dem ned nu.</strong> De vises kun denne ene gang — serveren
har kun deres hash.
</p>
<ul class="codes mono">
{#each recoveryCodes as code (code)}
<li>{code}</li>
{/each}
</ul>
<div class="row">
<button class="secondary" onclick={() => (recoveryCodes = [])}>Jeg har dem</button>
</div>
</div>
{:else if app.user?.totp_enabled}
<p class="hint">
Slået til.{#if remaining !== null}
{remaining} gendannelseskode{remaining === 1 ? '' : 'r'} tilbage.{/if}
</p>
<form onsubmit={disableTOTP}>
<div class="field">
<label for="disable-pw">Adgangskode</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">
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.
</p>
</div>
<div class="row">
<button class="secondary" onclick={regenerate}>Nye gendannelseskoder</button>
<button class="danger" type="submit">Slå to-faktor fra</button>
</div>
</form>
{:else if totpSecret}
<div class="field">
<p class="hint">Scan i din authenticator-app, eller indtast nøglen manuelt.</p>
<p class="mono secret">{totpSecret}</p>
<p class="hint mono uri">{totpURI}</p>
</div>
<form onsubmit={confirmTOTP}>
<div class="field">
<label for="totp">Koden fra appen</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">
To-faktor er først slået til, når en kode har bevist, at appen faktisk
har nøglen.
</p>
</div>
<div class="row">
<button class="primary" type="submit">Bekræft</button>
<button class="secondary" onclick={() => (totpSecret = '')}>Fortryd</button>
</div>
</form>
{:else}
<div class="row">
<button class="primary" onclick={beginTOTP}>Slå to-faktor til</button>
</div>
{/if}
</section>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.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);
}
.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>
@@ -0,0 +1,188 @@
<script>
/** The AI provider: which one, which model, and the key. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let settings = $state(null);
let apiKey = $state('');
let saving = $state(false);
let saved = $state(false);
let errors = $state({});
let summary = $state('');
let summarising = $state(false);
const PROVIDERS = [
{ value: '', label: 'Slået fra' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'google', label: 'Google' },
{ value: 'compatible', label: 'OpenAI-kompatibel (Ollama, LM Studio, …)' }
];
// Suggestions, not a closed list: the model field is free text, because a new
// model name must not require a new release of this app to be usable.
const SUGGESTED_MODEL = {
anthropic: 'claude-sonnet-5',
openai: 'gpt-5',
google: 'gemini-2.5-pro',
compatible: 'llama3.1'
};
$effect(() => {
api
.getAISettings()
.then((s) => (settings = s))
.catch((e) => app.toast(humanMessage(e)));
});
async function save(event) {
event.preventDefault();
saving = true;
saved = false;
errors = {};
try {
await api.setAISettings({
provider: settings.provider,
base_url: settings.base_url ?? '',
model: settings.model ?? '',
// An empty key means "leave the stored one alone" on the server. That
// is why this field starts empty rather than pre-filled with dots.
api_key: apiKey
});
apiKey = '';
settings = await api.getAISettings();
saved = true;
} catch (e) {
errors = e.fields ?? {};
if (!Object.keys(errors).length) app.toast(humanMessage(e));
} finally {
saving = false;
}
}
async function weeklySummary() {
summarising = true;
summary = '';
try {
summary = (await api.aiSummary()).summary;
} catch (e) {
app.toast(humanMessage(e));
} finally {
summarising = false;
}
}
</script>
<section class="panel">
<header>
<h2>AI</h2>
<p class="hint">
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.
</p>
</header>
{#if settings === null}
<p class="empty">…</p>
{:else}
<form onsubmit={save}>
<div class="field">
<label for="provider">Udbyder</label>
<select id="provider" bind:value={settings.provider}>
{#each PROVIDERS as provider (provider.value)}
<option value={provider.value}>{provider.label}</option>
{/each}
</select>
{#if errors.provider}<p class="error">{errors.provider}</p>{/if}
</div>
{#if settings.provider}
<div class="field">
<label for="model">Model</label>
<input
id="model"
bind:value={settings.model}
placeholder={SUGGESTED_MODEL[settings.provider] ?? ''}
/>
</div>
{#if settings.provider === 'compatible'}
<div class="field">
<label for="base">Adresse</label>
<input
id="base"
class="mono"
bind:value={settings.base_url}
placeholder="http://localhost:11434/v1"
/>
</div>
{/if}
<div class="field">
<label for="key">API-nøgle</label>
<input
id="key"
type="password"
autocomplete="off"
bind:value={apiKey}
placeholder={settings.has_key ? 'Der er gemt en nøgle — lad feltet stå tomt' : ''}
/>
<p class="hint">
{#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}
</p>
</div>
{/if}
<div class="row">
<button class="primary" type="submit" disabled={saving}>Gem</button>
{#if saved}<span class="saved">Gemt.</span>{/if}
</div>
</form>
{/if}
</section>
{#if settings?.provider && settings?.has_key}
<section class="panel">
<header>
<h2>Ugentligt overblik</h2>
<p class="hint">En kort note om, hvad der ser vigtigst ud lige nu.</p>
</header>
<div class="row">
<button class="secondary" onclick={weeklySummary} disabled={summarising}>
{summarising ? 'Skriver …' : 'Skriv et overblik'}
</button>
</div>
{#if summary}
<p class="summary">{summary}</p>
{/if}
</section>
{/if}
<style>
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.summary {
margin: 0;
padding: var(--s4);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
line-height: 1.6;
white-space: pre-wrap;
}
</style>
@@ -0,0 +1,340 @@
<script>
/** Import, export and project templates. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { goto } from '$app/navigation';
// --- Todoist import ------------------------------------------------------------
let importing = $state(false);
let result = $state(null);
async function importTodoist(event) {
const file = event.target.files?.[0];
if (!file) return;
event.target.value = ''; // so the same file can be picked twice after a failure
importing = true;
result = null;
try {
result = await api.importTodoist(file);
await app.refreshProjects();
} catch (e) {
app.toast(e.fields?.file ?? humanMessage(e));
} finally {
importing = false;
}
}
// --- templates --------------------------------------------------------------
let templates = $state([]);
let loadingTemplates = $state(true);
let saveFrom = $state('');
let templateName = $state('');
let savingTemplate = $state(false);
let usingId = $state('');
let newProjectName = $state('');
let startDate = $state('');
$effect(() => {
api
.listTemplates()
.then((r) => (templates = r.templates))
.catch((e) => app.toast(humanMessage(e)))
.finally(() => (loadingTemplates = false));
});
let projects = $derived(app.projects.filter((p) => !p.is_inbox));
async function saveTemplate(event) {
event.preventDefault();
if (!saveFrom) return;
savingTemplate = true;
try {
const created = await api.saveTemplate({
project_id: saveFrom,
name: templateName.trim() || app.projectById(saveFrom)?.name || 'Skabelon',
description: ''
});
templates = [created, ...templates];
saveFrom = '';
templateName = '';
} catch (e) {
app.toast(humanMessage(e));
} finally {
savingTemplate = false;
}
}
async function useTemplate(event) {
event.preventDefault();
if (!usingId) return;
try {
const project = await api.useTemplate(usingId, {
name: newProjectName.trim(),
start_date: startDate
});
await app.refreshProjects();
usingId = '';
newProjectName = '';
startDate = '';
goto(`/projekt/${project.id}`);
} catch (e) {
app.toast(e.fields?.start_date ?? humanMessage(e));
}
}
async function deleteTemplate(template) {
if (!confirm(`Slet skabelonen "${template.name}"?`)) return;
const previous = templates;
templates = templates.filter((t) => t.id !== template.id);
try {
await api.deleteTemplate(template.id);
} catch (e) {
templates = previous;
app.toast(humanMessage(e));
}
}
let exportProject = $state('');
</script>
<section class="panel">
<header>
<h2>Importér fra Todoist</h2>
<p class="hint">
Vælg CSV-eksporten af ét projekt. Prioriteterne bliver konverteret, ikke
kopieret: Todoist skriver 4 for det, grænsefladen kalder P1.
</p>
</header>
<div class="field">
<label for="todoist">CSV-fil</label>
<input id="todoist" type="file" accept=".csv,text/csv" onchange={importTodoist} disabled={importing} />
</div>
{#if importing}
<p class="hint">Importerer …</p>
{/if}
{#if result}
<div class="result">
<p class="hint">
{result.tasks} opgave{result.tasks === 1 ? '' : 'r'}, {result.sections}
sektion{result.sections === 1 ? '' : 'er'} og {result.comments}
kommentar{result.comments === 1 ? '' : 'er'} hentet ind.
</p>
{#if result.warnings?.length}
<!-- Reported rather than silently dropped: a partial import that says
nothing is discovered weeks later. -->
<ul class="warnings">
{#each result.warnings as warning, i (i)}
<li>{warning}</li>
{/each}
</ul>
{/if}
{#if result.project_id}
<div class="row">
<a class="link" href="/projekt/{result.project_id}">Åbn projektet</a>
</div>
{/if}
</div>
{/if}
</section>
<section class="panel">
<header>
<h2>Eksport</h2>
<p class="hint">
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.
</p>
</header>
<div class="row">
<!-- Plain links, not fetch: the browser's own download handling knows what to
do with a Content-Disposition, and the session cookie rides along. -->
<a class="link" href={api.exportAccountURL()} download>Hele kontoen som JSON</a>
</div>
<div class="field">
<label for="export-project">Ét projekt</label>
<select id="export-project" bind:value={exportProject}>
<option value="">Vælg et projekt</option>
{#each app.projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
{#if exportProject}
<div class="row">
<a class="link" href={api.exportProjectCSVURL(exportProject)} download>CSV</a>
<a class="link" href={api.exportProjectICSURL(exportProject)} download>Kalenderfil</a>
</div>
{/if}
</section>
<section class="panel">
<header>
<h2>Skabeloner</h2>
<p class="hint">
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.
</p>
</header>
<form onsubmit={saveTemplate}>
<div class="field">
<label for="save-from">Gem et projekt som skabelon</label>
<select id="save-from" bind:value={saveFrom}>
<option value="">Vælg et projekt</option>
{#each projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
{#if saveFrom}
<div class="field">
<label for="template-name">Navn på skabelonen</label>
<input
id="template-name"
bind:value={templateName}
placeholder={app.projectById(saveFrom)?.name ?? ''}
/>
</div>
<div class="row">
<button class="primary" type="submit" disabled={savingTemplate}>Gem som skabelon</button>
</div>
{/if}
</form>
{#if loadingTemplates}
<p class="empty">…</p>
{:else if templates.length === 0}
<p class="empty">Ingen skabeloner endnu.</p>
{:else}
<ul class="list">
{#each templates as template (template.id)}
<li>
<div class="what">
<span class="title">{template.name}</span>
<span class="meta">
{template.task_count} opgave{template.task_count === 1 ? '' : 'r'}
</span>
</div>
<button class="use" onclick={() => (usingId = usingId === template.id ? '' : template.id)}>
Brug
</button>
<button class="remove" onclick={() => deleteTemplate(template)}>Slet</button>
</li>
{#if usingId === template.id}
<li class="use-form">
<form onsubmit={useTemplate}>
<div class="field">
<label for="new-name">Navn på det nye projekt</label>
<input id="new-name" bind:value={newProjectName} placeholder={template.name} />
</div>
<div class="field">
<label for="start">Startdato</label>
<input id="start" type="date" bind:value={startDate} />
<p class="hint">Dag nul for skabelonens datoer. Tom betyder i dag.</p>
</div>
<div class="row">
<button class="primary" type="submit">Opret projektet</button>
</div>
</form>
</li>
{/if}
{/each}
</ul>
{/if}
</section>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.result {
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s4);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.warnings {
margin: 0;
padding-left: var(--s5);
font-size: var(--text-sm);
color: var(--ink-muted);
}
.what {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.title {
font-size: var(--text-sm);
}
.meta {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.use,
.remove {
flex: none;
font-size: var(--text-xs);
padding: var(--s1) var(--s2);
border-radius: var(--radius-sm);
color: var(--ink-faint);
transition: color var(--fast) var(--ease);
}
.use:hover {
color: var(--accent);
}
.remove:hover {
color: var(--danger);
background: var(--danger-sunken);
}
.use-form {
background: var(--surface-sunken);
}
.use-form form {
width: 100%;
}
.link {
font-size: var(--text-sm);
color: var(--accent);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,301 @@
<script>
/** Gmail, the calendar feed, and the mail-to-task address. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
// --- Gmail ------------------------------------------------------------------
let gmail = $state(null);
let savingGmail = $state(false);
let gmailSaved = $state(false);
let syncing = $state(false);
/**
* What the OAuth callback redirected back with. It arrives as a query parameter
* because the callback is a top-level navigation from Google — there is no fetch
* whose response could have carried it.
*/
const CALLBACK = {
connected: { tone: 'ok', text: 'Gmail er forbundet.' },
state: {
tone: 'bad',
text: 'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.'
},
expired: { tone: 'bad', text: 'Forsøget udløb undervejs. Prøv igen.' },
invalid: { tone: 'bad', text: 'Svaret fra Google kunne ikke læses. Prøv igen.' },
failed: { tone: 'bad', text: 'Google afviste ombytningen af koden.' },
norefresh: {
tone: 'bad',
text: 'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.'
},
access_denied: { tone: 'bad', text: 'Adgangen blev afvist hos Google.' }
};
let callback = $derived.by(() => {
const value = $page.url.searchParams.get('gmail');
if (!value) return null;
return CALLBACK[value] ?? { tone: 'bad', text: `Gmail svarede: ${value}` };
});
$effect(() => {
api
.getGmail()
.then((g) => (gmail = g))
.catch((e) => app.toast(humanMessage(e)));
});
async function connect() {
try {
const { url } = await api.authorizeGmail();
location.href = url;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function saveGmail(event) {
event.preventDefault();
savingGmail = true;
gmailSaved = false;
try {
await api.setGmail({ trigger: gmail.trigger, label: gmail.label ?? '' });
gmailSaved = true;
} catch (e) {
app.toast(humanMessage(e));
} finally {
savingGmail = false;
}
}
async function syncNow() {
syncing = true;
try {
const result = await api.syncGmail();
app.toast(
result?.created ? `${result.created} opgave(r) hentet.` : 'Ingen nye beskeder.'
);
} catch (e) {
app.toast(humanMessage(e));
} finally {
syncing = false;
}
}
async function disconnectGmail() {
if (!confirm('Afbryd forbindelsen til Gmail? Tokens bliver glemt.')) return;
try {
await api.disconnectGmail();
gmail = await api.getGmail();
} catch (e) {
app.toast(humanMessage(e));
}
}
// --- calendar feed -------------------------------------------------------------
let feedURL = $state('');
let mailAddress = $state('');
/** Null until loaded, so the warning does not flash before the answer is in. */
let mailConfigured = $state(null);
$effect(() => {
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
api
.getMailAddress()
.then((r) => {
mailAddress = r.address;
mailConfigured = r.configured;
})
.catch(() => {});
});
async function rotateFeed() {
if (!confirm('Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.')) return;
try {
feedURL = (await api.rotateFeed()).url;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function rotateMail() {
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
try {
const r = await api.rotateMailAddress();
mailAddress = r.address;
mailConfigured = r.configured;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
app.toast('Kopieret.');
} catch {
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
}
}
</script>
<section class="panel">
<header>
<h2>Gmail</h2>
<p class="hint">
Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave.
Envejs: at fjerne stjernen gør ikke noget ved opgaven.
</p>
</header>
{#if callback}
<p class="callback" data-tone={callback.tone}>{callback.text}</p>
{/if}
{#if gmail === null}
<p class="empty">…</p>
{:else if !gmail.connected}
<div class="row">
<button class="primary" onclick={connect}>Forbind Gmail</button>
</div>
<p class="hint">
Kræver at administratoren har registreret en OAuth-klient hos Google og sat
<span class="mono">VERDANDE_GMAIL_CLIENT_ID</span> og
<span class="mono">_SECRET</span>.
</p>
{:else}
<p class="hint">Forbundet som <strong>{gmail.email || 'ukendt konto'}</strong>.</p>
<form onsubmit={saveGmail}>
<div class="field">
<label for="trigger">Hvad laver en opgave</label>
<select id="trigger" bind:value={gmail.trigger}>
<option value="starred">Stjernemarkerede</option>
<option value="label">En bestemt etiket</option>
<option value="both">Begge dele</option>
</select>
</div>
{#if gmail.trigger === 'label' || gmail.trigger === 'both'}
<div class="field">
<label for="gmail-label">Etikettens navn i Gmail</label>
<input id="gmail-label" bind:value={gmail.label} placeholder="fx Opgaver" />
</div>
{/if}
<div class="row">
<button class="primary" type="submit" disabled={savingGmail}>Gem</button>
{#if gmailSaved}<span class="saved">Gemt.</span>{/if}
<button class="secondary" onclick={syncNow} disabled={syncing}>Hent nu</button>
<button class="danger" onclick={disconnectGmail}>Afbryd</button>
</div>
</form>
{/if}
</section>
<section class="panel">
<header>
<h2>Kalenderfeed</h2>
<p class="hint">
Abonnér i Apple Kalender, Google eller Thunderbird. En kalenderklient kan
ikke logge ind, så nøglen i adressen <em>er</em> hele adgangskoden — del den
ikke.
</p>
</header>
<div class="field">
<label for="feed">Adresse</label>
<input id="feed" class="mono" value={feedURL} readonly />
</div>
<div class="row">
<button class="secondary" onclick={() => copy(feedURL)}>Kopiér</button>
<button class="danger" onclick={rotateFeed}>Nyt link</button>
</div>
<p class="hint">
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.
</p>
</section>
<section class="panel">
<header>
<h2>Mail til opgave</h2>
<p class="hint">
Send eller videresend en mail hertil, og emnelinjen bliver tolket som en
hurtig tilføjelse — &ldquo;Fakturer Anders p1 #Firma&rdquo; virker også her.
</p>
</header>
{#if mailConfigured === false}
<p class="callback" data-tone="bad">
Der er ingen mailserver sat op på denne server, så adressen herunder kan
ikke modtage noget endnu. Sæt <span class="mono">VERDANDE_SMTP_HOST</span> og
de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til
<span class="mono">todo+*</span> videre til
<span class="mono">/inbound/mail</span>.
</p>
{/if}
<div class="field">
<label for="mail">Din adresse</label>
<input id="mail" class="mono" value={mailAddress} readonly />
</div>
<div class="row">
<button class="secondary" onclick={() => copy(mailAddress)}>Kopiér</button>
<button class="danger" onclick={rotateMail}>Ny adresse</button>
</div>
</section>
<section class="panel">
<header>
<h2>CalDAV</h2>
<p class="hint">
Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird
kan også skrive tilbage.
</p>
</header>
<div class="field">
<label for="caldav">Server</label>
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
<p class="hint">
Brugernavn er din e-mail. Adgangskoden er en
<a href="/indstillinger/tokens">API-token</a> — ikke din rigtige adgangskode.
</p>
</div>
</section>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.callback {
margin: 0;
padding: var(--s3);
border-radius: var(--radius);
font-size: var(--text-sm);
border: 1px solid;
}
.callback[data-tone='ok'] {
color: var(--accent);
background: var(--accent-sunken);
border-color: var(--accent);
}
.callback[data-tone='bad'] {
color: var(--danger);
background: var(--danger-sunken);
border-color: var(--danger);
}
a {
color: var(--accent);
}
</style>
@@ -0,0 +1,256 @@
<script>
/** Notifications: what has happened, and whether the browser should tell you. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import * as push from '$lib/push.js';
// --- web push ----------------------------------------------------------------
let pushState = $state('loading');
let busy = $state(false);
$effect(() => {
if (!push.supported()) {
pushState = 'unsupported';
return;
}
if (push.permission() === 'denied') {
pushState = 'blocked';
return;
}
push.current().then((sub) => (pushState = sub ? 'on' : 'off'));
});
async function enablePush() {
busy = true;
try {
await push.subscribe();
pushState = 'on';
} catch (e) {
app.toast(e.message ?? humanMessage(e));
if (push.permission() === 'denied') pushState = 'blocked';
} finally {
busy = false;
}
}
async function disablePush() {
busy = true;
try {
await push.unsubscribe();
pushState = 'off';
} catch (e) {
app.toast(e.message ?? humanMessage(e));
} finally {
busy = false;
}
}
// --- the list -----------------------------------------------------------------
let notifications = $state([]);
let unread = $state(0);
let loading = $state(true);
$effect(() => {
api
.listNotifications()
.then((r) => {
notifications = r.notifications;
unread = r.unread;
})
.catch((e) => app.toast(humanMessage(e)))
.finally(() => (loading = false));
});
async function markAllRead() {
const previous = notifications;
notifications = notifications.map((n) => ({ ...n, read: true }));
unread = 0;
try {
await api.markNotificationsRead();
} catch (e) {
notifications = previous;
app.toast(humanMessage(e));
}
}
function when(iso) {
const then = new Date(iso);
const minutes = Math.round((Date.now() - then) / 60000);
if (minutes < 1) return 'lige nu';
if (minutes < 60) return `${minutes} min. siden`;
if (minutes < 60 * 24) return `${Math.round(minutes / 60)} t. siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
}
// --- version ------------------------------------------------------------------
let version = $state(null);
$effect(() => {
api.version().then((v) => (version = v)).catch(() => {});
});
</script>
<section class="panel">
<header>
<h2>Notifikationer på enheden</h2>
<p class="hint">
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.
</p>
</header>
{#if pushState === 'loading'}
<p class="empty">…</p>
{:else if pushState === 'unsupported'}
<p class="hint">
Denne browser understøtter det ikke. Safari kræver, at appen er lagt på
hjemmeskærmen; over almindelig HTTP virker det kun på localhost.
</p>
{:else if pushState === 'blocked'}
<p class="hint">
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.
</p>
{:else if pushState === 'on'}
<p class="hint">Slået til på denne enhed.</p>
<div class="row">
<button class="secondary" onclick={disablePush} disabled={busy}>Slå fra</button>
</div>
{:else}
<div class="row">
<button class="primary" onclick={enablePush} disabled={busy}>
Slå til på denne enhed
</button>
</div>
<p class="hint">Gælder kun den browser, du sidder i nu.</p>
{/if}
</section>
<section class="panel">
<header>
<h2>Seneste</h2>
{#if unread > 0}
<p class="hint">{unread} ulæst{unread === 1 ? '' : 'e'}.</p>
{/if}
</header>
{#if loading}
<p class="empty">…</p>
{:else if notifications.length === 0}
<p class="empty">Ingenting endnu.</p>
{:else}
<ul class="list">
{#each notifications as note (note.id)}
<li class:unread={!note.read}>
<div class="what">
<span class="title">{note.title}</span>
{#if note.body}<span class="body">{note.body}</span>{/if}
<span class="meta">
{#if note.actor_name}{note.actor_name} · {/if}{when(note.created_at)}
</span>
</div>
{#if note.task_id && note.project_id}
<a class="go" href="/projekt/{note.project_id}">Åbn</a>
{/if}
</li>
{/each}
</ul>
{#if unread > 0}
<div class="row">
<button class="secondary" onclick={markAllRead}>Markér alle som læst</button>
</div>
{/if}
{/if}
</section>
{#if version}
<section class="panel">
<header>
<h2>Version</h2>
</header>
<p class="hint">
Kører <span class="mono">{version.current}</span>.
{#if version.disabled}
Der bliver ikke set efter opdateringer.
{:else if version.update_available}
<strong>{version.latest} er ude.</strong>
{:else if version.checked_at}
Ingen nyere version fundet.
{/if}
</p>
{#if version.update_available}
{#if version.notes}
<p class="notes">{version.notes}</p>
{/if}
{#if version.url}
<div class="row">
<a class="release" href={version.url} target="_blank" rel="noreferrer noopener">
Se udgivelsen
</a>
</div>
{/if}
{/if}
</section>
{/if}
<style>
.what {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.title {
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.body {
font-size: var(--text-sm);
color: var(--ink-muted);
overflow-wrap: anywhere;
}
.meta {
font-size: var(--text-xs);
color: var(--ink-faint);
}
/* An accent bar rather than a bold row: the list is read top to bottom, and
weight changes make it look ragged. */
li.unread {
border-left: 2px solid var(--accent);
}
.go,
.release {
flex: none;
font-size: var(--text-xs);
color: var(--accent);
text-decoration: none;
}
.release {
font-size: var(--text-sm);
}
.notes {
margin: 0;
padding: var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
white-space: pre-wrap;
line-height: 1.6;
}
</style>
@@ -0,0 +1,268 @@
<script>
/**
* Personal API tokens.
*
* The one screen in the app that shows a secret. It is shown exactly once, and
* the copy has to say so — the server keeps a hash, so there is no later screen
* that could reveal it even if this one offered to.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let tokens = $state([]);
let loading = $state(true);
let name = $state('');
let expiresInDays = $state(0);
let errors = $state({});
let creating = $state(false);
/** The plaintext of the token just minted. Held only in this variable. */
let fresh = $state(null);
let copied = $state(false);
let connectorCopied = $state(false);
/** What Claude's connector dialog needs, which is a URL and nothing else. */
let connectorURL = $derived(fresh ? `${location.origin}/mcp?key=${fresh.token}` : '');
$effect(() => {
api
.listTokens()
.then((r) => (tokens = r.tokens))
.catch((e) => app.toast(humanMessage(e)))
.finally(() => (loading = false));
});
async function create(event) {
event.preventDefault();
errors = {};
creating = true;
try {
const created = await api.createToken(name.trim(), Number(expiresInDays));
fresh = created;
copied = false;
name = '';
expiresInDays = 0;
tokens = [{ ...created, token: undefined }, ...tokens];
} catch (e) {
errors = e.fields ?? {};
if (!Object.keys(errors).length) app.toast(humanMessage(e));
} finally {
creating = false;
}
}
async function copy() {
try {
await navigator.clipboard.writeText(fresh.token);
copied = true;
} catch {
// Insecure context, or permission refused. The token is on screen and
// selectable, so this is a convenience that failed, not the feature.
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
}
}
async function copyConnector() {
try {
await navigator.clipboard.writeText(connectorURL);
connectorCopied = true;
} catch {
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
}
}
async function revoke(token) {
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
return;
const previous = tokens;
tokens = tokens.filter((t) => t.id !== token.id);
try {
await api.deleteToken(token.id);
} catch (e) {
tokens = previous;
app.toast(humanMessage(e));
}
}
const date = (iso) =>
iso ? new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' }) : null;
const expired = (iso) => iso && new Date(iso) < new Date();
</script>
<section class="panel">
<header>
<h2>API-tokens</h2>
<p class="hint">
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.
</p>
</header>
{#if fresh}
<div class="fresh">
<p class="hint">
<strong>Kopiér den nu.</strong> Den vises kun denne ene gang — serveren har
kun dens hash.
</p>
<p class="mono value">{fresh.token}</p>
<div class="row">
<button class="primary" onclick={copy}>Kopiér</button>
{#if copied}<span class="saved">Kopieret.</span>{/if}
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
</div>
<!-- Shown here and nowhere else, because it contains the token, and the
token exists only in this moment. Claude's connector dialog takes a
URL and nothing else — there is no field for a header — so this is
the form it has to be in. -->
<div class="also">
<p class="hint">
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
i Claude? Så er det denne adresse, dialogen skal have:
</p>
<p class="mono value">{connectorURL}</p>
<div class="row">
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
</div>
</div>
</div>
{/if}
<form onsubmit={create}>
<div class="field">
<label for="token-name">Navn</label>
<input
id="token-name"
bind:value={name}
placeholder="fx min bærbare, eller backup-scriptet"
aria-invalid={errors.name ? 'true' : undefined}
/>
{#if errors.name}<p class="error">{errors.name}</p>{/if}
</div>
<div class="field">
<label for="token-expiry">Udløber</label>
<select id="token-expiry" bind:value={expiresInDays}>
<option value={0}>Aldrig</option>
<option value={30}>Om 30 dage</option>
<option value={90}>Om 90 dage</option>
<option value={365}>Om et år</option>
</select>
<p class="hint">
En kalenderklient, der har abonneret i årevis, har brug for
&ldquo;aldrig&rdquo;. Et script, du kører én gang, har ikke.
</p>
{#if errors.expires_in_days}<p class="error">{errors.expires_in_days}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={creating}>Lav en token</button>
</div>
</form>
{#if loading}
<p class="empty">…</p>
{:else if tokens.length === 0}
<p class="empty">Ingen tokens endnu.</p>
{:else}
<ul class="list">
{#each tokens as token (token.id)}
<li>
<div class="what">
<span class="token-name">{token.name}</span>
<span class="meta mono">
{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}
</span>
</div>
<button class="revoke" onclick={() => revoke(token)} aria-label="Tilbagekald {token.name}">
Tilbagekald
</button>
</li>
{/each}
</ul>
{/if}
</section>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.fresh {
display: flex;
flex-direction: column;
gap: var(--s3);
padding: var(--s4);
background: var(--accent-sunken);
border: 1px solid var(--accent);
border-radius: var(--radius);
}
.value {
margin: 0;
padding: var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow-wrap: anywhere;
user-select: all;
}
.also {
display: flex;
flex-direction: column;
gap: var(--s2);
padding-top: var(--s3);
border-top: 1px solid var(--accent);
}
.also a {
color: inherit;
}
.what {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.token-name {
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.meta {
color: var(--ink-faint);
}
.revoke {
flex: none;
font-size: var(--text-xs);
color: var(--ink-faint);
padding: var(--s1) var(--s2);
border-radius: var(--radius-sm);
transition: color var(--fast) var(--ease);
}
.revoke:hover {
color: var(--danger);
background: var(--danger-sunken);
}
</style>
+3 -7
View File
@@ -3,7 +3,7 @@
import { page } from '$app/stores';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
import TaskList from '$lib/components/TaskList.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
@@ -168,18 +168,14 @@
<CalendarView />
{:else}
<section>
{#each unsectioned as task (task.id)}
<TaskRow {task} />
{/each}
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
</section>
{#each sections as section (section.id)}
{@const tasks = open.filter((t) => t.section_id === section.id)}
<section>
<h2>{section.name}</h2>
{#each tasks as task (task.id)}
<TaskRow {task} />
{/each}
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
{#if !tasks.length}
<p class="empty">Tom</p>
{/if}
+64
View File
@@ -0,0 +1,64 @@
/**
* verdande's service worker.
*
* It exists for Web Push and nothing else. There is deliberately no offline cache:
* the app is a live view of a task list that other people are editing, and a cached
* shell serving yesterday's data is worse than a browser saying it cannot connect.
* Adding one later means versioning and invalidating it, which is a real feature
* rather than a few lines here.
*/
// Take over as soon as the browser is willing. Without these two, the first push
// after granting permission arrives with no worker in control to show it.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
// A push whose body is not our JSON still deserves to be shown: browsers
// display their own "site updated in the background" notice otherwise, which
// looks like a bug in the app.
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
}
const title = payload.title || 'verdande';
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
icon: '/icon.svg',
badge: '/icon.svg',
// The tag collapses repeats: five comments on one task should replace each
// other rather than stack into five separate notifications.
tag: payload.tag || payload.url || 'verdande',
data: { url: payload.url || '/' }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = event.notification.data?.url || '/';
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
// Focus a tab that already has the app open rather than opening a second
// one. Somebody with the app pinned does not want a new tab per reminder.
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
await client.focus();
if ('navigate' in client) await client.navigate(target);
return;
}
}
await self.clients.openWindow(target);
})()
);
});
@@ -1,23 +0,0 @@
// vite.config.js
import { sveltekit } from "file:///Users/kw/Documents/Code/ToDoApp/web/node_modules/@sveltejs/kit/src/exports/vite/index.js";
var vite_config_default = {
plugins: [sveltekit()],
server: {
port: 5173,
// In development the app runs on 5173 and the API on 8080. Proxying rather
// than pointing fetch at another origin keeps the session cookie
// first-party, which is what production looks like — otherwise every
// cookie and CSRF behaviour would differ between dev and the real thing.
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: false,
ws: true
}
}
}
};
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9rdy9Eb2N1bWVudHMvQ29kZS9Ub0RvQXBwL3dlYi92aXRlLmNvbmZpZy5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDUxNzMsXG5cdFx0Ly8gSW4gZGV2ZWxvcG1lbnQgdGhlIGFwcCBydW5zIG9uIDUxNzMgYW5kIHRoZSBBUEkgb24gODA4MC4gUHJveHlpbmcgcmF0aGVyXG5cdFx0Ly8gdGhhbiBwb2ludGluZyBmZXRjaCBhdCBhbm90aGVyIG9yaWdpbiBrZWVwcyB0aGUgc2Vzc2lvbiBjb29raWVcblx0XHQvLyBmaXJzdC1wYXJ0eSwgd2hpY2ggaXMgd2hhdCBwcm9kdWN0aW9uIGxvb2tzIGxpa2UgXHUyMDE0IG90aGVyd2lzZSBldmVyeVxuXHRcdC8vIGNvb2tpZSBhbmQgQ1NSRiBiZWhhdmlvdXIgd291bGQgZGlmZmVyIGJldHdlZW4gZGV2IGFuZCB0aGUgcmVhbCB0aGluZy5cblx0XHRwcm94eToge1xuXHRcdFx0Jy9hcGknOiB7XG5cdFx0XHRcdHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MCcsXG5cdFx0XHRcdGNoYW5nZU9yaWdpbjogZmFsc2UsXG5cdFx0XHRcdHdzOiB0cnVlXG5cdFx0XHR9XG5cdFx0fVxuXHR9XG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4UixTQUFTLGlCQUFpQjtBQUV4VCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUEsSUFLTixPQUFPO0FBQUEsTUFDTixRQUFRO0FBQUEsUUFDUCxRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==