Compare commits

...
30 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 2b45aa33e1 feat(admin): ask the panel to restart, from verdande's own settings
Release / Build and publish image (push) Failing after 10m2s
A container cannot replace its own image from the inside, so "update" is really
"ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on the
way, which is what makes a restart an update at all. This removes the second
browser tab, not the panel's authority: the panel still does the work and still
records who asked.

Three settings, needed together, and the page names whichever are missing — an
operator reading "not configured" should not have to go and find out which of
three. The token is never sent to a browser; the page is told only whether it is
set.

Answers 202 rather than waiting. The panel stops this container as part of
answering, so the reply may never arrive, and a request cut off mid-flight is the
successful case wearing a failure's clothes. The interface believes neither answer
and polls /healthz until it responds.

Administrators only, and sessions only. A leaked API token that could restart the
server is a denial of service in one request.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:18:35 +02:00
Kristian WindandClaude Opus 5 19aa02e80c feat(projects): section headings you can count at a glance, and an Inbox that speaks the language
Two from use.

Section headings were small uppercase text over a hairline — a heading you have to
look for. A project with four sections is four groups, and the eye should be able
to count them without reading a word, so the heading gets a ground of its own and
the rows sit against the page. `--surface` rather than a tint of the accent: the
colour on that page belongs to the project, and a section is not a second thing to
colour. A step in lightness separates them in all five themes without anybody
measuring anything.

And the Inbox. The server names it at account creation, before anybody has seen a
settings page, so it is the one project name nobody chose — and an account made in
Danish went on saying "Indbakke" inside an English interface, which reads as a
string somebody forgot to translate. It was. `projectName()` translates it while it
still carries the name the server gave it; rename it and that is your name for it,
in every language, like any other project. The rename field keeps the raw name, or
editing a translated label would save the translation.

The screenshot set gains the list view, which is where sections read as bands
rather than as columns — and the shape most people actually work in.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:02:56 +02:00
Kristian WindandClaude Opus 5 fe52818ab6 feat(auth): passkeys, as a way in and as a second factor
A password and a TOTP code are both shared secrets: the server keeps something
that can be stolen from it, and the person keeps something that can be talked out
of them. A passkey is neither. The private half never leaves the device, and the
signature is bound to the origin that asked for it — so a convincing copy of the
sign-in page at another address gets a signature it cannot use.

Logging in asks for no email. The device knows which account its key belongs to,
which is not only convenience: it means the sign-in page cannot be used to find out
who has an account here. A key that verified its owner is both factors at once and
completes the login; one that only proved possession still meets the TOTP step,
exactly as a password does.

The relying party id is derived from VERDANDE_BASE_URL rather than configured
separately — a second setting that has to agree with the first is one that will
one day disagree, and the failure is a login nobody can complete. An IP address is
refused up front: no browser accepts one, and without the check the server issues
a challenge happily and the browser throws SecurityError long afterwards. The list
endpoint reports whether this deployment can offer them at all, so the interface
never shows a button that cannot work.

A challenge is stored server-side, answerable exactly once, and taken rather than
read — two requests racing the same one would both succeed, which is the shape of
a replay. A sign count that has gone backwards means the credential was cloned and
is refused rather than logged.

The Go tests cover who may reach these endpoints and that challenges are spent.
The ceremony itself needs an authenticator, so the smoke test drives Chromium's
virtual one over CDP — the only thing that can tell whether the base64url
conversions are right, since one wrong byte produces a signature that will not
verify and reads as a broken key rather than a broken encoder.

That test runs at localhost in its own context while the suite stays on the
loopback address, because WebAuthn refuses an IP and resolving localhost is slow
enough here to cost the suite minutes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:56:54 +02:00
Kristian WindandClaude Opus 5 982c3aab18 test(i18n): commit the guard that was written and never added
Release / Build and publish image (push) Failing after 10m6s
It was created alongside the three untranslated strings it catches and left
untracked — `git add -u` stages tracked files only. CI has been running without
it since.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:19:26 +02:00
Kristian WindandClaude Opus 5 a52ad9817f feat(tasks): undo a completion, and a place to find what was closed
Reported from use: "I just closed a task by accident, and there is no undo and no
list of closed tasks." Both halves were true.

Completing is one click on a small circle beside a row you were only reading, and
it takes the row off the screen — so the mistake and the evidence of it leave
together. There was no name left to search for and nowhere to look: the project
page can show its own closed tasks, but only if you already know which project it
was in.

The toast covers the mistake you notice at once. It names the task, so you can see
which one you hit, and stands for twelve seconds rather than five — five is enough
to read "could not save" and not enough to notice, decide and reach the mouse. A
toast with an action also gets two targets: the message dismisses and the action
acts, which were one button before and would otherwise put "yes, put it back" and
"go away" in the same place.

`/faerdige` covers the one you notice tomorrow. A place rather than a toggle in
every view, because somebody hunting for a task they closed does not know which
view they closed it in. Newest first — a record of what is finished is read
backwards from now, not by a due date the task no longer has — and grouped by the
day, because "I dag" and "I går" is how anybody would describe it.

A recurring task is offered no undo. Completing it moves it to its next date rather
than closing it, so there is nothing to reverse, and an undo that quietly rolled
the date back would be a different act from the one it claims to be.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:18:01 +02:00
Kristian WindandClaude Opus 5 0dbf7a40bf feat(tasks): write a task straight into a section
The box at the top puts things in the project with no section, which is right for
capturing. But a project has sections because the work belongs somewhere, and
dragging every new task down into the right one is a second gesture for something
you already knew when you typed it.

Two ways in, because they answer different moments. A field at the foot of each
section and each board column — standing there is enough, no syntax to know. And
`/section` in quick add, for the box at the top and for Today and Upcoming, where
you are not standing in a section at all.

The field goes through quick add rather than createTask, so a line written there
still parses its dates, priorities and labels.

The sigil needed care: '#' and '@' never turn up in a Danish task line by accident
and '/' turns up constantly. It has to follow whitespace and be followed
immediately by the name, which "km/t", "3/4" and "20 kr / stk" all fail. The bare
form is greedy, because "Kunder/Ordrer" is a real section name and the sigil is
inside it.

A section id from the client is checked against the project the task actually
landed in. A `#project` in the same line can move the task elsewhere, and the id
would otherwise follow it into a project that section does not belong to.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:13:10 +02:00
Kristian WindandClaude Opus 5 9cc7df69f0 fix(projects): the header stops crushing the project's name
"GarageRisteriet" rendered as "GarageRist / eriet" — broken mid-word. A name split
mid-syllable does not read as a wrap, it reads as a rendering fault.

Two causes. The row carried eight controls beside the heading — colour, show-done,
three view buttons, share, history, delete — and the heading was the only flexible
thing in it, so it was what gave way. And `overflow-wrap: anywhere` breaks at the
first opportunity rather than the last necessary one, so what gave way, gave way
inside a word.

Everything but the view switcher is behind one button now. What stays out is what
you use while working — which view you are in. What moves in is what you do *to* a
project rather than in it, and most of it once ever. The heading gets the row back,
and `break-word` only splits a word that could not fit on a line of its own.

The menu closes on Escape and on a click anywhere else, listened for in the capture
phase so a click whose own handler stops propagation still closes it. A menu you
can only leave by pressing its own button again is one you click twice to get out
of.

Three more untranslated strings turned up on the way: "Fjern {member.name}", "Luk
menu" and "En slettet konto". The guard missed them because none of its words
covered them — it knows fjern, opret, vælg, luk, tilføj, ryd, gem, slet and omdøb
now.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:00:32 +02:00
Kristian WindandClaude Opus 5 ab5e3e35a6 fix(i18n): three Danish strings that had no æ, ø or å in them
Spotted on the running instance: the version line read "Running v0.11.0. Der
bliver ikke set efter opdateringer." — half translated, on one line.

The sweep that was supposed to catch these grepped for Danish letters, and none of
the three survivors contain one: "Ingen nyere version fundet.", "Ikke mere i dag."
and a token hint split across two source lines so neither half looked like prose.

So there is a test for it now. It reads the templates and looks for the words that
give Danish away whatever letters they are spelled with — a word list rather than a
language detector, because this ships as one static binary and thirty common words
catch the sentences a person actually writes. It fails on the exact three that got
through.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:50:25 +02:00
Kristian WindandClaude Opus 5 d6d5bf590b fix(sections): a section can hold more than one task
Reported as "sections can only have one task", and that is exactly what it looked
like.

TaskList read the dragged task from its own `draggingId`, which is only set when
the drag *started* in that list. A task dragged in from the unsectioned rows above,
or from another section, arrived as null and the handler returned early — after it
had already called `stopPropagation`, so the section around it never got the drop
either.

So a section could only be dropped into while it was empty, because an empty one
has no row in the way to swallow the drop. Put one task in and there was no route
to a second. The id comes out of `dataTransfer` at drop time now, and `dragover`
decides with `carries()` rather than local state — which is the only thing a target
can read while a drag is in the air.

The stuck frame was the same cause: the section's own cleanup never ran, so the
highlight stayed lit after the drop. It is cleared on `dragend` now, which fires
once per drag on the source and bubbles to the window — the one place that is true
however a drag ends, including one abandoned with Escape, which produces no drop at
all and left the frame standing too.

The smoke test drops a second task onto the row already in the section, which is
the gesture that did nothing, and runs in WebKit as well.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:35:06 +02:00
Kristian WindandClaude Opus 5 6853897d00 ci: install WebKit as well, for the one test that needs it
The `webkit-forms` project was added to the Playwright config and CI was not told,
so it installed Chromium and the one WebKit test died on a missing executable —
26 passed, 1 failed, and the failure said nothing about the app.

It is one test on purpose. The suite stays Chromium, because running it in three
engines finds the same breakage three times; focus is the exception, and Safari
ignoring `autofocus` on anything inserted after load is what made "sections have
no function" true from inside Safari.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:05:29 +02:00
Kristian WindandClaude Opus 5 726a489a67 fix(migrations): say why foreign keys go off, accurately
Release / Build and publish image (push) Failing after 10m0s
The note on 0009 and 0012 said the drop would cascade and take every row with it.
It would not: nothing references `activity` or `attachments`, so dropping either
takes nothing along. That claim was carried over from 0008, where seven tables do
reference `tasks` and it is exactly right.

The real reason is the check that comes with the marker. With foreign keys on, the
copy is validated a row at a time against a half-rebuilt schema, and the first bad
row aborts with no idea how many others are like it. With them off,
`PRAGMA foreign_key_check` runs once before the commit and names what is actually
wrong — which matters, because these run against a live database on somebody's
next restart and a failed migration is a server that does not start.

The upgrade test now carries activity rows, an attachment and a group across all
five migrations rather than stopping at 0008, and checks that the columns added by
ALTER arrived with their defaults rather than as NULL.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:01:18 +02:00
Kristian WindandClaude Opus 5 626ea9aaec feat(tasks): finished work can be looked at, and is out of the way by default
A task list is what is left to do, so closing something has to take it off the
plan. But "what did I get done" is a real question and the answer was nowhere:
once a task was closed it left the interface entirely.

They come back in one list at the bottom rather than back among the open ones in
their sections. A closed task has stopped being work and started being a record,
and putting the record in the middle of the plan makes the plan longer without
making it say more.

Off by default, and deliberately: a list that opens full of things already done is
a list you have to read past before you can use it.

In localStorage rather than on the account, like the sidebar's width and unlike a
folded group. It is a way of *looking*, not a statement about the work — somebody
reviewing their week wants it on for ten minutes, and a setting that followed them
to every device is one they would have to turn off twice.

`loadTasks` asks for closed tasks when the setting is on, rather than each caller
deciding. A view added later cannot then forget and quietly ignore it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:49:58 +02:00
Kristian WindandClaude Opus 5 4d8b70918b feat(i18n): the interface speaks both languages
The account has had a `locale` since the beginning and it only chose which grammar
quick add read a line with — so picking English changed nothing anybody could see,
and the field said so honestly: "Sprog i hurtig tilføjelse". It says "Sprog" now,
because it means it.

Every component goes through `t()`. Danish is the source: a key missing from
English falls through to Danish rather than to the key, because a Danish sentence
in an English screen can be read and `settings.notifications.title` cannot.

`tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a
dozen components, and that is the half of a translation that gets left behind — an
English interface still saying "24. aug." is exactly the shape of a job half done.
There is a smoke test that switches to English and refuses to see a Danish month
name in the calendar's heading.

Colour and theme names are keys now. `colors.js` keeps its ids untouched — they
are what the database stores, and `internal/store` reads them out of that file —
but `name` is `'color.tomato'`, resolved where it is drawn.

The Inbox keeps its name. It is a project row the server creates at signup, not a
string in the interface, and it can be renamed like any other project.

Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had
already drifted: one said "aldrig logget ind", another "aldrig", and two stopped
at days where the others carried on to a date.

Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and
they are in every smoke test; translating them buys nothing and costs both.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:46:08 +02:00
Kristian WindandClaude Opus 5 e3c7fc792a feat(i18n): the machinery, and the shell translated
The account has had a `locale` since the beginning, and it only chose which
grammar quick add parsed a line with — so setting it to English changed nothing
anybody could see. This is the other half of that field, started.

`t()` reads `i18n.locale`, which is `$state`, so every component that calls it
redraws when the language changes; that is what makes the setting take effect
without a reload, and why the module is `.svelte.js`. Danish is the source and the
fallback: a key missing from English falls through to Danish rather than to the
key, because a Danish sentence in an English screen can be read and
`settings.notifications.title` cannot. `tag()` is the single place `Intl` is
asked, so dates follow the language instead of being `da-DK` in a dozen files.

`internal/i18n` is a Go package that exists only to fail the build when the two
dictionaries drift — the same trick `colors_test.go` plays on the palette. It
checks the placeholders as well as the keys: a translation that spells `{n}` as
`{antal}` leaves `{antal}` on the screen, which is worse than an untranslated
sentence because it looks like a bug in the app rather than a gap in a dictionary.

Converted so far: the shell. Sidebar, layout, quick add, task row, command
palette — fifty-two keys, and the surfaces people look at constantly. Twenty
files remain, the settings pages being the heavy ones.

The language picker is deliberately not wired up yet. It still says "Sprog i
hurtig tilføjelse" and still only changes the parser. Wiring it before the sweep
is done would hand anybody who picks English a bilingual screen — the
half-finished-theme failure `app.css` already taught us. As it stands Danish is
unchanged and English is not offered, which is the honest state for something
half done.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:25:58 +02:00
Kristian WindandClaude Opus 5 804963b222 feat(groups): a group becomes somewhere you can go
It was a heading over some rows: a name, a colour, and whether it was folded.
Enough to tidy a list, and not enough to be what people mean by it — "Arbejde" is
a body of work with context of its own, and documents that belong to all of it
rather than to any one project inside it.

`/gruppe/{id}` carries three things a heading cannot. The projects in it, each
with what is still open — open tasks, not all of them, because "12" beside a
project somebody finished last year is a number that means nothing. A description,
written in place and saved on blur, the same shape a task's description has. And
the files that govern the whole group: the contract, the drawing, the agreement
everything else rests on.

The name in the sidebar is a link to it and the chevron beside it still folds.
Two intentions, two targets — the single one they used to share could only ever
serve the smaller.

Migration 0012 rebuilds `attachments`, because its CHECK spelled out exactly which
parent columns may be set and SQLite cannot alter a CHECK. The rule is still
"exactly one parent", counted rather than written out as pairwise comparisons:
with three columns that is three clauses, with four it is six, and the version
that is wrong looks exactly like the one that is right.

A group's file is reachable by its owner and nobody else. A group belongs to one
person and is never shared, so `reachAttachment` asks about ownership rather than
a project role — both old callers resolved straight to a task, so a group's
attachment answered 404 for the person who uploaded it.

Three tests also stopped racing their own navigation. Creating a project navigates
and `goto` is a promise nobody was holding, so a quick add fired straight after
could land in the Inbox instead — intermittent, and it read as the quick add
having failed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:20:03 +02:00
Kristian WindandClaude Opus 5 0b467beb3e docs: the API and the handover, brought up to what is here
The OpenAPI document gains the audit log, the backups, the Gmail client and the
sidebar sections, and loses the paragraph that said deleting an account takes
every task the person ever wrote.

The handover gains the day: why an account can be deleted without taking other
people's work, why the audit log pages with a keyset, why a week view exists at
all, why form fields are 16px on touch, and why `autofocus` is gone.

And the trap that cost the most, which was not in the code: the build hung on 0%
CPU because iCloud had evicted node_modules and a stalled download blocks a read
forever. The fix is a repo path outside a synced folder.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 1a6adfd9d3 docs: screenshots that regenerate themselves, and eleven of them
They were many rounds behind — no settings, no task detail, no themes, no groups,
no colours, no calendar, no admin pages. The reason is in how they were made:
`tools/shots` needs a running instance and a session cookie pasted in by hand, so
it ran about as often as somebody felt like doing that.

`node scripts/generate-screenshots.js` starts the real binary, creates its own
account, fills it with Danish demo data and photographs eleven surfaces into both
`site/screenshots` and `docs/screenshots`. Nothing to set up and nothing to
remember.

A script rather than a test, for the same reason `generate-icons.js` is one: CI
must never rewrite a tracked binary file.

`tools/shots` stays. It is still the way to photograph an instance that is
already running rather than a fresh one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 01d498be20 fix(projects): group_id was accepted on create and thrown away
The field was declared on the request type, commented on it, and applied by the
update handler — and the create handler never read it. Creating a project into a
group answered 201 and put it nowhere. A field that is accepted and discarded is
worse than one that is refused: nothing reports it.

Found by looking at a screenshot. Both groups stood empty while the projects sat
loose above them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 31bf0557df feat(gmail): the OAuth client can be entered instead of deployed
The one click that opens Google's consent screen already existed. What it needs
behind it is a registered client, and no app can conjure one: Google issues no
Gmail access to an unregistered client, and `gmail.readonly` is a restricted
scope, so an id shipped inside a public image would be both extractable from it
and unusable without Google's security review of every deployment using it.

The registration is a one-off. Having to edit a Rune's manifest and recreate the
container to paste the result was not.

`VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when set. A value in the
environment is a deliberate deployment decision, applied again on every recreate,
and a form that could quietly override it would make the manifest a lie — so the
page says where the value came from, and PUT answers 409.

The redirect URI is spelled out on the page rather than left to be reconstructed.
It is derived from VERDANDE_BASE_URL, it has to match Google's copy exactly, and
the error Google returns when it does not names neither value.

Its own table rather than `user_settings`: an OAuth client is the instance's, not
one person's — and `user_settings` has `user_id` as its whole primary key, so a
second scope there would silently overwrite the AI provider's key.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:18 +02:00
Kristian WindandClaude Opus 5 101ca33069 feat(tasks): a row says what is underneath it, and what is attached
A task with three sub-tasks looked exactly like one without, and so did a task
with a drawing on it. The whole reason to attach something is that somebody
should find it.

Counted in the same query as the tasks, as correlated subqueries: a list of fifty
would otherwise be a hundred more lookups, and the point is that you see this
without asking.

Said as a fraction rather than a total. "3" tells you something is there; "1/3"
tells you whether there is anything left to do, which is the question a list is
for. All-done goes accent-coloured, because "3/3" and "1/3" are the two answers
you actually look for and only one of them is good news.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:18 +02:00
Kristian WindandClaude Opus 5 eaff890860 fix(forms): Safari focused none of them, and a board could not make a section
Two reports, one sentence between them: "sections have no function, because you
cannot create more than one."

`autofocus` does not work in Safari for anything inserted after the page has
loaded, and every inline form here is inserted after the page has loaded — rename
a project, add a section, name a group. In Safari they all opened with no cursor
in them and what you typed went nowhere. That does not look like a bug. It looks
like the feature does not work. `use:focusOnMount` replaces all seventeen.

And the board had no way to add a section at all. The button lived only in the
list view, which is the one place nobody looks for it: a column on a board *is* a
section, so that is where you go to make one.

Chasing this turned up a third. The task drawer's three fields save on blur, and
closing the drawer *is* a blur — the input is being unmounted and the browser
fires blur on the way out. If the task left the store in the same moment, `task`
was already null and the handler threw `Cannot read properties of null`, stopping
the page. It only became reproducible once focus actually worked.

Fields are 16px on a touch device now, whatever the component asked for. Below
16px, iOS Safari zooms in when a field takes focus and does not zoom back out —
so the whole interface is left too large and has to be pinched back by hand, once
per field. The type scale tops out at 15px, so every input in the app did it.

The sidebar's resize handle sat at `right: -3px`. An absolutely positioned child
counts towards its container's scrollable overflow, and `overflow-y: auto`
resolves the other axis to `auto` too — so those three pixels gave the whole menu
a horizontal scrollbar on every desktop, always. The account name could do it as
well: `overflow: hidden` with no `min-width: 0` cannot shrink, so the row grew
instead of ellipsing.

There is a WebKit pass in the suite now, over one test. The rest stays Chromium
on purpose — a smoke test exists to catch a build that does not work, and three
engines find the same bug three times — but focus is the exception, and it took a
person using the app to find that out.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:02 +02:00
Kristian WindandClaude Opus 5 1da7ea64bf feat(admin): the nightly backup, made visible
It has run since the beginning and nothing showed it. A backup nobody can see is
a backup nobody knows has been failing, and the failure is quiet: a volume gone
read-only, a disk that filled, a container that has not been up at midnight all
week. The first anybody hears of it is the day they need one.

`ListBackupRuns` was already in the store and exposed nowhere. Now there is a
list, a download, and a button that takes one now — the same work the nightly job
does without its "one a day" check, because waiting until tonight to learn whether
backups work at all is how somebody finds out on the day they need one, and it is
the only way to get a snapshot from *before* something you are about to do.

Restoring is deliberately not a button. Swapping a database under a server that
is writing to it means stopping every writer, replacing the file and reopening,
and a half-done version of that destroys the thing it was restoring. The panel
says what to do instead.

`RunBackupNow` turns out to have been a second copy of the backup logic, written
for a button that did not exist yet, and the two had already drifted: it never
created the backups directory and logged nothing. One implementation now.

Administrators only, and sessions only. A backup is a complete copy of the
database, so this hands out everybody's data in one file.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:43 +02:00
Kristian WindandClaude Opus 5 04a7ba1024 feat(sidebar): headings that fold, a group head that is not indented, and colour where it is edited
Three things reported from use, all in the sidebar.

Projekter, Delt med mig, Filtre and Etiketter fold, the way a project group
already does. Kept on the account rather than in localStorage, and for the same
reason the group's own `collapsed` is: folding a heading says "this is the part
of my work I am not in right now", and that is as true on the laptop as on the
desktop. The sidebar's *width* is the opposite case and stays per browser. A JSON
array of keys rather than a column each, so a renamed heading is not a migration.

The group heading is no longer indented — only what is filed under it. The colour
dot sat as a sibling before the fold button and pushed the whole row in, and a
heading that starts further in than the rows above it says the heading is inside
something.

And the colour is chosen while renaming. The dot was itself the button, which
gave the heading four controls on a line narrow enough to crowd the name; both
are edits to the same thing, so they belong in the same moment. The form closes
on `focusout` and only when focus has left it entirely, and the swatches refuse
focus on mousedown — Safari and Firefox do not focus a button on click, so
`relatedTarget` would be null and the form would shut under the pointer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:30 +02:00
Kristian WindandClaude Opus 5 b169591209 feat(upcoming): a week at a time, so a drag can cross a month boundary
A month grid is anchored to a month, so the two days on either side of its edge
sit in different grids — and you cannot page mid-drag, because a drag in flight
swallows the click that would page. A week that straddles the 31st and the 1st
has both days in the same row.

The same component as the month, with `span="week"`, rather than a second one:
the part that is easy to get almost right is the same in both. A drop target has
to decide from `dataTransfer.types` alone whether it accepts, and two copies of
that are two places to be almost right.

Seven cells instead of forty-two, so the height a month spends on six rows goes
into one and the cells hold a real list rather than a hint. On a phone the row
stacks and each day carries its own weekday name, because a column of bare
numbers is not a week.

`calendar` still means the month in localStorage. It is the value already stored
for everybody who chose it, and renaming it would silently reset them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:30 +02:00
Kristian WindandClaude Opus 5 85f7b2488d feat(admin): what has been done on this server, across every project
The other half of the error log. That one is what broke; this is what was done,
and nobody could ask it: `ListActivity` takes one project id, and an
administrator is not necessarily a member of the project they need to look into.

Paged with a keyset cursor rather than an offset. The log is written to
constantly and read rarely, so a page fetched a minute after the one before it
would be shifted by everything written in between — one row shown twice and the
row behind it skipped, which is the single failure an audit log must not have.
Both halves of the cursor matter: the log is written in whole seconds, so a
timestamp alone cannot separate two rows written in the same one.

Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it
to `created_by`. A log whose rows vanish with the account they describe answers
"what has happened here" with everything except the part somebody had a reason to
remove. `project_id` keeps cascading on purpose: the trash is the undo, and past
it the deletion was deliberate.

The event names moved to `web/src/lib/events.js`, because two surfaces read the
same log now and two copies of nineteen strings is two places for them to drift.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:12 +02:00
Kristian WindandClaude Opus 5 ebdc8eacf2 feat(admin): deleting an account stops deleting other people's work
`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every
task they had ever written — including in projects somebody else owns. The
interface said so in plain words before you pressed the button, which is a
warning rather than a fix. It was the last remaining way to lose other people's
work by accident.

SET NULL instead: the task stays and loses its author, the same shape
`assignee_id` and `completed_by` already have.

SQLite cannot alter a foreign key action, so the table is rebuilt — and with
foreign keys on, the drop in the middle of a rebuild is a cascading delete that
takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op
inside a transaction, so migrations now run on a connection of their own, and a
migration can opt out of enforcement with `-- verdande:rebuild-tables`.
`PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
dangling reference fails the migration rather than writing it.

The test fills a database at 0007, upgrades it, and counts. Without the marker it
loses a task, a label link and a comment — which is exactly what it is for.

The admin list now says two numbers instead of one: what disappears, and what
stays behind unattributed. They are different sentences and the second is a
reassurance.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:03:59 +02:00
Kristian WindandClaude Opus 5 a29534c811 build: lift Vite to 8, and get the tree out of iCloud's way
The frontend build hung on 0% CPU, roughly every other run: blocked in read()
inside libuv, no output, forever. `svelte-kit sync` alone did it too, so it was
not the bundler.

The cause was not the toolchain. `~/Documents` is iCloud-managed, and iCloud
evicts what it thinks is cold — which for a dependency tree is nearly all of it:
359 of 400 sampled files under node_modules were `dataless`. Reading an evicted
file blocks until it is downloaded again, and a download that stalls hangs the
build for good. The tell was a different error entirely: Playwright once reported
`ETIMEDOUT: connection timed out, read` while loading jsconfig.json, and
ETIMEDOUT on a local file read does not exist.

Vite 5 and esbuild 0.21 predate Node 22 and this machine runs Node 26, so they
went up anyway — to Vite 8 with @sveltejs/vite-plugin-svelte 7, which SvelteKit
2.70 takes. `package-lock.json` had to be deleted for npm to resolve it; it held
plugin 4 and rejected the whole tree with ERESOLVE. That made the build faster.
It was not what was hanging.

`engines` says Node >= 22.12 now, so the next person is told rather than left
watching a process do nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:03:45 +02:00
Kristian WindandClaude Opus 5 494640c1a8 fix(mobile): the drawer no longer pushes the page sideways
Release / Build and publish image (push) Failing after 10m8s
The task drawer was `width: 100vw`. A viewport unit includes the scrollbar
gutter and a percentage does not, so the drawer came out a few pixels wider
than the page it was pinned to and the whole document could be shoved
sideways. It is `100%` now, and at phone width it is stated as four insets
instead — a box defined by the viewport's own edges cannot be a rounding
error wider than it.

The scrolling body gets `min-width: 0` and `overflow-x: hidden` too, so a
pasted URL or a long unbroken word in a comment cannot stretch the flex
column and take the page with it.

Writing the test for that found another one: creating a project from the
sidebar navigated to it and left the drawer covering the screen. Every link
in there closes it on the way out; the form did not, because it calls
`goto` directly. Now it does the same thing for the same reason.

The phone test checks the document cannot be scrolled horizontally, both
with the drawer shut and with it open.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 23:24:49 +02:00
Kristian WindandClaude Opus 5 6d27d730d2 fix(mobile): the project header wraps instead of crushing the title
Release / Build and publish image (push) Failing after 10m4s
On a phone, a project called "Skovvænget" rendered as a vertical column of
single letters while the buttons ran off the right edge. It looked like a
broken font.

The header is a flex row of a title and four controls, and 390px does not
have room for them. Nothing wrapped, so the heading was shrunk to its
min-content — which with `overflow-wrap: anywhere` is one character. Adding
Historik this evening is what tipped it over.

It wraps now, and the title keeps a floor of 8ch so flex stops squeezing at
the point where wrapping is the better answer. Kommende had the same shape
and gets the same fix.

Every other smoke test runs at desktop width, which is exactly why this
shipped. There is one at 390 now: it measures the heading's box — wide
enough to hold a word, short enough not to be a column of letters — and
checks the page cannot be scrolled sideways.

Also stops an empty project saying both "Uden sektion" and "Ingenting her
endnu": that label is a heading for the tasks outside a section, and with no
sections there is nothing for them to be outside of.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 23:09:08 +02:00
Kristian WindandClaude Opus 5 b0ab6ee5c0 fix(docker): build the frontend and the binary once, and cross-compile
Two of the last five releases died at exit 132 — SIGILL, an instruction
QEMU does not implement — partway through `npm run build`. It reads as a
broken build and is a broken emulator.

The cause is that neither builder stage was pinned to the build platform,
so buildx ran both once per target: Node and esbuild under emulation for
arm64, and the entire Go toolchain too. Nothing in either stage needs to be
the target architecture. The frontend produces HTML, CSS and JavaScript,
which are the same bytes wherever the image runs, and Go cross-compiles
natively with CGO already off.

So both are `--platform=$BUILDPLATFORM` now, and the binary is built with
`GOARCH=${TARGETARCH}`. The frontend is built once instead of twice, and
nothing is emulated at all — which should also take most of the fifteen
minutes off a release.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 23:04:09 +02:00
127 changed files with 11755 additions and 1896 deletions
+11 -5
View File
@@ -79,12 +79,18 @@ jobs:
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
# Chromium for the suite, WebKit for one test.
#
# A smoke test exists to catch a build that does not work at all, and running
# the whole thing in three engines finds the same breakage three times for
# three times the cost. Focus is the exception, and it took a real report to
# learn it: Safari ignores `autofocus` on anything inserted after the page has
# loaded, so every inline form in the app opened with no cursor in it. WebKit
# here is the same engine Safari is, and `webkit-forms` in the Playwright
# config greps a single test out of the suite to run in it.
- name: Install browsers
working-directory: web
run: npx playwright install --with-deps chromium
run: npx playwright install --with-deps chromium webkit
# The suite builds the frontend, embeds it and starts the real binary — see
# playwright.config.js. There is nothing to build here first.
+10
View File
@@ -11,6 +11,16 @@
# Frontend
node_modules/
# iCloud syncs ~/Documents, and it evicts what it thinks is cold — which for a
# dependency tree is nearly all of it. An evicted file blocks on read until it is
# downloaded again, and a download that stalls hangs the build for good: 0 % CPU,
# no output, forever. macOS never syncs anything ending in .nosync, so the tree
# lives there and `node_modules` is a symlink to it. Redo it after a fresh clone:
#
# mv node_modules node_modules.nosync && ln -s node_modules.nosync node_modules
#
# The real fix is a repo path outside a synced folder.
node_modules.nosync/
/web/.svelte-kit/
/web/build/
+20 -3
View File
@@ -5,7 +5,17 @@
# manager and no libc — the attack surface of the running container is the binary.
# --- 1. the web interface ----------------------------------------------------
FROM node:22-alpine AS web
#
# Pinned to the *build* platform, not the target. What this stage produces is
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
# on — so building it once and copying it into both architectures is not an
# optimisation, it is the correct thing.
#
# Without the pin, buildx runs this stage once per target, which means running
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
# instruction the emulator does not implement, roughly one release in three. It
# looks like a broken build and is a broken emulator.
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src
COPY web/ ./
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
fi
# --- 2. the binary -----------------------------------------------------------
FROM golang:1.26-alpine AS build
#
# Also pinned to the build platform, and cross-compiled instead. Go does that
# natively and CGO is already off, so there is nothing to emulate — where
# building *in* the target architecture means running the whole Go toolchain
# under QEMU for one of the two.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src
@@ -37,11 +52,13 @@ COPY . .
COPY --from=web /src/build ./cmd/verdande/webbuild
ARG VERSION=dev
# Supplied by buildx per target. The default keeps a plain `docker build` working.
ARG TARGETARCH=amd64
# CGO off is what makes this binary static, and it is only possible because the
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
RUN CGO_ENABLED=0 GOOS=linux go build \
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-tags embedweb \
-trimpath \
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
+11 -1
View File
@@ -118,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. |
| ![A week on Upcoming](site/screenshots/week.png) | ![A task, opened](site/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
| ![The five themes](site/screenshots/themes.png) | ![Instance history](site/screenshots/history.png) |
| Five themes, all measured for contrast. | What has been done on the server. |
Regenerate these with `go run ./tools/shots` against a running instance.
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
the real binary, creates its own account, fills it with demo data and photographs
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
still works, and is the way to photograph an instance that is already running.)
## Running it
@@ -190,6 +197,9 @@ to put one that the operator would ever see.
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
## What lives in `/data`
+12
View File
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
All three together let **Indstillinger → Notifikationer** restart this instance,
which is also how it picks up a new version: a container cannot replace its own
image, so the panel recreates it and pulls `:latest` on the way. Any one of the
three alone does nothing, and the page names the ones that are missing.
The token is a panel credential with control over that server. Anybody who can
read this environment can restart it — which is true of anybody with the host
anyway — and it is never sent to a browser.
Off unless you turn it on. When on, verdande asks the public GitHub releases API
at most every six hours and sends nothing about your instance — no identifier, no
+718 -9
View File
@@ -226,6 +226,43 @@ paths:
"422": { $ref: "#/components/responses/Validation" }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/sidebar-sections:
put:
tags: [auth]
summary: Which sidebar headings are folded away.
description: |
On the account rather than in the browser. A project group already carries
`collapsed` on its own row for the same reason: folding a heading is a
statement about the work — "I am not in Etiketter at the moment" — and that
is as true on a laptop as on a desktop. The sidebar's *width* is the
opposite case and stays in the browser, because a narrow laptop and a wide
monitor deserve different answers.
Its own route rather than a field on `PATCH /auth/me`. That one backs a
form with a save button and validates three fields together; this writes on
every click of a chevron, and sharing the path would mean a fold sending a
name and a timezone it was never asked to change.
The keys belong to the interface and are not checked here. An unknown one
costs nothing — the sidebar folds what it recognises and ignores the rest —
so renaming or removing a heading leaves a harmless entry rather than
needing a migration.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
sections:
type: array
maxItems: 20
items: { type: string }
example: ["labels", "filters"]
responses:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/me:
get:
tags: [auth]
@@ -351,6 +388,195 @@ paths:
"204": { description: Off. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/passkeys:
get:
tags: [auth]
summary: The keys registered on this account.
description: |
A passkey is a key on a device rather than a secret in a head. The private
half never leaves the device, and the signature is bound to the origin that
asked for it — so a convincing copy of the sign-in page at another address
gets a signature it cannot use.
One row per credential, because a person has a laptop and a phone and losing
one must not mean losing the way in. The name is theirs to write: a list
nobody can read is a list nobody revokes from.
Sessions only, like `/tokens` — a leaked API token must not be able to add
its own way in, which would turn a theft into a tenancy.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
passkeys:
type: array
items: { $ref: "#/components/schemas/Passkey" }
available:
type: boolean
description: |
Whether this deployment can offer passkeys at all. False when
`VERDANDE_BASE_URL` is an IP address or otherwise not a
domain an authenticator will accept — the interface asks once
rather than discovering it from a 503 it caused by offering a
button.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/auth/passkeys/register/begin:
post:
tags: [auth]
summary: Start registering a key. Returns the options and a challenge id.
description: |
Registration is done by somebody already signed in: you cannot be handed a
way into an account you have not proved you own.
The challenge is kept server-side rather than in a cookie, because the whole
point of it is that the server chose it and remembers choosing it. It is
answerable once and expires after five minutes.
responses:
"200":
description: Pass `options` to `navigator.credentials.create()`.
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
options: { type: object, additionalProperties: true }
"503":
description: |
`VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
rather than 404: this version has passkeys, this deployment cannot offer
them, and those are different things to fix.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v1/auth/passkeys/register/finish:
post:
tags: [auth]
summary: Finish registering a key.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
name:
type: string
description: What to call it in the list — "min bærbare".
credential:
type: object
additionalProperties: true
description: The response from `navigator.credentials.create()`.
responses:
"201":
description: Registered.
content:
application/json:
schema: { $ref: "#/components/schemas/Passkey" }
"400":
description: The attempt expired, was already used, or did not verify.
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/passkeys/{passkeyID}:
parameters:
- { name: passkeyID, in: path, required: true, schema: { type: string } }
patch:
tags: [auth]
summary: Rename a key.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
responses:
"204": { description: Renamed. }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [auth]
summary: Remove a key.
description: |
Scoped in the SQL rather than checked first: an id read in one step and
deleted in the next is a window, and the reason somebody is on this screen
is usually that they think a device is gone.
responses:
"204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/auth/passkey/login/begin:
post:
tags: [auth]
summary: Start signing in with a key. No session, and no email.
description: |
A discoverable login: nobody has said who they are yet, and that is the
point. The device knows which account its key belongs to, so there is no
address to type and no list of accounts for anybody to probe.
responses:
"200":
description: Pass `options` to `navigator.credentials.get()`.
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
options: { type: object, additionalProperties: true }
"429": { $ref: "#/components/responses/RateLimited" }
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/passkey/login/finish:
post:
tags: [auth]
summary: Finish signing in with a key.
description: |
A key that verified its owner — a PIN, a fingerprint, a face — is both
factors at once, and completes the login outright. One that only proved
possession still stops at the TOTP step, exactly as a password does.
A counter that has gone backwards means the credential has been cloned. It
is refused rather than logged: that counter is the only signal this design
gives.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
credential:
type: object
additionalProperties: true
description: The response from `navigator.credentials.get()`.
responses:
"200":
description: Signed in, or `totp_required` when the key was possession only.
content:
application/json:
schema:
type: object
properties:
user: { $ref: "#/components/schemas/User" }
totp_required: { type: boolean }
"401":
description: |
One message for an unknown key and for a bad signature. Telling them
apart is a way to learn which credentials exist here.
"429": { $ref: "#/components/responses/RateLimited" }
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/sessions:
get:
tags: [auth]
@@ -856,6 +1082,322 @@ paths:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/activity:
get:
tags: [auth]
summary: What has been done on this instance. Administrators only.
description: |
The same rows as `/projects/{projectID}/activity`, read across every
project instead of one at a time.
The per-project history is a question a project's members ask about their
own work. This is a different question — "what has happened on this
server" — and nothing could answer it: the per-project endpoint takes one
project id, and an administrator is not necessarily a member of the
project they need to look into.
It is the other half of `/errors`. That one is what broke; this is what was
done.
Rows outlive the accounts that wrote them. `activity.user_id` is
`ON DELETE SET NULL`, so a deleted account leaves its trail behind with
`user_id` and `user_name` absent — a log that lost those rows would be
missing the part most worth keeping.
Paged with a keyset cursor rather than an offset. The log is written to
constantly and read rarely, so a page fetched a minute after the one
before it would be shifted by everything written in between: a row shown
twice and the row behind it skipped.
parameters:
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
- name: before
in: query
schema: { type: string }
description: |
`next_cursor` from the previous page, as `<unix>:<id>`. Both halves
are required: the log is written in whole seconds, so a timestamp
alone cannot separate two rows written in the same one.
example: "1755432000:01J8Z9"
- { name: user_id, in: query, schema: { type: string } }
- { name: project_id, in: query, schema: { type: string } }
- name: event
in: query
schema: { type: string }
description: An exact event name, as listed by `/activity/events`.
example: task.completed
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
activity:
type: array
items:
type: object
properties:
id: { type: string }
at: { type: string, format: date-time }
project_id: { type: string }
project_name: { type: string }
task_id: { type: string }
user_id:
type: string
description: Absent when the account has been deleted.
user_name:
type: string
description: Absent when the account has been deleted.
event: { type: string, example: task.completed }
payload: { type: object, additionalProperties: true }
next_cursor:
type: string
description: |
Absent on the last page. Present only when the page came
back full — a short page is the end of the log, and a
cursor there invites one more request that can only come
back empty.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/activity/events:
get:
tags: [auth]
summary: The event names the log contains, with counts. Administrators only.
description: |
What the `event` filter is drawn from, most frequent first.
Offered rather than typed. Event names are an internal vocabulary —
`member.role_changed`, not a sentence anybody would guess — so a text
field for them is a field that returns nothing until you have read the
source. The counts are worth having on their own: they are the shape of
what the instance does.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
events:
type: array
items:
type: object
properties:
event: { type: string, example: task.completed }
count: { type: integer }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/gmail/client:
get:
tags: [integrations]
summary: The Gmail OAuth client this instance uses. Administrators only.
description: |
The registration Google requires, enterable rather than deployed.
The one click that opens Google's consent screen already exists. What it
needs behind it is a registered client, and no app can conjure one: Google
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
restricted scope, so an id shipped inside a public image would be both
extractable from it and unusable without Google's security review of every
deployment that used it.
The registration is a one-off. Having to edit a Rune's manifest and
recreate the container to paste the result was not, which is what this
replaces.
`VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when they are set. A
value in the environment is a deliberate deployment decision — under
version control, reapplied on every recreate — and a form that could
quietly override it would make the manifest a lie. `from_env` says so, and
`PUT` answers 409.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/GmailClient" }
"403": { $ref: "#/components/responses/Forbidden" }
put:
tags: [integrations]
summary: Set the Gmail OAuth client. Administrators only.
description: |
An empty `client_secret` means "leave the stored one alone", not "delete
it" — otherwise correcting a typo in the id would silently clear the
secret. The secret is never read back, for the same reason the AI key is
not: a settings page that repopulates a password field is one that will
eventually leak into a screenshot.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
client_id: { type: string }
client_secret: { type: string }
responses:
"204": { description: Saved. }
"403": { $ref: "#/components/responses/Forbidden" }
"409":
description: The environment sets it, so this cannot.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/backups:
get:
tags: [auth]
summary: The nightly backups. Administrators only.
description: |
The backup job has run since the beginning and nothing showed it. A backup
nobody can see is a backup nobody knows has been failing, and the failure
is quiet: a volume that went read-only, a disk that filled, a container
that has not been up at midnight all week. The first anybody hears of it
is the day they need one.
The most recent fourteen files are kept — counted by file rather than by
age, so a container that was off for a month does not come back and delete
every backup for being too old. Rows older than that stay in the list with
`present: false`: a true record of a backup that was made and a file that
is no longer there.
Sessions-only as well as administrators-only. A backup is a complete copy
of the database, so this is everybody's data in one file.
parameters:
- { name: limit, in: query, schema: { type: integer, default: 30, maximum: 100 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
backups:
type: array
items: { $ref: "#/components/schemas/BackupRun" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [auth]
summary: Take a backup now. Administrators only.
description: |
The same work the nightly job does, without its "one a day" check.
Two reasons it exists: waiting until tonight to find out whether backups
work at all is how somebody finds out on the day they need one, and it is
the only way to get a snapshot from *before* something you are about to
do.
Synchronous — a homelab database is a second or two, and a button that
returns immediately and says nothing is a button people press twice.
responses:
"201":
description: Written.
content:
application/json:
schema: { $ref: "#/components/schemas/BackupRun" }
"403": { $ref: "#/components/responses/Forbidden" }
"500":
description: |
The snapshot failed — a full volume, or one that has gone read-only,
which is precisely the condition this button exists to surface.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v1/backups/{backupID}:
parameters:
- { name: backupID, in: path, required: true, schema: { type: string } }
get:
tags: [auth]
summary: Download one backup file. Administrators only.
description: |
A SQLite database, served as `application/octet-stream` with
`Content-Disposition: attachment` — nothing should ever be tempted to
render it inline.
Restoring is deliberately not an API call. Putting a database back under a
running server means stopping every writer, swapping the file and
reopening, and a half-done version of that corrupts the thing it was
restoring. Stop the container, put the file in place of `verdande.db` in
the data volume, delete the `-wal` and `-shm` beside it, and start it
again.
A row whose file has been rotated away answers 404. That is the honest
answer to "send me that one".
responses:
"200":
description: The database file.
content:
application/octet-stream:
schema: { type: string, format: binary }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/panel:
get:
tags: [auth]
summary: Whether this instance can restart itself. Administrators only.
description: |
A container cannot replace its own image from the inside, so "update" is
really "ask whoever owns the container to recreate it" — Yggdrasil pulls
`:latest` on the way, which is what makes a restart an update at all.
This says whether the three settings are present. The token is never sent:
the page needs to know whether the button can work, not what the credential
is. When it cannot, the missing variables are named — an operator reading
"not configured" should not have to go and find out which of three.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
configured: { type: boolean }
panel_url: { type: string }
missing:
type: array
items: { type: string }
description: The environment variables that are not set.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/panel/restart:
post:
tags: [auth]
summary: Ask the panel to recreate this container. Administrators only.
description: |
The panel does the work and records who asked; this removes the second
browser tab, not the panel's authority.
Answers 202 rather than waiting: the panel stops this container as part of
answering, so the reply may never arrive. A request cut off mid-flight is
the successful case wearing a failure's clothes — the interface polls
`/healthz` rather than believing either answer.
Sessions only, like the rest of this group. A leaked API token that could
restart the server is a denial of service in one request.
responses:
"202":
description: The panel accepted, or was already restarting this container.
content:
application/json:
schema:
type: object
properties:
restarting: { type: boolean }
note: { type: string }
"403": { $ref: "#/components/responses/Forbidden" }
"502":
description: |
The panel refused. Usually a token without control of this server.
"503":
description: The three settings are not all present.
/api/v1/errors:
get:
tags: [auth]
@@ -1012,11 +1554,12 @@ paths:
The only hard delete in this API. Everything else is soft-deleted with a
trash behind it; this is not recoverable.
What goes is wider than it looks: their projects, every task in those
projects, **and every task they wrote anywhere** — `tasks.created_by`
cascades, so retiring a colleague's account removes their contributions
from shared projects too. Tasks merely assigned to them are unassigned
and survive.
What goes: their projects, and every task in those projects.
What stays: everything they wrote in **somebody else's** project.
`tasks.created_by` is `ON DELETE SET NULL`, so that work survives and
loses its author rather than disappearing with the account. Tasks merely
assigned to them are unassigned and survive by the same rule.
Refused for your own account, and for the last administrator.
responses:
@@ -1100,6 +1643,37 @@ paths:
/api/v1/project-groups/{groupID}:
parameters:
- { name: groupID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: The group as a place, with what is in it.
description: |
A heading can carry a name. A page carries what the group *is*: the
sentence saying why these projects are one body of work, the documents
that belong to all of them rather than to any one, and the projects
themselves.
The projects come with the group rather than being fetched per row — the
page is nothing without them, and two round trips to draw one list is one
too many.
A group that is not yours is not found. That is the rule for every route
here, and it is enforced by the lookup rather than by a separate check.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
group: { $ref: "#/components/schemas/ProjectGroup" }
projects:
type: array
items: { $ref: "#/components/schemas/Project" }
attachments:
type: array
items: { $ref: "#/components/schemas/Attachment" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [projects]
summary: Rename a group, or fold it.
@@ -1127,6 +1701,41 @@ paths:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/project-groups/{groupID}/attachments:
parameters:
- { name: groupID, in: path, required: true, schema: { type: string } }
post:
tags: [projects]
summary: Put a document on the group itself.
description: |
A file that belongs to the whole group rather than to any one task in it —
the contract that governs all of "Arbejde", not a step in it.
The same storage as every other attachment: content-addressed by hash, so
the path never contains anything the uploader chose, and the same file
uploaded twice is stored once. 25 MB maximum.
Downloading and deleting go through `/attachments/{attachmentID}` like any
other. A group's attachment is reachable by its owner and nobody else,
because a group belongs to one person and is never shared.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file: { type: string, format: binary }
responses:
"201":
description: Stored.
content:
application/json:
schema: { $ref: "#/components/schemas/Attachment" }
"404": { $ref: "#/components/responses/NotFound" }
"413": { $ref: "#/components/responses/TooLarge" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/trash/projects:
get:
tags: [projects]
@@ -2516,6 +3125,14 @@ components:
locale: { type: string, enum: [da, en] }
is_admin: { type: boolean }
totp_enabled: { type: boolean }
sidebar_collapsed:
type: array
items: { type: string }
description: |
The sidebar headings this person has folded away. Always an array,
never null: a client that has to check for both an absent array and an
empty one will one day check for only one of them.
example: ["labels"]
created_at: { type: string, format: date-time }
RecoveryCodes:
@@ -2550,9 +3167,60 @@ components:
task_count:
type: integer
description: |
Everything a delete would destroy: tasks in projects they own, plus
every task they wrote anywhere. Not the same as "their tasks", and
the difference is the whole point of showing it.
What a delete would destroy: the tasks in the projects they own,
which go with those projects.
authored_elsewhere:
type: integer
description: |
What a delete would leave behind: tasks they wrote in projects other
people own. Those survive without an author. A separate number from
`task_count` because it is a separate sentence — one is what
disappears, the other is what stays unattributed.
GmailClient:
type: object
properties:
client_id: { type: string }
has_secret:
type: boolean
description: Whether a secret is stored. The secret itself is never sent back.
from_env:
type: boolean
description: |
The deployment set this, so the stored pair is ignored and the fields
are not worth editing.
redirect_uri:
type: string
description: |
What has to be registered with Google, spelled out. Derived from
`VERDANDE_BASE_URL` and matched exactly — it is the single most likely
thing to be wrong, and the error Google returns for it names neither
value. Google rejects private IP addresses over http, so a LAN address
will not do: it has to be https, or `http://localhost`.
example: https://verdande.example.com/oauth/gmail/callback
BackupRun:
type: object
properties:
id: { type: string }
started_at: { type: string, format: date-time }
finished_at:
type: string
format: date-time
description: |
Absent while a run is still going, and on one the process died in the
middle of — which is the same shape from outside, and honestly so: a
run that was interrupted never finished.
size_bytes: { type: integer }
error:
type: string
description: Present only on a run that failed, in the words it failed with.
present:
type: boolean
description: |
Whether the file is still on disk. Rotation keeps the most recent
fourteen, so an older row is a record with nothing behind it and its
download answers 404.
PendingInvite:
type: object
@@ -2609,7 +3277,12 @@ components:
items: { type: string }
completed: { type: boolean }
completed_at: { type: string, format: date-time }
created_by: { type: string }
created_by:
type: string
description: |
Empty when the author's account has been deleted. The task outlives
the person who wrote it.
sort_order: { type: number }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
@@ -2704,12 +3377,44 @@ components:
same thing once decoded, and telling "put it nowhere" apart from "I
did not mention it" is what makes this a PATCH.
Passkey:
type: object
properties:
id: { type: string }
name:
type: string
description: What the owner called it. "min bærbare" is what makes a list reviewable.
aaguid:
type: string
description: |
Identifies the authenticator model, which is what lets a list say
something recognisable beside a key somebody named months ago.
discoverable:
type: boolean
description: |
Whether the credential lives on the device. Only these can start a login
with no email typed first.
user_verified:
type: boolean
description: |
Whether the authenticator verified the person — a PIN, a fingerprint, a
face. One that did is both factors at once; one that only proved
possession is a first factor and still meets the TOTP step.
created_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time }
ProjectGroup:
type: object
properties:
id: { type: string }
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
description:
type: string
description: |
What the group is, in the owner's words. A heading carries a name; a
page carries the sentence that says why these projects are one body of
work. Empty until somebody writes one.
collapsed: { type: boolean }
sort_order: { type: number }
@@ -2718,6 +3423,10 @@ components:
properties:
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
description:
type: string
maxLength: 4000
description: What the group is, in the owner's words.
collapsed: { type: boolean }
Color:
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+11 -2
View File
@@ -3,21 +3,30 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
github.com/coder/websocket v1.8.15
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
github.com/teambition/rrule-go v1.8.2
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
+28 -2
View File
@@ -2,12 +2,27 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,6 +33,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -25,10 +42,17 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
@@ -39,6 +63,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+30
View File
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
}
return code
}
// The relying party is derived from the instance's own base URL, and the two ways
// that goes wrong are both silent until a browser refuses.
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
for _, c := range []struct {
base string
ok bool
why string
}{
{"https://verdande.example.dk", true, "a real domain"},
{"http://localhost:5173", true, "localhost, which every browser excepts"},
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
// An IP address is not a domain. The browser throws SecurityError long
// after the server has happily issued a challenge, so it is refused here
// and the endpoints answer 503 instead.
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
{"http://[::1]:8080", false, "an IPv6 literal"},
{"", false, "no address at all"},
{"not a url", false, "not an address"},
} {
_, err := NewWebAuthn(c.base, "verdande")
if c.ok && err != nil {
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
}
if !c.ok && err == nil {
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package auth
import (
"encoding/base64"
"fmt"
"net"
"net/url"
"strings"
"github.com/go-webauthn/webauthn/webauthn"
)
// Passkeys, wrapped so the rest of the app never touches the protocol types.
//
// A password and a TOTP code are both shared secrets: the server keeps something
// that can be stolen from it, and the person keeps something that can be talked
// out of them. A passkey is neither. The private half never leaves the device, and
// the signature is bound to the origin that asked for it — so a convincing copy of
// the sign-in page at another address gets a signature it cannot use.
//
// That binding is also the one thing that can be got wrong here, and it is
// invisible when it is: the RP ID has to be the registrable domain of the address
// people actually visit. Get it wrong and registration works, login works on your
// machine, and everybody behind a different hostname is quietly locked out.
// WebAuthn is the relying party — this instance, as an authenticator sees it.
type WebAuthn struct{ *webauthn.WebAuthn }
// NewWebAuthn derives the relying party from the instance's own base URL.
//
// Derived rather than configured, because a second setting that has to agree with
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
// the failure is a login that cannot be completed, reported as "the button does
// nothing".
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
}
// The RP ID is the host without the port. A port in it is rejected by every
// browser, and localhost:5173 during development is exactly where somebody
// would first put one.
rpID := parsed.Hostname()
// An IP address is not a domain, and no browser will accept one as an RP ID —
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
// the server has happily issued a challenge. Refused here instead, so the
// endpoints answer 503 and the interface never offers a button that cannot
// work. `localhost` is the exception every browser makes, and it is what
// development runs on.
if ip := net.ParseIP(rpID); ip != nil {
return nil, fmt.Errorf(
"auth: passkeys need a domain name, and %q is an IP address — "+
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
}
w, err := webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: displayName,
// The origin is the full scheme://host:port, which is what the browser
// sends and what the signature is checked against.
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
})
if err != nil {
return nil, fmt.Errorf("auth: passkeys: %w", err)
}
return &WebAuthn{w}, nil
}
// A PasskeyUser is what the library needs to know about an account.
//
// The handle is the account id rather than anything a person chose. It is written
// into the credential on the device and cannot be changed afterwards, so it must
// not be an email address: changing your email would orphan every key you own.
type PasskeyUser struct {
ID string
Email string
Name string
Credentials []webauthn.Credential
}
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
// go in a BLOB well enough, but the id is also compared against what the browser
// sends back as base64url, and one encoding on both sides is one thing to be
// wrong about.
func EncodeCredentialID(raw []byte) string {
return base64.RawURLEncoding.EncodeToString(raw)
}
func DecodeCredentialID(encoded string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(encoded)
}
+19
View File
@@ -38,6 +38,22 @@ type Config struct {
// the deal its operator made by self-hosting.
UpdateCheck bool
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
// A container cannot replace its own image from the inside. Restarting is the
// panel's job — it recreates the container and pulls `:latest` on the way — so
// what this needs is a way to ask. All three are required together; any one
// alone cannot do anything.
//
// The token is a panel credential with control over that server. Anybody who
// can read this environment can restart it, which is true of anybody with the
// host anyway. It is never sent to a browser: the settings page is told only
// whether it is set.
PanelURL string
PanelToken string
PanelServerID string
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
@@ -69,6 +85,9 @@ func Load() (*Config, error) {
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
+111
View File
@@ -0,0 +1,111 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/store"
)
// What has happened on this server, across every project.
//
// The per-project history under /projects/{id}/activity is the same rows read by
// the people who work in that project. This is the administrator's version of the
// question, and it is a different one: an administrator is not necessarily a member
// of the project they need to look into, and "which account did that" cannot be
// asked one project at a time.
//
// It sits beside the error log for a reason. That one is the half about what broke;
// this is the half about what was done, and an instance had only ever had the first.
type auditEntryJSON struct {
ID string `json:"id"`
At string `json:"at"`
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
TaskID string `json:"task_id,omitempty"`
// UserID and UserName are absent when the account has been deleted. The row
// stays — an audit log that lost its rows when somebody left would be missing
// exactly the part worth keeping.
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Event string `json:"event"`
Payload map[string]any `json:"payload,omitempty"`
}
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := store.AuditFilter{
UserID: q.Get("user_id"),
ProjectID: q.Get("project_id"),
Event: q.Get("event"),
Limit: parseLimit(q.Get("limit"), 100, 500),
}
if cursor := q.Get("before"); cursor != "" {
at, id, ok := parseAuditCursor(cursor)
if !ok {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"before must be <unix>:<id>, as next_cursor returns it")
return
}
f.BeforeAt, f.BeforeID = at, id
}
entries, err := s.db.ListAuditLog(r.Context(), f)
if err != nil {
s.internal(w, r, "list audit log", err)
return
}
out := make([]auditEntryJSON, 0, len(entries))
for _, a := range entries {
out = append(out, auditEntryJSON{
ID: a.ID, At: a.CreatedAt.Format(time.RFC3339),
ProjectID: a.ProjectID, ProjectName: a.ProjectName, TaskID: a.TaskID,
UserID: a.UserID, UserName: a.UserName, Event: a.Event, Payload: a.Payload,
})
}
body := map[string]any{"activity": out}
// A cursor only when the page was full. A short page is the end of the log, and
// a cursor there invites one more request that can only come back empty.
if len(entries) == f.Limit && f.Limit > 0 {
last := entries[len(entries)-1]
body["next_cursor"] = strconv.FormatInt(last.CreatedAt.Unix(), 10) + ":" + last.ID
}
writeJSON(w, http.StatusOK, body)
}
// handleAuditEvents is what the event filter is drawn from: the names that occur,
// with counts. Offered rather than typed — "member.role_changed" is an internal
// vocabulary, and a text field for it is a field that returns nothing until you
// have read the source.
func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
events, err := s.db.AuditEvents(r.Context())
if err != nil {
s.internal(w, r, "list audit events", err)
return
}
out := make([]map[string]any, 0, len(events))
for _, e := range events {
out = append(out, map[string]any{"event": e.Event, "count": e.Count})
}
writeJSON(w, http.StatusOK, map[string]any{"events": out})
}
// parseAuditCursor reads "<unix>:<id>" — the pair the keyset walk needs. Both
// halves are required: a cursor carrying only a timestamp cannot separate two rows
// written in the same second, which is the case it exists for.
func parseAuditCursor(s string) (int64, string, bool) {
at, id, found := strings.Cut(s, ":")
if !found || id == "" {
return 0, "", false
}
unix, err := strconv.ParseInt(at, 10, 64)
if err != nil || unix <= 0 {
return 0, "", false
}
return unix, id, true
}
+264
View File
@@ -0,0 +1,264 @@
package httpapi
import (
"fmt"
"net/http"
"testing"
)
// The point of the instance-wide log is that it crosses projects the caller is not
// a member of. The per-project endpoint cannot answer this question, and an
// administrator asking "what has happened on this server" was previously told to
// go and look in each project they happen to belong to.
func TestTheAuditLogCrossesProjectsTheAdminIsNotInAllOfThem(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
// A project the administrator is not a member of.
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres eget"})
theirID := theirs["id"].(string)
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "noget i deres eget", "project_id": theirID,
})
// The administrator cannot read that project's own history at all.
if resp, _ := ts.do(t, "GET", "/api/v1/projects/"+theirID+"/activity", nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("a project the admin is not in: status %d, want 404", resp.StatusCode)
}
// The instance log has it.
resp, body := ts.do(t, "GET", "/api/v1/activity", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("audit log: status %d", resp.StatusCode)
}
entries, _ := body["activity"].([]any)
var found map[string]any
for _, raw := range entries {
e := raw.(map[string]any)
if e["project_id"] == theirID && e["event"] == "task.created" {
found = e
}
}
if found == nil {
t.Fatalf("nothing from a project the admin is not in: %v", entries)
}
if found["project_name"] != "Deres eget" {
t.Errorf("project_name = %v — a row that does not name its project says nothing",
found["project_name"])
}
if found["user_name"] != "Anden" {
t.Errorf("user_name = %v", found["user_name"])
}
}
// Administrators only, and sessions only. An audit log readable with a bearer token
// would let a stolen token read the record of the theft — and of everything else on
// the instance.
func TestTheAuditLogIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
if resp, _ := other.do(t, "GET", path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
req, _ := http.NewRequest("GET", ts.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s with an admin's API token: status %d, want 403", path, resp.StatusCode)
}
}
}
// The cursor is keyed on (created_at, id) because the log is written in whole
// seconds. A page boundary that landed inside a busy second would, with a
// timestamp-only cursor, either repeat those rows or skip them — and an audit log
// that skips rows is worse than no audit log.
func TestTheAuditLogPagesWithoutRepeatingOrSkipping(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Meget"})
projectID := project["id"].(string)
// Written as fast as the loop goes, so they share timestamps.
const written = 25
for i := range written {
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": fmt.Sprintf("opgave %d", i), "project_id": projectID,
})
}
seen := map[string]bool{}
cursor := ""
for page := 0; page < 20; page++ {
path := "/api/v1/activity?limit=4"
if cursor != "" {
path += "&before=" + cursor
}
resp, body := ts.do(t, "GET", path, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("page %d: status %d", page, resp.StatusCode)
}
entries, _ := body["activity"].([]any)
for _, raw := range entries {
id := raw.(map[string]any)["id"].(string)
if seen[id] {
t.Fatalf("row %s came back on two pages", id)
}
seen[id] = true
}
next, _ := body["next_cursor"].(string)
if next == "" {
break
}
if next == cursor {
t.Fatalf("the cursor did not move: %s", next)
}
cursor = next
}
// Everything that was written is in the walk exactly once: the task creations,
// plus the project creation.
if len(seen) != written+1 {
t.Errorf("the walk saw %d rows, want %d — a page boundary lost some",
len(seen), written+1)
}
}
// The filters, and the list the event filter is drawn from.
func TestTheAuditLogFiltersAndListsItsEvents(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
mineID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min", "project_id": mineID})
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
theirID := theirs["id"].(string)
other.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "deres", "project_id": theirID})
_, byUser := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID, nil)
for _, raw := range byUser["activity"].([]any) {
if got := raw.(map[string]any)["user_id"]; got != otherUser.ID {
t.Errorf("user_id filter returned a row by %v", got)
}
}
if len(byUser["activity"].([]any)) == 0 {
t.Error("the user filter returned nothing")
}
_, byProject := ts.do(t, "GET", "/api/v1/activity?project_id="+mineID, nil)
for _, raw := range byProject["activity"].([]any) {
if got := raw.(map[string]any)["project_id"]; got != mineID {
t.Errorf("project_id filter returned a row from %v", got)
}
}
_, byEvent := ts.do(t, "GET", "/api/v1/activity?event=project.created", nil)
rows := byEvent["activity"].([]any)
if len(rows) != 2 {
t.Errorf("event filter found %d project.created rows, want 2", len(rows))
}
for _, raw := range rows {
if got := raw.(map[string]any)["event"]; got != "project.created" {
t.Errorf("event filter returned %v", got)
}
}
// And the vocabulary the filter is built from.
_, events := ts.do(t, "GET", "/api/v1/activity/events", nil)
counts := map[string]float64{}
for _, raw := range events["events"].([]any) {
e := raw.(map[string]any)
counts[e["event"].(string)] = e["count"].(float64)
}
if counts["project.created"] != 2 || counts["task.created"] != 2 {
t.Errorf("event counts = %v", counts)
}
// A malformed cursor is a 400, not a silent first page: paging that quietly
// restarts is how an audit walk reads the same rows forever.
if resp, _ := ts.do(t, "GET", "/api/v1/activity?before=igår", nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("a malformed cursor: status %d, want 400", resp.StatusCode)
}
}
// A deleted account leaves its trail behind. This is the reason migration 0009
// exists: an audit log that lost its rows when somebody left would be missing
// exactly the part worth keeping.
func TestTheAuditLogOutlivesTheAccountItDescribes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// In a project that outlives them, so the row is not carried off by the
// project cascade instead.
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
sharedID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "deres bidrag", "project_id": sharedID,
})
_, before := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID+"&event=task.created", nil)
if len(before["activity"].([]any)) != 1 {
t.Fatalf("expected one row before the delete: %v", before["activity"])
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/activity?project_id="+sharedID+"&event=task.created", nil)
rows := after["activity"].([]any)
if len(rows) != 1 {
t.Fatalf("the row went with the account: %v", rows)
}
row := rows[0].(map[string]any)
if _, ok := row["user_id"]; ok {
t.Errorf("user_id is still there after the account went: %v", row["user_id"])
}
if _, ok := row["user_name"]; ok {
t.Errorf("user_name is still there after the account went: %v", row["user_name"])
}
// And the project's own history still shows it, rather than dropping the row
// on an inner join to a user that is gone.
_, project := ts.do(t, "GET", "/api/v1/projects/"+sharedID+"/activity", nil)
var kept int
for _, raw := range project["activity"].([]any) {
if raw.(map[string]any)["event"] == "task.created" {
kept++
}
}
if kept != 1 {
t.Errorf("the project history dropped the authorless row: %d", kept)
}
}
+47 -2
View File
@@ -27,17 +27,62 @@ type userJSON struct {
Locale string `json:"locale"`
IsAdmin bool `json:"is_admin"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
// SidebarCollapsed is which sidebar headings this person has folded away. On
// the account rather than in the browser, because folding one is a statement
// about the work and not about the screen — the sidebar's width is the
// opposite case and stays in localStorage.
SidebarCollapsed []string `json:"sidebar_collapsed"`
CreatedAt string `json:"created_at"`
}
func toUserJSON(u *store.User) userJSON {
collapsed := u.SidebarCollapsed
// Never null in the response: a client that has to check for both an absent
// array and an empty one will one day check for only one of them.
if collapsed == nil {
collapsed = []string{}
}
return userJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339),
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
// handleSetSidebarSections records which sidebar headings are folded away.
//
// Its own route rather than a field on the profile: that one backs a form with a
// save button and validates three fields together, and this writes on every click
// of a chevron. Sharing the path would mean a fold sending a name and a timezone
// it was never asked to change — and a validation error on one of those failing a
// fold.
//
// The keys are the sidebar's and are not checked here. An unknown one costs
// nothing: the sidebar folds the headings it recognises and ignores the rest, so
// a heading that is renamed or removed leaves a harmless entry rather than a
// migration.
func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request) {
var req struct {
Sections []string `json:"sections"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
// A cap, because this is a list the client decides the length of. Twenty is far
// more headings than the sidebar has ever had.
if len(req.Sections) > 20 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many sections")
return
}
user := userFrom(r.Context())
if err := s.db.SetSidebarCollapsed(r.Context(), user.ID, req.Sections); err != nil {
s.storeError(w, r, "set sidebar sections", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
+3 -1
View File
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
BaseURL: "http://127.0.0.1",
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
+158
View File
@@ -0,0 +1,158 @@
package httpapi
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/jobs"
)
// The nightly backup, made visible.
//
// It has run since the beginning and nothing showed it. A backup nobody can see is
// a backup nobody knows has been failing — and the failure mode is quiet: a volume
// that went read-only, a disk that filled, a container that has not been up at
// midnight for a week. The first time anybody finds out is the day they need one.
//
// Administrators only, and behind a session as well as the admin check. A backup
// file is a complete copy of the database, so downloading one is downloading
// everybody's data; a leaked API token must not be able to.
type backupRunJSON struct {
ID string `json:"id"`
Started string `json:"started_at"`
// Finished is absent while a backup is still running, and on one that was
// interrupted — which is the same shape from outside and honestly so: a run
// the process died in the middle of never finished.
Finished string `json:"finished_at,omitempty"`
Size int64 `json:"size_bytes"`
Error string `json:"error,omitempty"`
// Present says the file is still on disk. Rotation keeps the most recent
// fourteen, so an older row is a true record of a backup that was made and a
// file that is no longer there — and a download link for it would 404.
Present bool `json:"present"`
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
runs, err := s.db.ListBackupRuns(r.Context(), parseLimit(r.URL.Query().Get("limit"), 30, 100))
if err != nil {
s.internal(w, r, "list backups", err)
return
}
out := make([]backupRunJSON, 0, len(runs))
for _, b := range runs {
j := backupRunJSON{
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
Size: b.SizeBytes, Error: b.Error,
}
if b.FinishedAt != nil {
j.Finished = b.FinishedAt.Format(time.RFC3339)
}
if b.Path != "" {
if _, err := os.Stat(b.Path); err == nil {
j.Present = true
}
}
out = append(out, j)
}
writeJSON(w, http.StatusOK, map[string]any{"backups": out})
}
// handleRunBackup takes one now.
//
// The same function the nightly job runs, without the "has one run today" check.
// Waiting until tonight to learn whether backups work at all is how somebody finds
// out on the day they need one — and it is the only way to get a snapshot from
// *before* something you are about to do.
//
// Synchronous. A backup of a homelab database is a second or two, and a button
// that returns immediately and tells you nothing is a button you press twice.
func (s *Server) handleRunBackup(w http.ResponseWriter, r *http.Request) {
if err := jobs.Backup(r.Context(), s.cfg, s.db, s.log); err != nil {
s.internal(w, r, "run backup", err)
return
}
runs, err := s.db.ListBackupRuns(r.Context(), 1)
if err != nil || len(runs) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
b := runs[0]
j := backupRunJSON{
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
Size: b.SizeBytes, Error: b.Error, Present: true,
}
if b.FinishedAt != nil {
j.Finished = b.FinishedAt.Format(time.RFC3339)
}
writeJSON(w, http.StatusCreated, j)
}
// handleDownloadBackup sends one backup file.
//
// The path comes from the row rather than from the request, and is then checked to
// be inside the backups directory anyway. The row is written by this process and
// should always be safe; the check is there because "should always" is how a path
// traversal gets shipped, and the thing on the other side of it is every account's
// data in one file.
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
runs, err := s.db.ListBackupRuns(r.Context(), 100)
if err != nil {
s.internal(w, r, "list backups", err)
return
}
id := chi.URLParam(r, "backupID")
var path string
for _, b := range runs {
if b.ID == id {
path = b.Path
break
}
}
if path == "" {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
dir, err := filepath.Abs(s.cfg.BackupsDir())
if err != nil {
s.internal(w, r, "backups dir", err)
return
}
full, err := filepath.Abs(path)
if err != nil || !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
file, err := os.Open(full)
if err != nil {
// Rotation keeps the most recent fourteen, so a row older than that is a
// true record with no file behind it. Not an error — a 404 is the honest
// answer to "send me that one".
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
s.internal(w, r, "stat backup", err)
return
}
// The same treatment attachments get, and for a stronger reason: this is a
// SQLite database, and nothing should ever be tempted to render it inline.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", filepath.Base(full)))
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "", info.ModTime(), file)
}
+116
View File
@@ -0,0 +1,116 @@
package httpapi
import (
"net/http"
"testing"
)
// The backup panel exists because the job was invisible, so the thing to check is
// that it can be seen and asked for — and that the file it hands out is guarded
// like what it is, which is every account's data in one download.
func TestABackupCanBeTakenListedAndDownloaded(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Nothing yet, and that is a state the list has to survive rather than a case
// nobody hits: it is what a fresh instance looks like.
_, empty := ts.do(t, "GET", "/api/v1/backups", nil)
if got, _ := empty["backups"].([]any); len(got) != 0 {
t.Fatalf("a fresh instance has %d backups", len(got))
}
resp, made := ts.do(t, "POST", "/api/v1/backups", nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("run backup: status %d, body %v", resp.StatusCode, made)
}
id, _ := made["id"].(string)
if id == "" {
t.Fatal("the new run has no id")
}
if size, _ := made["size_bytes"].(float64); size <= 0 {
t.Errorf("size_bytes = %v — a backup of a database with rows in it is not empty", made["size_bytes"])
}
if made["error"] != nil {
t.Errorf("the backup reported an error: %v", made["error"])
}
_, listed := ts.do(t, "GET", "/api/v1/backups", nil)
rows, _ := listed["backups"].([]any)
if len(rows) != 1 {
t.Fatalf("listed %d backups, want 1", len(rows))
}
row := rows[0].(map[string]any)
if row["id"] != id {
t.Errorf("the listed run is not the one that was made")
}
if row["present"] != true {
t.Error("present = false on a backup that was just written")
}
// And the file itself, which must arrive as a download rather than as
// something a browser might try to render.
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/backups/"+id, nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
dl, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer dl.Body.Close()
if dl.StatusCode != http.StatusOK {
t.Fatalf("download: status %d", dl.StatusCode)
}
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q, want application/octet-stream", ct)
}
if cd := dl.Header.Get("Content-Disposition"); cd == "" || cd[:10] != "attachment" {
t.Errorf("Content-Disposition = %q, want an attachment", cd)
}
// An id that is not a backup is a 404, not a path to try opening.
if resp, _ := ts.do(t, "GET", "/api/v1/backups/../../etc/passwd", nil); resp.StatusCode == http.StatusOK {
t.Error("a traversal in the id was served")
}
if resp, _ := ts.do(t, "GET", "/api/v1/backups/nogetsomhelst", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("an unknown id: status %d, want 404", resp.StatusCode)
}
}
// Administrators only, and sessions only. A backup is a complete copy of the
// database — a leaked token that could download one turns a stolen credential into
// a copy of everybody's data.
func TestBackupsAreAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/backups"},
{"POST", "/api/v1/backups"},
{"GET", "/api/v1/backups/whichever"},
} {
if resp, _ := other.do(t, c.method, c.path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s as an ordinary user: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/backups"},
{"POST", "/api/v1/backups"},
} {
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with an admin's API token: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
}
+39 -13
View File
@@ -300,16 +300,48 @@ func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
return rel, size, nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
// reachAttachment answers whether this person may see an attachment at all, and
// whether they may change it.
//
// An attachment hangs on exactly one of three things, and each has its own idea of
// who may reach it. A task's is governed by its project's roles, directly or
// through the comment it hangs on. A group's is governed by nothing but ownership:
// a group belongs to one person, is never shared, and a group that is not yours is
// not found — the same rule the rest of the group routes use.
//
// Both callers below used to resolve straight to a task, so a group's attachment
// answered 404 for its own owner.
func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit bool, err error) {
user := userFrom(r.Context())
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
return false, err
}
if a.GroupID != "" {
if _, err := s.db.GetProjectGroup(r.Context(), a.GroupID, user.ID); err != nil {
return false, err
}
// Nobody else can reach it, so whoever can, can change it.
return true, nil
}
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, r, "attachment task", err)
return
return false, err
}
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil {
return false, err
}
return role.CanEdit(), nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
if _, err := s.reachAttachment(r, attachmentID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
@@ -354,15 +386,9 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
user := userFrom(r.Context())
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, r, "attachment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
canEdit, err := s.reachAttachment(r, attachmentID)
if err != nil || !canEdit {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
+130 -5
View File
@@ -10,12 +10,44 @@ import (
"github.com/kristianwind/verdande/internal/store"
)
func (s *Server) gmailConfig() gmail.Config {
return gmail.Config{
// gmailConfig is the OAuth client this instance authorises with.
//
// The environment wins when it is set. A value in the environment is a deliberate
// deployment decision — it is in the Rune's manifest, under version control,
// applied on every recreate — and a form that could quietly override it would make
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
// to redeploy a container to paste a client id.
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
cfg := gmail.Config{
ClientID: s.cfg.GmailClientID,
ClientSecret: s.cfg.GmailClientSecret,
RedirectURL: s.cfg.GmailRedirectURL(),
}
if cfg.ClientID != "" && cfg.ClientSecret != "" {
return cfg
}
stored, err := s.db.InstanceSettings(ctx, "gmail")
if err != nil {
// Not fatal: an unconfigured Gmail and a database that could not answer
// look the same from here, and the caller's next step is the same either
// way — say it is not set up.
return cfg
}
if id, _ := stored["client_id"].(string); cfg.ClientID == "" {
cfg.ClientID = id
}
if secret, _ := stored["client_secret"].(string); cfg.ClientSecret == "" {
cfg.ClientSecret = secret
}
return cfg
}
// gmailFromEnv says whether the deployment set the client, which the settings page
// needs so it can explain why the fields are not editable rather than accepting
// changes that will never take effect.
func (s *Server) gmailFromEnv() bool {
return s.cfg.GmailClientID != "" && s.cfg.GmailClientSecret != ""
}
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
@@ -27,7 +59,7 @@ func (s *Server) gmailConfig() gmail.Config {
// defaults.
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
cfg := s.gmailConfig()
cfg := s.gmailConfig(r.Context())
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
@@ -102,7 +134,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
return
}
token, err := s.gmailConfig().Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
token, err := s.gmailConfig(r.Context()).Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
if err != nil {
s.log.Error("gmail exchange", "err", err, "user", user.ID)
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
@@ -185,7 +217,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
access := str("access_token")
expires, _ := settings["expires_at"].(float64)
if access == "" || int64(expires) <= time.Now().Unix() {
token, err := s.gmailConfig().Refresh(ctx, refresh)
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
if err != nil {
return 0, err
}
@@ -300,3 +332,96 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, status)
}
// --- the OAuth client itself ------------------------------------------------------
// The registration Google requires, enterable rather than deployed.
//
// The one click that opens Google's consent screen already exists; what it needs
// behind it is a registered client, and that is not something any app can conjure:
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
// a restricted scope, so an id shipped inside a public image would be both
// extractable from it and unusable without Google's security review of every
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
// recreate the container to paste the result was not.
//
// Administrators only, and sessions only: this is the instance's identity to
// Google, and a leaked API token must not be able to read or replace it.
type gmailClientJSON struct {
ClientID string `json:"client_id"`
// Secret is written and never read back, like the AI key: a settings page that
// repopulates a password field is one that will eventually leak into a
// screenshot.
ClientSecret string `json:"client_secret,omitempty"`
HasSecret bool `json:"has_secret"`
// FromEnv says the deployment set this, in which case the stored pair is
// ignored and the fields are not worth editing. Sent so the page can say why
// rather than accepting changes that will never take effect.
FromEnv bool `json:"from_env"`
// RedirectURI is what has to be registered with Google, spelled out. It is
// derived from VERDANDE_BASE_URL and must match exactly — it is the single
// most likely thing to be wrong, and the error Google gives for it names
// neither value.
RedirectURI string `json:"redirect_uri"`
}
func (s *Server) handleGetGmailClient(w http.ResponseWriter, r *http.Request) {
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
if err != nil {
s.internal(w, r, "gmail client", err)
return
}
out := gmailClientJSON{
FromEnv: s.gmailFromEnv(),
RedirectURI: s.cfg.GmailRedirectURL(),
}
if out.FromEnv {
out.ClientID = s.cfg.GmailClientID
out.HasSecret = true
} else {
out.ClientID, _ = stored["client_id"].(string)
secret, _ := stored["client_secret"].(string)
out.HasSecret = secret != ""
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
var req gmailClientJSON
if err := decodeJSON(w, r, &req); err != nil {
return
}
if s.gmailFromEnv() {
writeError(w, http.StatusConflict, CodeConflict,
"the Gmail client is set in the environment and cannot be changed here")
return
}
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
if err != nil {
s.internal(w, r, "gmail client", err)
return
}
// An empty secret means "leave it alone", not "delete it" — otherwise
// correcting a typo in the client id would silently clear the secret.
secret, _ := stored["client_secret"].(string)
if req.ClientSecret != "" {
secret = strings.TrimSpace(req.ClientSecret)
}
id := strings.TrimSpace(req.ClientID)
if fields := map[string]string{}; id == "" && secret != "" {
fields["client_id"] = "required"
writeFieldErrors(w, fields)
return
}
if err := s.db.SetInstanceSettings(r.Context(), "gmail", map[string]any{
"client_id": id, "client_secret": secret,
}); err != nil {
s.internal(w, r, "save gmail client", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+127
View File
@@ -1153,3 +1153,130 @@ func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
}
}
// The Gmail OAuth client, enterable rather than deployed.
//
// The registration itself cannot be avoided — Google issues no Gmail access to an
// unregistered client — but having to edit a Rune's manifest and recreate the
// container to paste the result can be, and that is what this covers.
func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, empty := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if empty["client_id"] != "" || empty["has_secret"] != false {
t.Errorf("a fresh instance already has a client: %v", empty)
}
if empty["from_env"] != false {
t.Errorf("from_env = %v with nothing in the environment", empty["from_env"])
}
// The single most likely thing to be wrong, so it is spelled out rather than
// left for somebody to reconstruct.
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
}
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "1234.apps.googleusercontent.com", "client_secret": "hemmelig",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("save: status %d", resp.StatusCode)
}
_, saved := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if saved["client_id"] != "1234.apps.googleusercontent.com" {
t.Errorf("client_id = %v", saved["client_id"])
}
if saved["has_secret"] != true {
t.Error("has_secret is false after saving one")
}
// Never read back. A settings page that repopulates a password field is one
// that will eventually leak into a screenshot.
if _, present := saved["client_secret"]; present {
t.Error("the secret came back in the response")
}
// Saving only the id must not clear the secret — otherwise correcting a typo
// silently breaks the connection.
ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "5678.apps.googleusercontent.com",
})
_, again := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if again["client_id"] != "5678.apps.googleusercontent.com" || again["has_secret"] != true {
t.Errorf("saving the id alone lost the secret: %v", again)
}
// And the authorise call now has something to build a URL with, which is the
// whole point: one click, and Google's consent screen.
resp, body := ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("authorize with a stored client: status %d, body %v", resp.StatusCode, body)
}
if url, _ := body["url"].(string); !strings.Contains(url, "5678.apps.googleusercontent.com") {
t.Errorf("the consent URL does not carry the stored client: %v", body["url"])
}
}
// The environment wins, and says so. A value in the Rune's manifest is applied on
// every recreate, so a form that could quietly override it would make the manifest
// a lie.
func TestTheEnvironmentsGmailClientCannotBeOverwritten(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.GmailClientID = "fra-miljøet.apps.googleusercontent.com"
c.GmailClientSecret = "også-fra-miljøet"
})
ts.bootstrap(t)
_, shown := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if shown["from_env"] != true {
t.Errorf("from_env = %v with the environment set", shown["from_env"])
}
if shown["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
t.Errorf("client_id = %v", shown["client_id"])
}
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "min-egen", "client_secret": "min-egen",
})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("overwriting the environment: status %d, want 409", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if after["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
t.Errorf("the environment's client was replaced: %v", after["client_id"])
}
}
// Administrators only, and sessions only: this is the instance's identity to
// Google, and a leaked token must not be able to read or replace it.
func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, c := range []struct {
method string
body any
}{{"GET", nil}, {"PUT", map[string]any{"client_id": "x"}}} {
if resp, _ := other.do(t, c.method, "/api/v1/gmail/client", c.body); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", c.method, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/gmail/client", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
}
}
+105
View File
@@ -0,0 +1,105 @@
package httpapi
import (
"context"
"fmt"
"net/http"
"time"
)
// Asking the panel to restart this instance.
//
// A container cannot replace its own image from the inside, so "update" is really
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
// the way, which is what makes a restart an update at all.
//
// The alternative was a second browser tab: read the version here, go to the
// panel, find the server, press Restart. This removes the tab, not the panel's
// authority — the panel still does the work and still records who asked.
//
// Administrators only, and sessions only. A leaked API token that could restart
// the server is a denial of service in one request, and the whole point of the
// session rule elsewhere in this file is that a stolen token must not be able to
// do things only a person at a keyboard should.
type panelStatusJSON struct {
// Configured is all three settings being present. The token itself is never
// sent: the page needs to know whether the button can work, not what the
// credential is.
Configured bool `json:"configured"`
PanelURL string `json:"panel_url,omitempty"`
// Why it cannot work, when it cannot. An operator reading "not configured"
// should not have to go and find out which of three things is missing.
Missing []string `json:"missing,omitempty"`
}
func (s *Server) panelStatus() panelStatusJSON {
var missing []string
if s.cfg.PanelURL == "" {
missing = append(missing, "VERDANDE_PANEL_URL")
}
if s.cfg.PanelToken == "" {
missing = append(missing, "VERDANDE_PANEL_TOKEN")
}
if s.cfg.PanelServerID == "" {
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
}
return panelStatusJSON{
Configured: len(missing) == 0,
PanelURL: s.cfg.PanelURL,
Missing: missing,
}
}
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.panelStatus())
}
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
status := s.panelStatus()
if !status.Configured {
writeError(w, http.StatusServiceUnavailable, CodeInternal,
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
return
}
user := userFrom(r.Context())
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
// A short timeout of its own. The panel stops this container as part of
// answering, so the reply may never arrive — that is not a failure, and a
// request that hung waiting for it would look like one.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
s.internal(w, r, "build panel request", err)
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
// Including "the panel killed us mid-reply", which is the successful case
// wearing a failure's clothes. The interface polls /healthz rather than
// believing either answer, so this only has to be honest about what it saw.
s.log.Warn("panel restart call did not complete", "err", err)
writeJSON(w, http.StatusAccepted, map[string]any{
"restarting": true,
"note": "the panel did not answer; it may already be restarting this container",
})
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
s.log.Error("panel refused the restart", "status", resp.StatusCode)
writeError(w, http.StatusBadGateway, CodeInternal,
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
}
+131
View File
@@ -0,0 +1,131 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// Restarting is administrators only, and sessions only. A leaked API token that
// could restart the server is a denial of service in one request.
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
method := "GET"
if path != "/api/v1/panel" {
method = "POST"
}
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
}
}
// Unconfigured, it says which of the three settings are missing rather than
// "not configured" — an operator should not have to go and find out which.
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
if status["configured"] != false {
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
}
missing, _ := status["missing"].([]any)
if len(missing) != 3 {
t.Errorf("missing = %v, want all three settings named", missing)
}
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
}
if body["error"] == nil {
t.Error("the refusal says nothing about why")
}
}
// Configured, it calls the panel with a bearer token and reports what came back.
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
var gotPath, gotAuth string
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "hemmelig"
cfg.PanelServerID = "abc-123"
})
ts.bootstrap(t)
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
if status["configured"] != true {
t.Fatalf("configured = %v with all three set", status["configured"])
}
// The token is a credential and must never reach a browser.
for key, value := range status {
if s, ok := value.(string); ok && s == "hemmelig" {
t.Errorf("the panel token was sent to the client in %q", key)
}
}
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
}
if gotPath != "/api/servers/abc-123/restart" {
t.Errorf("called %q", gotPath)
}
if gotAuth != "Bearer hemmelig" {
t.Errorf("Authorization = %q", gotAuth)
}
}
// A panel that refuses is reported as the panel refusing, not as this server
// breaking: the fix is a token with control of that server, and saying "internal
// error" would send somebody looking in the wrong place.
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "forkert"
cfg.PanelServerID = "abc-123"
})
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the refusal does not say what to check")
}
}
+404
View File
@@ -0,0 +1,404 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
// Passkeys: a key on a device instead of a secret in a head.
//
// Four endpoints, in two pairs. Registration is done by somebody already signed in
// — you cannot be handed a way into an account you have not proved you own.
// Logging in is done by somebody who is not, and is the one place here that is
// reachable without a session.
//
// Each ceremony is two requests because the browser has to talk to the
// authenticator in between: the server issues a challenge, the device signs it,
// the server checks the signature against what it remembers issuing. The challenge
// lives in the database rather than in a cookie, because the whole point of it is
// that the server chose it and remembers choosing it.
const challengeTTL = 5 * time.Minute
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
// built. 404 would read as "this version does not have passkeys"; the truth is
// that this deployment cannot offer them, which is a different thing to fix.
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
if s.passkeys != nil {
return true
}
writeError(w, http.StatusServiceUnavailable, CodeInternal,
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
return false
}
type passkeyJSON struct {
ID string `json:"id"`
Name string `json:"name"`
// AAGUID identifies the authenticator model, which is what lets the list say
// something recognisable beside a key somebody named three months ago.
AAGUID string `json:"aaguid,omitempty"`
Discoverable bool `json:"discoverable"`
UserVerified bool `json:"user_verified"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
}
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list passkeys", err)
return
}
out := make([]passkeyJSON, 0, len(keys))
for _, k := range keys {
j := passkeyJSON{
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
CreatedAt: k.CreatedAt.Format(time.RFC3339),
}
if !k.LastUsedAt.IsZero() {
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
}
out = append(out, j)
}
// Whether this deployment can offer them at all, alongside the ones that exist.
// The interface asks one question rather than discovering the answer from a 503
// it triggered by offering a button — an address an authenticator refuses is a
// deployment fact, not an error somebody caused.
writeJSON(w, http.StatusOK, map[string]any{
"passkeys": out, "available": s.passkeys != nil,
})
}
// --- registering a key ------------------------------------------------------------
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
user := userFrom(r.Context())
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
if err != nil {
s.internal(w, r, "load passkeys", err)
return
}
// Resident keys, and the authenticator asked to verify its owner. Both are
// what make this a *passkey* rather than a second factor: only a discoverable
// credential can start a login with no email typed, and only a verified one
// stands in for the password rather than beside it.
creation, session, err := s.passkeys.BeginRegistration(account,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementPreferred,
UserVerification: protocol.VerificationPreferred,
}),
// Excluding what they already have, so a second registration on the same
// device replaces nothing and reports itself instead of silently making a
// duplicate.
webauthn.WithExclusions(account.excludeList()),
)
if err != nil {
s.internal(w, r, "begin passkey registration", err)
return
}
s.issueChallenge(w, r, user.ID, "register", session, creation)
}
type finishRegistrationRequest struct {
ChallengeID string `json:"challenge_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
var req finishRegistrationRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
if !ok {
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
return
}
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
if err != nil {
s.internal(w, r, "load passkeys", err)
return
}
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "Passkey"
}
key := &store.Passkey{
UserID: user.ID,
CredentialID: auth.EncodeCredentialID(credential.ID),
PublicKey: credential.PublicKey,
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
SignCount: credential.Authenticator.SignCount,
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
UserVerified: credential.Flags.UserVerified,
Name: name,
}
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
s.internal(w, r, "store passkey", err)
return
}
writeJSON(w, http.StatusCreated, passkeyJSON{
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
CreatedAt: key.CreatedAt.Format(time.RFC3339),
})
}
// --- signing in with a key ---------------------------------------------------------
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
// Discoverable: nobody has said who they are yet, and that is the point. The
// device knows which account its key belongs to, so there is no email to type
// and no list of accounts for anybody to probe.
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
if err != nil {
s.internal(w, r, "begin passkey login", err)
return
}
s.issueChallenge(w, r, "", "login", session, assertion)
}
type finishLoginRequest struct {
ChallengeID string `json:"challenge_id"`
Credential json.RawMessage `json:"credential"`
}
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
var req finishLoginRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
if !ok {
return
}
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
return
}
// Which account, resolved from the credential the device signed with. The
// handler is given the raw id and returns the owner; the library checks the
// signature against that owner's stored public key.
var matched *store.Passkey
credential, err := s.passkeys.ValidateDiscoverableLogin(
func(rawID, userHandle []byte) (webauthn.User, error) {
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
if err != nil {
return nil, err
}
matched = key
owner, err := s.db.UserByID(r.Context(), key.UserID)
if err != nil {
return nil, err
}
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
}, *session, parsed)
if err != nil || matched == nil {
// One message, as with a wrong password: telling "no such key" apart from
// "bad signature" is a way to learn which credentials exist here.
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
return
}
// A counter that goes backwards means the credential has been cloned. Not
// every authenticator keeps one, so zero on both sides is silence rather than
// suspicion — but a real counter that has gone down is the one signal this
// design gives, and it is refused rather than logged.
if credential.Authenticator.CloneWarning {
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
return
}
owner, err := s.db.UserByID(r.Context(), matched.UserID)
if err != nil {
s.internal(w, r, "load account", err)
return
}
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
s.log.Warn("record passkey use", "err", err)
}
s.loginLimiter.reset(clientIP(r))
// A verified key is both factors at once: the device proved possession and the
// person proved they were there, which is exactly what a password plus a code
// is for. One that only proved possession still stops at the code.
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
ttl := s.cfg.SessionTTL
if pending {
ttl = 10 * time.Minute
}
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
if err != nil {
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
if pending {
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
return
}
uj := toUserJSON(owner)
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
}
// --- managing them ------------------------------------------------------------------
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
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 err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
userFrom(r.Context()).ID, name); err != nil {
s.storeError(w, r, "rename passkey", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
userFrom(r.Context()).ID); err != nil {
s.storeError(w, r, "delete passkey", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- shared plumbing -----------------------------------------------------------------
// passkeyAccount is the library's view of an account, plus the rows behind it.
type passkeyAccount struct {
*auth.PasskeyUser
stored []store.Passkey
}
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
for _, c := range a.PasskeyUser.Credentials {
out = append(out, c.Descriptor())
}
return out
}
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
keys, err := s.db.ListPasskeys(r.Context(), userID)
if err != nil {
return nil, err
}
credentials := make([]webauthn.Credential, 0, len(keys))
for _, k := range keys {
raw, err := auth.DecodeCredentialID(k.CredentialID)
if err != nil {
continue
}
credentials = append(credentials, webauthn.Credential{
ID: raw,
PublicKey: k.PublicKey,
Authenticator: webauthn.Authenticator{
SignCount: k.SignCount,
},
})
}
return &passkeyAccount{
PasskeyUser: &auth.PasskeyUser{
ID: userID, Email: email, Name: name, Credentials: credentials,
},
stored: keys,
}, nil
}
// issueChallenge stores the server's side of a ceremony and hands the browser the
// options plus the id it will answer with.
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
encoded, err := json.Marshal(session)
if err != nil {
s.internal(w, r, "encode webauthn session", err)
return
}
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
if err != nil {
s.internal(w, r, "store challenge", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
}
// takeChallenge reads a challenge once and checks it belongs where it is being
// used. A registration challenge answered by a login — or one account's answered
// by another — is refused rather than merely failing later.
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"that attempt has expired or was already used; start again")
return nil, false
}
if userID != "" && owner != userID {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
return nil, false
}
var session webauthn.SessionData
if err := json.Unmarshal(raw, &session); err != nil {
s.internal(w, r, "decode webauthn session", err)
return nil, false
}
return &session, true
}
+201
View File
@@ -0,0 +1,201 @@
package httpapi
import (
"encoding/json"
"net/http"
"testing"
"github.com/kristianwind/verdande/internal/store"
)
// What can be tested without an authenticator, which is most of the surface that
// actually goes wrong.
//
// The signature itself cannot be produced here — that needs a real device or a
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
// What is testable is everything around it: who may reach these endpoints, that a
// challenge is answerable exactly once, that one account's challenge cannot be
// answered by another, and that an expired one is refused. Those are the parts a
// mistake would make silently permissive.
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
// A leaked API token must not be able to add its own way in: that would turn a
// theft into a tenancy.
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/auth/passkeys"},
{"POST", "/api/v1/auth/passkeys/register/begin"},
{"POST", "/api/v1/auth/passkeys/register/finish"},
} {
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
}
// Beginning a registration hands back options and a challenge id, and the options
// carry the things that make this a passkey rather than a second factor.
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
}
if body["challenge_id"] == "" || body["challenge_id"] == nil {
t.Fatal("no challenge id came back")
}
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
if publicKey == nil {
t.Fatalf("no publicKey in the options: %v", options)
}
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
if selection["residentKey"] != "preferred" {
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
"with no email typed", selection["residentKey"])
}
if selection["userVerification"] != "preferred" {
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
"stand in for the password", selection["userVerification"])
}
// The user handle must be the account id rather than the email: it is written
// into the credential and cannot be changed, so an email there would orphan
// every key the moment somebody changed their address.
user, _ := publicKey["user"].(map[string]any)
if user["name"] != "[email protected]" {
t.Errorf("user.name = %v", user["name"])
}
if id, _ := user["id"].(string); id == "" {
t.Error("no user handle in the options")
}
}
// A challenge is answerable exactly once, and only by whom it was issued to.
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
challenge := begun["challenge_id"].(string)
// Somebody else's challenge, answered by this account. Refused before the
// signature is even looked at.
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
}
// And it is spent: the rightful owner cannot use it either now, because taking
// it is what deleted it. That is deliberate — a challenge that survives a
// failed attempt is a challenge somebody can keep trying.
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
}
}
// Logging in with a key needs no session and says nothing about who exists here.
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// No session at all — a fresh client, which is the whole point.
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
}
challenge := body["challenge_id"].(string)
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
// A discoverable login must not list credentials: that list would say which
// keys exist on this instance to anybody who asked.
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
if list, _ := allowed.([]any); len(list) > 0 {
t.Errorf("the login options list %d credentials; a discoverable login must not",
len(list))
}
}
// A garbage answer is refused with the same message a wrong key would get.
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
}
}
// A key belongs to its owner, and nobody else can rename or remove it.
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// Written straight to the store: registering one properly needs a device.
key := &store.Passkey{
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
}
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
t.Fatal(err)
}
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
t.Fatalf("the owner sees %d keys, want 1", len(keys))
}
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
t.Errorf("somebody else sees %d of this account's keys", len(keys))
}
for _, c := range []struct {
what, method string
body any
}{
{"rename", "PATCH", map[string]any{"name": "min nu"}},
{"delete", "DELETE", nil},
} {
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
}
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner renaming: status %d", resp.StatusCode)
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner deleting: status %d", resp.StatusCode)
}
}
+147 -10
View File
@@ -2,6 +2,8 @@ package httpapi
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
@@ -17,16 +19,17 @@ import (
// that is not yours is not found.
type projectGroupJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Collapsed bool `json:"collapsed"`
SortOrder float64 `json:"sort_order"`
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Collapsed bool `json:"collapsed"`
SortOrder float64 `json:"sort_order"`
}
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
return projectGroupJSON{
ID: g.ID, Name: g.Name, Color: g.Color,
ID: g.ID, Name: g.Name, Color: g.Color, Description: g.Description,
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
}
}
@@ -45,9 +48,10 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request)
}
type projectGroupRequest struct {
Name *string `json:"name"`
Color *string `json:"color"`
Collapsed *bool `json:"collapsed"`
Name *string `json:"name"`
Color *string `json:"color"`
Description *string `json:"description"`
Collapsed *bool `json:"collapsed"`
}
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
@@ -73,6 +77,9 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
if req.Color != nil {
g.Color = *req.Color
}
if req.Description != nil {
g.Description = strings.TrimSpace(*req.Description)
}
if req.Collapsed != nil {
g.Collapsed = *req.Collapsed
}
@@ -105,8 +112,18 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
return
}
if req.Description != nil {
description := strings.TrimSpace(*req.Description)
if len(description) > 4000 {
writeFieldErrors(w, map[string]string{"description": "must be at most 4000 characters"})
return
}
req.Description = &description
}
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
Name: req.Name, Color: req.Color, Collapsed: req.Collapsed,
Name: req.Name, Color: req.Color, Description: req.Description,
Collapsed: req.Collapsed,
}); err != nil {
s.storeError(w, r, "update project group", err)
return
@@ -120,6 +137,126 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
}
// groupProjectJSON is a project as the group's page lists it: the project, plus
// how much is left in it. Open tasks, not all of them — "12" beside a project
// somebody finished last year is a number that means nothing.
type groupProjectJSON struct {
projectJSON
OpenTasks int `json:"open_tasks"`
}
// handleGetProjectGroup is the group as a place rather than a heading.
//
// A heading can carry a name. A page carries what the group *is* — the sentence
// that says why these projects are one body of work, the documents that belong to
// all of them rather than to any one, and the projects themselves with what is
// left in each.
//
// The projects come with the group rather than being fetched per row, because the
// page is nothing without them and two round trips to draw one list is one too
// many.
func (s *Server) handleGetProjectGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
userID := userFrom(r.Context()).ID
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
if err != nil {
s.storeError(w, r, "get project group", err)
return
}
projects, err := s.db.ListProjects(r.Context(), userID, false)
if err != nil {
s.internal(w, r, "list projects", err)
return
}
counts, err := s.db.OpenTaskCounts(r.Context(), groupID, userID)
if err != nil {
s.internal(w, r, "open task counts", err)
return
}
inside := make([]groupProjectJSON, 0, 4)
for _, p := range projects {
if p.GroupID == groupID {
inside = append(inside, groupProjectJSON{
projectJSON: toProjectJSON(p), OpenTasks: counts[p.ID],
})
}
}
files, err := s.db.ListGroupAttachments(r.Context(), groupID)
if err != nil {
s.internal(w, r, "list group attachments", err)
return
}
attachments := make([]attachmentJSON, 0, len(files))
for _, a := range files {
attachments = append(attachments, toAttachmentJSON(a))
}
writeJSON(w, http.StatusOK, map[string]any{
"group": toProjectGroupJSON(*g), "projects": inside, "attachments": attachments,
})
}
// handleUploadGroupAttachment puts a document on the group itself.
//
// The same storage as everywhere else — content-addressed by hash, so the path
// never contains anything the uploader chose. What differs is only the parent.
func (s *Server) handleUploadGroupAttachment(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
user := userFrom(r.Context())
// Scoped by the lookup rather than by a separate check: a group that is not
// yours is not found, which is the rule for everything in this file.
if _, err := s.db.GetProjectGroup(r.Context(), groupID, user.ID); err != nil {
s.storeError(w, r, "get project group", err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeFieldErrors(w, map[string]string{"file": "required"})
return
}
defer file.Close()
if header.Size > maxUploadBytes {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, r, "store upload", err)
return
}
a := &store.Attachment{
GroupID: groupID,
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, r, "record attachment", err)
return
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
// handleDeleteProjectGroup removes the heading. The projects under it come back
// out as ungrouped, which is why this is not behind a confirmation about losing
// work: there is none to lose.
+80
View File
@@ -269,3 +269,83 @@ func TestAColourMustBeOneFromThePalette(t *testing.T) {
t.Errorf("name = %v", renamed["name"])
}
}
// A group is somewhere you can go, not only a heading.
func TestAGroupIsAPageWithItsProjectsDescriptionAndFiles(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{
"name": "Sæsonstart", "group_id": groupID,
})
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab", "group_id": groupID})
// And one outside it, which must not appear.
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Haven"})
// Two open, one closed — the count is what is left, not what has ever been.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "skriv oplæg", "project_id": first["id"].(string),
})
_, done := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "book lokale", "project_id": first["id"].(string),
})
ts.do(t, "POST", "/api/v1/tasks/"+done["id"].(string)+"/complete", nil)
resp, page := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get group: status %d, body %v", resp.StatusCode, page)
}
projects, _ := page["projects"].([]any)
if len(projects) != 2 {
t.Fatalf("the page lists %d projects, want 2", len(projects))
}
for _, raw := range projects {
p := raw.(map[string]any)
if p["name"] == "Haven" {
t.Error("a project outside the group is on its page")
}
if p["name"] == "Sæsonstart" && p["open_tasks"] != float64(1) {
t.Errorf("open_tasks = %v, want 1 — the closed one must not count", p["open_tasks"])
}
}
// The description, which is the part a heading cannot carry.
if resp, _ := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID, map[string]any{
"description": "Alt det, der har en faktura for enden.",
}); resp.StatusCode != http.StatusOK {
t.Fatalf("set description: status %d", resp.StatusCode)
}
_, again := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
if g, _ := again["group"].(map[string]any); g["description"] != "Alt det, der har en faktura for enden." {
t.Errorf("description = %v", g["description"])
}
// And setting only the description must not lose the name or colour.
if g, _ := again["group"].(map[string]any); g["name"] != "Arbejde" {
t.Errorf("the name went with the description: %v", g["name"])
}
}
// A group belongs to one person and is never shared, so somebody else's group is
// not found — including its files.
func TestAGroupsPageAndFilesAreItsOwnersAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/project-groups/" + groupID},
{"PATCH", "/api/v1/project-groups/" + groupID},
{"POST", "/api/v1/project-groups/" + groupID + "/attachments"},
} {
resp, _ := other.do(t, c.method, c.path, map[string]any{"description": "min nu"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s %s as somebody else: status %d, want 404", c.method, c.path, resp.StatusCode)
}
}
}
+12
View File
@@ -141,6 +141,18 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "create project", err)
return
}
// Filed under a group in the same call. `group_id` was declared on this
// request type, documented on it, applied by the update handler — and silently
// dropped here, so creating a project into a group answered 201 and put it
// nowhere. A field that is accepted and thrown away is worse than one that is
// refused.
if req.GroupID != nil && *req.GroupID != "" {
if err := s.db.SetProjectGroup(r.Context(), p.ID, *req.GroupID, user.ID); err != nil {
s.storeError(w, r, "set project group", err)
return
}
p.GroupID = *req.GroupID
}
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
s.publishToOwner(r, "project.created", toProjectJSON(*p))
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
+138
View File
@@ -283,3 +283,141 @@ func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
}
}
// A project can have more than one section.
//
// Reported from use: sections "have no function, because you cannot create more
// than one". Every test here made exactly one, which is the shape of test that
// cannot see this — and so is every smoke test.
func TestAProjectCanHaveSeveralSections(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
id := project["id"].(string)
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
for _, name := range names {
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections",
map[string]any{"name": name})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create section %q: status %d, body %v", name, resp.StatusCode, body)
}
}
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
got, _ := listed["sections"].([]any)
if len(got) != len(names) {
t.Fatalf("the project has %d sections, want %d", len(got), len(names))
}
// In the order they were made, and each with its own place: a sort_order that
// came out the same for all three would put them in an arbitrary order that
// changes between reads.
seen := map[float64]bool{}
for i, raw := range got {
s := raw.(map[string]any)
if s["name"] != names[i] {
t.Errorf("section %d is %v, want %q", i, s["name"], names[i])
}
order, _ := s["sort_order"].(float64)
if seen[order] {
t.Errorf("two sections share sort_order %v", order)
}
seen[order] = true
}
}
// A project can be created straight into a group.
//
// `group_id` was declared on the request type, documented on it, and applied by
// the update handler — but the create handler ignored it, so a client that asked
// for a project in a group got a 201 and a project in no group. A field that is
// accepted and thrown away is worse than one that is refused: nothing reports it.
func TestAProjectCanBeCreatedIntoAGroup(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
resp, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{
"name": "Sæsonstart", "group_id": groupID,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: status %d, body %v", resp.StatusCode, project)
}
if project["group_id"] != groupID {
t.Errorf("the response says group_id = %v, want %q", project["group_id"], groupID)
}
// And it is really filed there, not just claimed in the reply.
_, listed := ts.do(t, "GET", "/api/v1/projects", nil)
var found map[string]any
for _, raw := range listed["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == project["id"] {
found = p
}
}
if found == nil {
t.Fatal("the new project is not in the list")
}
if found["group_id"] != groupID {
t.Errorf("the stored project has group_id = %v, want %q", found["group_id"], groupID)
}
}
// A section holds as many tasks as you put in it.
//
// Reported from use: "sections can only have one task". Driven through the same
// endpoint the interface uses, with the same `after_id` the drop handler sends —
// the second task goes after the first, which is the case that would fail if
// positioning inside a section were wrong.
func TestASectionHoldsMoreThanOneTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Skovvænget"})
projectID := project["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
map[string]any{"name": "Håndværker"})
sectionID := section["id"].(string)
var previous string
for _, content := range []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"} {
_, created := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": content, "project_id": projectID,
})
id := created["id"].(string)
resp, moved := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
"project_id": projectID, "section_id": sectionID, "after_id": previous,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("move %q: status %d, body %v", content, resp.StatusCode, moved)
}
if moved["section_id"] != sectionID {
t.Fatalf("%q did not land in the section: %v", content, moved["section_id"])
}
previous = id
}
_, listed := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
tasks, _ := listed["tasks"].([]any)
inSection := []string{}
for _, raw := range tasks {
task := raw.(map[string]any)
if task["section_id"] == sectionID {
inSection = append(inSection, task["content"].(string))
}
}
if len(inSection) != 3 {
t.Fatalf("the section holds %d tasks, want 3: %v", len(inSection), inSection)
}
// And in the order they were placed, since each went after the one before it.
want := []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"}
for i, content := range want {
if inSection[i] != content {
t.Errorf("position %d is %q, want %q — the whole list: %v", i, inSection[i], content, inSection)
}
}
}
+59
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
"github.com/kristianwind/verdande/internal/mcp"
@@ -39,6 +40,12 @@ type Server struct {
mcp *mcp.Server
updates *update.Checker
// Nil when the base URL is not something an authenticator will accept — a bare
// hostname with no scheme, say. The endpoints answer 503 rather than the
// process refusing to start: passkeys are one way in among three, and an
// instance should not be unreachable because one of them cannot be offered.
passkeys *auth.WebAuthn
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
s.passkeys = w
} else {
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
}
s.mcp = s.buildMCP()
s.updates = update.New(Version, cfg.UpdateCheck)
@@ -132,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/password/reset",
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
// Signing in with a passkey. Rate-limited like the password path: a
// signature cannot be guessed, but the endpoint can still be used to
// hammer the database.
r.Method(http.MethodPost, "/passkey/login/begin",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
r.Method(http.MethodPost, "/passkey/login/finish",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
// The second step of a login: a session that has passed the password
// but not yet the code, and can reach nothing else.
r.Group(func(r chi.Router) {
@@ -144,6 +164,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireAuth)
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Post("/totp/setup", s.handleTOTPSetup)
@@ -159,6 +180,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireSession)
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
// Registering a key, and the list of them. Sessions only, for
// the same reason: a leaked token must not be able to add its
// own way in — that would turn a theft into a tenancy.
r.Get("/passkeys", s.handleListPasskeys)
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
})
})
})
@@ -236,6 +266,17 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/", s.handleDisconnectGmail)
r.Post("/authorize", s.handleGmailAuthorize)
r.Post("/sync", s.handleGmailSyncNow)
// The OAuth client itself is the instance's registration with
// Google, not anybody's mailbox — so unlike everything above it, it
// is administrators only, and behind a session as well: a leaked
// token must not be able to read or replace this instance's
// identity to Google.
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/client", s.handleGetGmailClient)
r.Put("/client", s.handleSetGmailClient)
})
})
r.Route("/import", func(r chi.Router) {
@@ -306,6 +347,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
// Restarting this instance from its own interface. The panel does
// the work — a container cannot replace its own image — and pulls
// `:latest` on the way, which is what makes a restart an update.
r.Get("/panel", s.handlePanelStatus)
r.Post("/panel/restart", s.handleRestartFromPanel)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
r.Get("/backups", s.handleListBackups)
r.Post("/backups", s.handleRunBackup)
r.Get("/backups/{backupID}", s.handleDownloadBackup)
// The instance-wide history. Above the per-project one in every
// sense: it crosses projects the caller is not a member of, which
// is the whole reason it is here and not there.
r.Get("/activity", s.handleAuditLog)
r.Get("/activity/events", s.handleAuditEvents)
r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleCreateUser)
r.Patch("/users/{userID}", s.handleUpdateUser)
@@ -321,8 +378,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/", s.handleCreateProjectGroup)
// Above /{groupID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjectGroups)
r.Get("/{groupID}", s.handleGetProjectGroup)
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
r.Post("/{groupID}/attachments", s.handleUploadGroupAttachment)
})
// The trash is outside the block above: a deleted project is invisible
+45 -11
View File
@@ -33,9 +33,14 @@ type taskJSON struct {
Completed bool `json:"completed"`
CompletedAt string `json:"completed_at,omitempty"`
CreatedBy string `json:"created_by"`
SortOrder float64 `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// What the row can say without being opened. Sent with every task rather than
// fetched when one is opened, because the point is that you see it in the list.
SubtaskCount int `json:"subtask_count,omitempty"`
SubtaskDone int `json:"subtask_done,omitempty"`
AttachmentCount int `json:"attachment_count,omitempty"`
SortOrder float64 `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toTaskJSON(t store.Task) taskJSON {
@@ -45,9 +50,11 @@ func toTaskJSON(t store.Task) taskJSON {
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
RepeatText: recurrence.Describe(t.RecurrenceRule),
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
SortOrder: t.SortOrder,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
SubtaskCount: t.SubtaskCount, SubtaskDone: t.SubtaskDone,
AttachmentCount: t.AttachmentCount,
SortOrder: t.SortOrder,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
// Never nil: a client iterating labels should not have to null-check first.
Labels: t.Labels,
}
@@ -468,6 +475,10 @@ type quickAddRequest struct {
// ProjectID is the view the user was looking at. A "#project" in the text
// wins over it: what somebody typed beats where they happened to be standing.
ProjectID string `json:"project_id,omitempty"`
// SectionID is the section the box was opened in, for the field that sits at
// the foot of one. Same rule as the project: a "/section" in the text wins,
// because typing it is a choice and standing there is a circumstance.
SectionID string `json:"section_id,omitempty"`
}
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
@@ -511,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
return
}
// Resolved after the project, because a section belongs to one project and the
// same name can exist in several. A name that matches nothing is reported the
// same way an unknown project is: the task is still created, in the project
// without a section, and the caller is told which word did not land.
sectionID, unknownSection := "", ""
// The section the box belongs to, unless the text names one. Checked against
// the project the task actually landed in, so a "#project" that moved it
// elsewhere cannot drag a section id from another project along with it.
if req.SectionID != "" {
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
sectionID = req.SectionID
}
}
if parsed.Section != "" {
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
sectionID = id
} else {
unknownSection = parsed.Section
}
}
t := &store.Task{
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence,
}
if parsed.DueDate != "" {
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
@@ -530,13 +563,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
if unknownProject != "" {
if unknownProject != "" || unknownSection != "" {
// Alongside the task rather than instead of it: the task was created, and
// this says what could not be honoured while creating it.
writeJSON(w, http.StatusCreated, struct {
taskJSON
UnknownProject string `json:"unknown_project"`
}{toTaskJSON(*t), unknownProject})
UnknownProject string `json:"unknown_project,omitempty"`
UnknownSection string `json:"unknown_section,omitempty"`
}{toTaskJSON(*t), unknownProject, unknownSection})
return
}
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
+95
View File
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
t.Errorf("unread = %v after marking everything read", after["unread"])
}
}
// Quick add can put a task straight into a section.
//
// A section belongs to one project, so the name is resolved after the project is
// decided — and two projects may both have a "Produktion", which are different
// sections. A name that matches nothing is reported the way an unknown project is:
// the task is still created, and the caller is told which word did not land.
func TestQuickAddPutsATaskInASection(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
projectID := project["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
map[string]any{"name": "Kunder/Ordrer"})
sectionID := section["id"].(string)
// The section name has a slash in it, which is also the sigil. The bare form is
// greedy, so the whole name comes across.
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
}
if task["section_id"] != sectionID {
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
}
if task["content"] != "Karpenhøj kaffe bestilling" {
t.Errorf("content = %v — the sigils should be gone", task["content"])
}
// Case-insensitive, like the project sigil.
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
if lower["section_id"] != sectionID {
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
}
// A name that matches nothing still creates the task, and says so.
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
if missing["section_id"] != nil && missing["section_id"] != "" {
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
}
if missing["unknown_section"] != "Findesikke" {
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
}
if missing["content"] != "noget andet" {
t.Errorf("content = %v", missing["content"])
}
// A section in another project is a different section, and must not be found.
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
map[string]any{"name": "Egen"})
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "forkert sted #Risteriet /Egen"})
if crossed["unknown_section"] != "Egen" {
t.Errorf("a section from another project was found: %v", crossed["section_id"])
}
}
// The field at the foot of a section passes the section it sits in, and a
// "#project" in the same line can move the task out from under it.
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
hereID := here["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
map[string]any{"name": "Produktion"})
sectionID := section["id"].(string)
// Standing in the section is enough; no sigil needed.
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
})
if plain["section_id"] != sectionID {
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
}
// A "#project" that moves the task must not drag the section id with it: that
// section belongs to the project being left behind.
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
})
if moved["section_id"] != nil && moved["section_id"] != "" {
t.Errorf("the section came along to another project: %v", moved["section_id"])
}
if moved["content"] != "male gavlen" {
t.Errorf("content = %v", moved["content"])
}
}
+8 -5
View File
@@ -30,11 +30,13 @@ type adminUserJSON struct {
Self bool `json:"self"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at,omitempty"`
// ProjectCount and TaskCount are what a delete would destroy. Sent with the
// list rather than fetched when the button is pressed, so the confirmation can
// name real numbers without a round trip in the middle of a decision.
ProjectCount int `json:"project_count"`
TaskCount int `json:"task_count"`
// ProjectCount and TaskCount are what a delete would destroy, and
// AuthoredElsewhere is what it would leave behind without an author. Sent with
// the list rather than fetched when the button is pressed, so the confirmation
// can name real numbers without a round trip in the middle of a decision.
ProjectCount int `json:"project_count"`
TaskCount int `json:"task_count"`
AuthoredElsewhere int `json:"authored_elsewhere"`
}
type pendingInviteJSON struct {
@@ -70,6 +72,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
AuthoredElsewhere: u.AuthoredElsewhere,
}
// Zero means never signed in, which is not a time and must not be sent as
// one — 1970 in an interface reads as a bug rather than as "never".
+46 -10
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"net/http"
"net/url"
"testing"
)
@@ -125,8 +126,8 @@ func TestASecondAdministratorCanBeDemoted(t *testing.T) {
}
}
// Deleting an account takes more than its own projects, and the list says so
// before anybody presses the button.
// Deleting an account takes its own projects and leaves everything else behind.
// The list says both numbers before anybody presses the button.
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
@@ -143,9 +144,10 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
"content": "deres egen", "project_id": theirs["id"].(string),
})
// And a task they wrote in a project somebody else owns. tasks.created_by
// cascades, so this one goes too — and a count that missed it would understate
// the damage in exactly the case that matters.
// And a task they wrote in a project somebody else owns. This one survives —
// `created_by` is ON DELETE SET NULL — and it is counted separately, because
// "this much disappears" and "this much stays unattributed" are two different
// things to tell somebody who is about to press delete.
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
sharedID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
@@ -168,18 +170,52 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
if found["project_count"] != float64(1) {
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
}
if found["task_count"] != float64(2) {
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
if found["task_count"] != float64(1) {
t.Errorf("task_count = %v, want 1 — only the task in their own project goes",
found["task_count"])
}
if found["authored_elsewhere"] != float64(1) {
t.Errorf("authored_elsewhere = %v, want 1 — the task in the shared project stays",
found["authored_elsewhere"])
}
// And the delete really does take it.
// And the delete leaves it standing, without an author.
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
got, _ := tasks["tasks"].([]any)
if len(got) != 1 {
t.Fatalf("a task written by the deleted account should survive in a shared project, got %v", got)
}
surviving := got[0].(map[string]any)
if surviving["content"] != "skrevet i et delt projekt" {
t.Errorf("the surviving task is not the one that was written: %v", surviving["content"])
}
if surviving["created_by"] != "" {
t.Errorf("created_by = %q, want empty — the author is gone, the work is not",
surviving["created_by"])
}
}
// The tasks table was rebuilt to change created_by's delete action, and a rebuild
// is where an index quietly stops matching its table: tasks_fts is external-content
// and keyed on tasks.rowid, which a rebuilt table hands out afresh. Search is the
// only place that would notice.
func TestSearchStillFindsTasksAfterTheTableRebuild(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Have"})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "beskær det grønne hegn", "project_id": project["id"].(string),
})
for _, q := range []string{"grønne", "gronne", "hegn"} {
_, found := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(q), nil)
if got, _ := found["tasks"].([]any); len(got) != 1 {
t.Errorf("search for %q found %d tasks, want 1", q, len(got))
}
}
}
+116
View File
@@ -0,0 +1,116 @@
// Package i18n exists only to hold this test.
//
// The interface's strings live in the frontend, where they are read; nothing in Go
// needs them. What Go can do is fail the build when the two dictionaries drift,
// which is the failure mode a translation always has and which nothing else here
// would notice: a key present in Danish and missing from English renders as Danish
// inside an English screen, and a key present only in English is a string nobody
// will ever see.
//
// A scanner rather than a parser, the same approach `colors_test.go` and
// `openapi_test.go` take: it needs the keys out of two lists this repository owns,
// and that is worth more than a dependency in a project whose whole shape is "one
// static binary".
package i18n
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
func TestTheTwoDictionariesHaveTheSameKeys(t *testing.T) {
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
da := readKeys(t, filepath.Join(dir, "da.js"))
en := readKeys(t, filepath.Join(dir, "en.js"))
if len(da) == 0 {
t.Fatal("no keys found in da.js — the scanner is looking at the wrong shape")
}
var missing, extra []string
for key := range da {
if !en[key] {
missing = append(missing, key)
}
}
for key := range en {
if !da[key] {
extra = append(extra, key)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 {
t.Errorf("%d keys are in da.js and not in en.js — they render as Danish inside an "+
"English screen:\n %s", len(missing), strings.Join(missing, "\n "))
}
if len(extra) > 0 {
t.Errorf("%d keys are in en.js and not in da.js — nothing reads them, because Danish "+
"is the source:\n %s", len(extra), strings.Join(extra, "\n "))
}
}
// The placeholders have to match too. `t()` fills `{name}` from what the caller
// passes, so a translation that spells one differently leaves `{navn}` on the
// screen — which is worse than an untranslated sentence, because it looks like a
// bug in the app rather than a gap in the dictionary.
func TestTheTwoDictionariesUseTheSamePlaceholders(t *testing.T) {
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
da := readEntries(t, filepath.Join(dir, "da.js"))
en := readEntries(t, filepath.Join(dir, "en.js"))
for key, danish := range da {
english, ok := en[key]
if !ok {
continue // reported by the test above
}
want := placeholders(danish)
got := placeholders(english)
if strings.Join(want, ",") != strings.Join(got, ",") {
t.Errorf("%s: Danish fills %v, English fills %v", key, want, got)
}
}
}
var (
// `'some.key': 'the string',` — single-quoted keys and values, which is what
// prettier writes for these files.
reEntry = regexp.MustCompile(`(?m)^\s*'([a-zA-Z0-9._]+)':\s*(.*?),?\s*$`)
rePlaceholder = regexp.MustCompile(`\{(\w+)\}`)
)
func readKeys(t *testing.T, path string) map[string]bool {
t.Helper()
keys := map[string]bool{}
for key := range readEntries(t, path) {
keys[key] = true
}
return keys
}
func readEntries(t *testing.T, path string) map[string]string {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", filepath.Base(path), err)
}
out := map[string]string{}
for _, m := range reEntry.FindAllStringSubmatch(string(body), -1) {
out[m[1]] = m[2]
}
return out
}
func placeholders(s string) []string {
var found []string
for _, m := range rePlaceholder.FindAllStringSubmatch(s, -1) {
found = append(found, m[1])
}
sort.Strings(found)
return found
}
+70
View File
@@ -0,0 +1,70 @@
package i18n
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Danish prose left in a component is the failure the dictionaries cannot see.
//
// `i18n_test.go` checks that the two dictionaries agree. It cannot check the thing
// that actually went wrong: a string that was never put in either, because nobody
// noticed it. Three survived the sweep — "Ingen nyere version fundet.", "Ikke mere
// i dag." and a token hint split across two lines — and they survived it for the
// same reason. The sweep grepped for æøå, and none of them contain one.
//
// So this reads the templates instead and looks for the words that give Danish
// away whatever letters it happens to use. A word list rather than a language
// detector: this repository ships one static binary, and a list of thirty common
// words catches the sentences a person would actually write.
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
// Text nodes of three characters or more, and the three attributes a person
// reads. Anything inside {…} is already an expression — usually a t() call —
// and is not what this is looking for.
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
// Words that are Danish and are not also English. "is", "for" and "to" would
// match English prose and are deliberately absent.
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
styles := regexp.MustCompile(`(?s)<style>.*`)
var found []string
root := filepath.Join("..", "..", "web", "src")
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".svelte") {
return err
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
markup := string(body)
if at := strings.Index(markup, "</script>"); at > 0 {
markup = markup[at:]
}
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
rel, _ := filepath.Rel(root, path)
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
attrs.FindAllStringSubmatch(markup, -1)...) {
phrase := strings.TrimSpace(m[1])
if danish.MatchString(phrase) {
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
}
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
if len(found) > 0 {
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
"t(). An English interface shows them in Danish, and neither dictionary knows "+
"they exist:\n %s", len(found), strings.Join(found, "\n "))
}
}
+40 -36
View File
@@ -162,22 +162,41 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
if time.Since(last) < 23*time.Hour {
return nil
}
return Backup(ctx, r.cfg, r.db, r.log)
}
// Backup takes one snapshot now, records the run and rotates the old files.
//
// Exported and free of the Runner, because the settings page can ask for one too:
// waiting until tonight to find out whether backups work at all is how somebody
// discovers on the day they need one that the volume has been read-only for a
// month. The nightly job is this function behind a "has one run today" check; the
// button is this function without it.
//
// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a
// live WAL-mode database can miss the most recent writes or catch a page
// mid-update; VACUUM INTO produces a consistent, already-compacted database
// without blocking writers for the duration.
func Backup(ctx context.Context, cfg *config.Config, db *store.DB, log *slog.Logger) error {
started := time.Now()
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
path := filepath.Join(r.cfg.BackupsDir(), name)
path := filepath.Join(cfg.BackupsDir(), name)
runID, err := r.db.StartBackupRun(ctx, started)
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
return fmt.Errorf("backups dir: %w", err)
}
runID, err := db.StartBackupRun(ctx, started)
if err != nil {
return err
}
if err := r.db.Snapshot(ctx, path); err != nil {
if err := db.Snapshot(ctx, path); err != nil {
// A failed attempt can leave a partial file, which would then look like a
// backup to anybody reading the directory.
os.Remove(path)
if recErr := r.db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
r.log.Warn("record backup failure", "err", recErr)
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
log.Warn("record backup failure", "err", recErr)
}
return fmt.Errorf("snapshot: %w", err)
}
@@ -186,13 +205,13 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
if info, err := os.Stat(path); err == nil {
size = info.Size()
}
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
r.log.Warn("record backup", "err", err)
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
log.Warn("record backup", "err", err)
}
r.log.Info("backup written", "path", path, "bytes", size,
log.Info("backup written", "path", path, "bytes", size,
"took", time.Since(started).Round(time.Millisecond).String())
return r.rotateBackups()
return rotateBackups(cfg, log)
}
// rotateBackups keeps the most recent fourteen and deletes the rest.
@@ -200,8 +219,8 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
// Counted by file, not by age: a container that was off for a month would
// otherwise come back, find every backup older than fourteen days, and delete all
// of them — leaving none at exactly the moment they were most likely to be needed.
func (r *Runner) rotateBackups() error {
entries, err := os.ReadDir(r.cfg.BackupsDir())
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
entries, err := os.ReadDir(cfg.BackupsDir())
if err != nil {
return err
}
@@ -216,12 +235,12 @@ func (r *Runner) rotateBackups() error {
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
for _, name := range backups[min(len(backups), backupKeepDays):] {
path := filepath.Join(r.cfg.BackupsDir(), name)
path := filepath.Join(cfg.BackupsDir(), name)
if err := os.Remove(path); err != nil {
r.log.Warn("remove old backup", "err", err, "path", path)
log.Warn("remove old backup", "err", err, "path", path)
continue
}
r.log.Info("old backup removed", "path", path)
log.Info("old backup removed", "path", path)
}
return nil
}
@@ -291,27 +310,12 @@ func (r *Runner) purgeSessions(ctx context.Context) error {
return nil
}
// RunBackupNow is what the "back up now" button calls, and what the tests use.
// RunBackupNow is the Runner's way in, for the tests that already hold one.
//
// It was a second copy of Backup — written when the button that would call it did
// not exist yet — and the two had already drifted: this one never created the
// backups directory, and logged nothing. One implementation, so a fix to the
// nightly path is a fix to the button as well.
func (r *Runner) RunBackupNow(ctx context.Context) error {
started := time.Now()
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
path := filepath.Join(r.cfg.BackupsDir(), name)
runID, err := r.db.StartBackupRun(ctx, started)
if err != nil {
return err
}
if err := r.db.Snapshot(ctx, path); err != nil {
os.Remove(path)
_ = r.db.FinishBackupRun(ctx, runID, "", 0, err)
return err
}
var size int64
if info, err := os.Stat(path); err == nil {
size = info.Size()
}
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
return err
}
return r.rotateBackups()
return Backup(ctx, r.cfg, r.db, r.log)
}
+1 -1
View File
@@ -154,7 +154,7 @@ func TestBackupsRotateByCountNotAge(t *testing.T) {
}
}
if err := runner.rotateBackups(); err != nil {
if err := rotateBackups(cfg, runner.log); err != nil {
t.Fatalf("rotate: %v", err)
}
+31 -1
View File
@@ -34,6 +34,7 @@ const (
KindTime Kind = "time"
KindPriority Kind = "priority"
KindProject Kind = "project"
KindSection Kind = "section"
KindLabel Kind = "label"
KindRepeat Kind = "repeat"
)
@@ -52,7 +53,11 @@ type Result struct {
Content string `json:"content"`
// Project is the name written after '#', not an id: the caller resolves it
// against the user's projects and decides what to do when it matches nothing.
Project string `json:"project,omitempty"`
Project string `json:"project,omitempty"`
// Section is the name written after '/', resolved by the caller against the
// sections of whichever project the task lands in. A section belongs to one
// project, so it cannot be looked up until the project is known.
Section string `json:"section,omitempty"`
Labels []string `json:"labels,omitempty"`
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
Priority int `json:"priority"`
@@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result {
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
// called "Fredag" from later being read as a weekday.
res.Project, spans = extractProject(input, spans)
res.Section, spans = extractSection(input, spans)
res.Labels, spans = extractLabels(input, spans)
res.Priority, spans = extractPriority(input, spans)
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
var (
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
//
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
// name — so the bare form is greedy and swallows the lot.
//
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
// three are the shapes a slash actually takes in a Danish task line.
reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
)
func extractProject(input string, spans []Span) (string, []Span) {
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
return name, spans
}
// Last one wins, as with the project: retyping is a correction, not two sections.
func extractSection(input string, spans []Span) (string, []Span) {
all := reSection.FindAllStringSubmatchIndex(input, -1)
if len(all) == 0 {
return "", spans
}
var name string
for _, m := range all {
name = submatch(input, m)
spans = append(spans, sigilSpan(input, m, KindSection))
}
return name, spans
}
func extractLabels(input string, spans []Span) ([]string, []Span) {
var labels []string
seen := map[string]bool{}
+43
View File
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
t.Error("no span of kind repeat")
}
}
// A section, and the three shapes a slash takes in ordinary text.
//
// The sigil is the one that needed thinking about: '#' and '@' never occur in a
// Danish task line by accident, and '/' occurs constantly. What keeps it apart is
// that the slash must follow whitespace and the name must follow the slash with
// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail.
func TestASectionIsWrittenWithASlash(t *testing.T) {
for _, c := range []struct {
in string
section string
content string
}{
{"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"},
{"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"},
// A real section name with a slash in it. The bare form is greedy, so the
// whole thing comes across rather than stopping at the second slash.
{"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"},
{`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"},
// Retyping is a correction, not two sections.
{"nye poser /Drift /Produktion", "Produktion", "nye poser"},
// And alongside everything else.
{"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"},
// The ways a slash shows up when nobody meant a section. None of them may
// produce one: the slash has to follow whitespace, and the name has to
// follow the slash with nothing between.
{"kør 80 km/t forbi", "", "kør 80 km/t forbi"},
{"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"},
// "3/4" is a section to nobody and a date to a Dane — the 3rd of April —
// and the date parser is right to claim it. What matters here is only that
// it did not become a section.
{"læs 3/4 af rapporten", "", "læs af rapporten"},
} {
got := Parse(c.in, ref(t), "da")
if got.Section != c.section {
t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section)
}
if got.Content != c.content {
t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content)
}
}
}
+141 -4
View File
@@ -5,13 +5,19 @@ import (
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
)
type Activity struct {
ID string
ProjectID string
TaskID string
// ProjectName is filled by the instance-wide log, where a row without it says
// nothing: "renamed a section" needs to say which project's section.
ProjectName string
TaskID string
// UserID and UserName are empty when the account has been deleted. The record
// of what was done outlives whoever did it — see migration 0009.
UserID string
UserName string
Event string
@@ -19,6 +25,23 @@ type Activity struct {
CreatedAt time.Time
}
// AuditFilter narrows the instance-wide log. Zero values mean "do not filter".
type AuditFilter struct {
UserID string
ProjectID string
// Event matches exactly, e.g. "task.completed". The interface offers the set
// that actually occurs rather than a text field, so there is no prefix or
// wildcard to support here.
Event string
// Before pages backwards. Keyed on (created_at, id) rather than on created_at
// alone: the log is written in whole seconds, so a busy moment puts several
// rows on the same timestamp and a cursor that only knew the time would either
// skip them or repeat them.
BeforeAt int64
BeforeID string
Limit int
}
// RecordActivity appends to a project's history.
//
// Every mutation writes one, which is what makes the activity log complete rather
@@ -35,7 +58,7 @@ func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, eve
_, err := db.ExecContext(ctx,
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix())
return err
}
@@ -43,9 +66,12 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
if limit <= 0 || limit > 200 {
limit = 50
}
// LEFT JOIN, not JOIN: a row whose author has been deleted still happened, and
// an inner join would drop it from the history silently.
rows, err := db.QueryContext(ctx, `
SELECT a.id, a.project_id, a.task_id, a.user_id, u.name, a.event, a.payload_json, a.created_at
FROM activity a JOIN users u ON u.id = a.user_id
SELECT a.id, a.project_id, a.task_id, COALESCE(a.user_id, ''), COALESCE(u.name, ''),
a.event, a.payload_json, a.created_at
FROM activity a LEFT JOIN users u ON u.id = a.user_id
WHERE a.project_id = ?
ORDER BY a.created_at DESC, a.id DESC
LIMIT ?`, projectID, limit)
@@ -72,6 +98,117 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
return out, rows.Err()
}
// ListAuditLog is the same history read across the whole instance rather than one
// project at a time.
//
// The per-project log answers "what happened here", which is a question a project's
// members ask about their own work. This one answers "what has happened on this
// server", which only an administrator asks and which nothing in the API could
// answer before: ListActivity takes one project id, and an administrator is not
// necessarily a member of the project they need to look into.
//
// Reading is a keyset walk rather than an OFFSET. The log is append-heavy and read
// rarely, so a page fetched a minute after the one before it would otherwise be
// shifted by everything written in between — showing a row twice and skipping the
// one behind it, which is precisely the failure an audit log must not have.
func (db *DB) ListAuditLog(ctx context.Context, f AuditFilter) ([]Activity, error) {
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
where := []string{}
args := []any{}
if f.UserID != "" {
where = append(where, "a.user_id = ?")
args = append(args, f.UserID)
}
if f.ProjectID != "" {
where = append(where, "a.project_id = ?")
args = append(args, f.ProjectID)
}
if f.Event != "" {
where = append(where, "a.event = ?")
args = append(args, f.Event)
}
if f.BeforeAt > 0 {
// A row value, so the tie-break on id is part of the comparison rather
// than a second clause that would have to repeat the timestamp.
where = append(where, "(a.created_at, a.id) < (?, ?)")
args = append(args, f.BeforeAt, f.BeforeID)
}
clause := ""
if len(where) > 0 {
clause = "WHERE " + strings.Join(where, " AND ")
}
args = append(args, limit)
rows, err := db.QueryContext(ctx, `
SELECT a.id, a.project_id, p.name, a.task_id,
COALESCE(a.user_id, ''), COALESCE(u.name, ''),
a.event, a.payload_json, a.created_at
FROM activity a
JOIN projects p ON p.id = a.project_id
LEFT JOIN users u ON u.id = a.user_id
`+clause+`
ORDER BY a.created_at DESC, a.id DESC
LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Activity{}
for rows.Next() {
var a Activity
var taskID sql.NullString
var payload string
var created int64
if err := rows.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &taskID, &a.UserID,
&a.UserName, &a.Event, &payload, &created); err != nil {
return nil, err
}
a.TaskID = taskID.String
a.CreatedAt = time.Unix(created, 0).UTC()
_ = json.Unmarshal([]byte(payload), &a.Payload)
out = append(out, a)
}
return out, rows.Err()
}
// AuditEvents is the set of event names the log actually contains, most frequent
// first, with how many of each.
//
// The filter offers these rather than a text field. Event names are an internal
// vocabulary — "member.role_changed", not a sentence anybody would guess — so a
// field to type them into is a field that returns nothing until you have read the
// source. The counts are worth having on their own: they are the shape of what the
// instance does.
func (db *DB) AuditEvents(ctx context.Context) ([]EventCount, error) {
rows, err := db.QueryContext(ctx, `
SELECT event, count(*) FROM activity
GROUP BY event ORDER BY count(*) DESC, event`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []EventCount{}
for rows.Next() {
var e EventCount
if err := rows.Scan(&e.Event, &e.Count); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
type EventCount struct {
Event string
Count int
}
// PurgeActivity drops history older than the retention window. An activity log that
// grows without limit turns a 10 MB database into a 2 GB one over a couple of years
// of ordinary use, on hardware that is somebody's spare machine.
+30 -13
View File
@@ -21,9 +21,12 @@ type Comment struct {
}
type Attachment struct {
ID string
TaskID string
CommentID string
ID string
TaskID string
CommentID string
// GroupID hangs the file on a project group rather than on any one task in
// it: the contract that governs all of "Arbejde", not a step in it.
GroupID string
Filename string
MimeType string
Size int64
@@ -154,17 +157,17 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
a.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO attachments (id, task_id, comment_id, filename, mime_type, size,
path, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, nullString(a.TaskID), nullString(a.CommentID), a.Filename,
a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
`INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type,
size, path, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
return err
}
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
if err != nil {
return nil, err
@@ -173,13 +176,26 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
return scanAttachments(rows)
}
// ListGroupAttachments is the documents that belong to a whole group rather than
// to anything inside it.
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAttachments(rows)
}
func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (map[string][]Attachment, error) {
args := make([]any, len(commentIDs))
for i, id := range commentIDs {
args[i] = id
}
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
ORDER BY created_at`, args...)
if err != nil {
@@ -202,14 +218,15 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
out := []Attachment{}
for rows.Next() {
var a Attachment
var taskID, commentID sql.NullString
var taskID, commentID, groupID sql.NullString
var created int64
if err := rows.Scan(&a.ID, &taskID, &commentID, &a.Filename, &a.MimeType,
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &a.Filename, &a.MimeType,
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
return nil, err
}
a.TaskID = taskID.String
a.CommentID = commentID.String
a.GroupID = groupID.String
a.CreatedAt = time.Unix(created, 0).UTC()
out = append(out, a)
}
@@ -218,7 +235,7 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE id = ?`, attachmentID)
if err != nil {
return nil, err
@@ -0,0 +1,101 @@
-- `tasks.created_by` stops cascading.
--
-- It was ON DELETE CASCADE, which made deleting an account delete every task that
-- account ever wrote — including in projects somebody else owns. Retiring a
-- colleague therefore removed their contributions from shared work rather than
-- leaving it behind. It is the last remaining way to lose other people's work by
-- accident, and the administrator's page saying so in plain words is a warning, not
-- a fix.
--
-- SET NULL instead: the task stays and loses its author. `created_by` becomes
-- nullable to make that expressible, which is the same shape `assignee_id` and
-- `completed_by` already have for the same reason.
--
-- SQLite cannot alter a foreign key action, so the table is rebuilt. The FTS
-- triggers come along: they are dropped first so that DROP TABLE and ALTER TABLE
-- see a schema they can parse, and recreated below. `tasks_fts` is an
-- external-content table keyed on `tasks.rowid`, and a rebuilt table hands out new
-- rowids, so the index is rebuilt at the end rather than left pointing at rows that
-- have moved.
--
-- verdande:rebuild-tables
DROP TRIGGER tasks_fts_insert;
DROP TRIGGER tasks_fts_delete;
DROP TRIGGER tasks_fts_update;
CREATE TABLE tasks_rebuilt (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
content TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 4,
due_date TEXT,
due_datetime INTEGER,
due_timezone TEXT,
duration_min INTEGER,
recurrence_rule TEXT,
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
-- Nullable, and SET NULL: an account can go without its work going with it.
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
completed_at INTEGER,
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
fold TEXT GENERATED ALWAYS AS (
replace(replace(replace(replace(replace(replace(
content || ' ' || description,
'ø', 'o'), 'Ø', 'o'),
'æ', 'ae'), 'Æ', 'ae'),
'å', 'aa'), 'Å', 'aa')
) STORED,
CHECK (priority BETWEEN 1 AND 4)
);
INSERT INTO tasks_rebuilt
(id, project_id, section_id, parent_id, content, description, priority,
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
assignee_id, created_by, completed_at, completed_by, sort_order,
created_at, updated_at, deleted_at)
SELECT
id, project_id, section_id, parent_id, content, description, priority,
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
assignee_id, created_by, completed_at, completed_by, sort_order,
created_at, updated_at, deleted_at
FROM tasks;
DROP TABLE tasks;
ALTER TABLE tasks_rebuilt RENAME TO tasks;
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
WHERE deleted_at IS NULL AND completed_at IS NULL;
CREATE INDEX idx_tasks_due ON tasks (due_date)
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
END;
CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
END;
CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
END;
-- The rowids the index was built against are gone with the old table.
INSERT INTO tasks_fts (tasks_fts) VALUES ('rebuild');
@@ -0,0 +1,54 @@
-- `activity.user_id` stops cascading, and the log gets an index it can be read by.
--
-- Two changes with the same cause: there is now an instance-wide audit log, and
-- the table it reads was only ever built to answer "what happened in this
-- project".
--
-- The cascade first. An audit log whose rows disappear when the account they
-- describe is deleted answers "what has happened on this server" with everything
-- except the part somebody had a reason to remove. That is the wrong answer to the
-- question, and it is the same mistake migration 0008 fixed on `tasks.created_by`:
-- the record outlives the person. SET NULL, and the reader shows "en slettet
-- konto" rather than a name.
--
-- `project_id` keeps cascading on purpose. A project purged from the trash is
-- genuinely gone — the trash is the undo, and past it the deletion was deliberate
-- and complete. Activity has its own retention window besides.
--
-- Then the index. `idx_activity_project` leads with project_id, so a query that
-- wants the newest rows across every project cannot use it and scans the table to
-- sort it. The new one is ordered the way the audit log reads, and the compound
-- (created_at, id) matches the keyset the handler pages with — two rows written in
-- the same second must not be able to hide each other across a page boundary.
--
-- The marker below is not here because the drop would cascade: nothing references
-- `activity`, so it takes nothing with it. It is here for the check that comes
-- with it. With foreign keys on, the copy is validated a row at a time against a
-- schema that is half rebuilt, and the first bad row aborts with no idea how many
-- others are like it. With them off, `PRAGMA foreign_key_check` runs once before
-- the commit and names what is actually wrong — and this runs against a live
-- database on somebody's next restart.
--
-- verdande:rebuild-tables
CREATE TABLE activity_rebuilt (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL,
-- Nullable: the record of what was done outlives whoever did it.
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
event TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL
);
INSERT INTO activity_rebuilt (id, project_id, task_id, user_id, event, payload_json, created_at)
SELECT id, project_id, task_id, user_id, event, payload_json, created_at FROM activity;
DROP TABLE activity;
ALTER TABLE activity_rebuilt RENAME TO activity;
CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC);
CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL;
CREATE INDEX idx_activity_recent ON activity (created_at DESC, id DESC);
@@ -0,0 +1,16 @@
-- Which of the sidebar's fixed headings a person has folded away.
--
-- A project group already carries `collapsed` on its own row, and the reasoning
-- there applies here too: folding a heading is a statement about the work, not
-- about the screen. "I am not in Etiketter at the moment" is as true on the laptop
-- as on the desktop, which is what separates this from the sidebar's *width* —
-- that one is in localStorage, because a narrow laptop and a wide monitor deserve
-- different answers and syncing it would make one of them wrong every time you
-- moved.
--
-- A JSON array of keys rather than a column per heading. The set is the sidebar's
-- to decide and it has already changed twice; a column each would mean a migration
-- every time a heading is added, and a row full of booleans nobody reads for the
-- ones that were removed. Unknown keys are ignored on the way out, so an old
-- client's folded state survives a heading being renamed rather than resetting it.
ALTER TABLE users ADD COLUMN sidebar_collapsed TEXT NOT NULL DEFAULT '[]';
@@ -0,0 +1,24 @@
-- Settings that belong to the instance rather than to a person.
--
-- The first of them is the Gmail OAuth client. It was configuration only —
-- `VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` — which on a Rune means editing the
-- manifest and recreating the container to change a value you paste once and
-- never touch again. The registration itself cannot be avoided (Google issues no
-- Gmail access to an unregistered client, and `gmail.readonly` is a restricted
-- scope, so a client id shipped inside a public image would be both extractable
-- and unusable), but having to redeploy to enter it can.
--
-- Its own table rather than `user_settings`, for two reasons. An OAuth client is
-- the instance's, not one person's — everybody connecting their mailbox uses the
-- same registration. And `user_settings` has `user_id` as its whole primary key,
-- so it can hold exactly one scope per person and its upsert rewrites the scope
-- name on conflict; adding a second scope there would silently overwrite the AI
-- provider's key.
--
-- JSON for the same reason templates are: written whole, read whole, and it would
-- otherwise be a migration every time a setting is added.
CREATE TABLE instance_settings (
scope TEXT PRIMARY KEY,
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
@@ -0,0 +1,60 @@
-- A group becomes somewhere you can go, rather than only a heading.
--
-- Until now a project group was a label over some rows in the sidebar: a name, a
-- colour, and whether it was folded. That is enough to tidy a list and not enough
-- to be the thing people actually mean by it — "Arbejde" is a body of work with
-- context of its own: what it is, and the documents that belong to all of it
-- rather than to any one project inside it.
--
-- Two changes. A description on the group, and attachments that can hang on one.
--
-- The attachments table has to be rebuilt for the second, because its CHECK spells
-- out exactly which parent columns may be set, and SQLite cannot alter a CHECK.
--
-- Foreign keys go off for the length of it — see the marker below and the note in
-- `applyMigration`. Not because the drop would cascade: nothing references
-- `attachments`, so it takes nothing with it. It is for the check that comes with
-- the marker. With foreign keys on, the copy is validated a row at a time against
-- a half-rebuilt schema and the first bad row aborts with no idea how many others
-- there are; with them off, `PRAGMA foreign_key_check` runs once before the commit
-- and names what is actually wrong. This runs against a live database on somebody's
-- next restart, and an attachment whose task was already gone would otherwise stop
-- the server from starting with one row's worth of explanation.
--
-- The rule stays "exactly one parent": an attachment belongs to a task, a comment
-- or a group, never two. An attachment with two parents is one that gets deleted
-- once and stays visible in the other place.
--
-- verdande:rebuild-tables
ALTER TABLE project_groups ADD COLUMN description TEXT NOT NULL DEFAULT '';
CREATE TABLE attachments_rebuilt (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
group_id TEXT REFERENCES project_groups (id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL,
path TEXT NOT NULL,
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
-- Exactly one parent, counted rather than spelled out as a chain of pairwise
-- comparisons: with three columns that is three clauses, with four it is six,
-- and the version that is wrong looks exactly like the version that is right.
CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL) + (group_id IS NOT NULL) = 1)
);
INSERT INTO attachments_rebuilt
(id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at)
SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments;
DROP TABLE attachments;
ALTER TABLE attachments_rebuilt RENAME TO attachments;
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
CREATE INDEX idx_attachments_group ON attachments (group_id) WHERE group_id IS NOT NULL;
@@ -0,0 +1,73 @@
-- Passkeys: a key on a device instead of a secret in a head.
--
-- The account already has two ways in — a password, and a TOTP code on top of it.
-- Both are shared secrets: the server keeps something that can be stolen from it,
-- and the person keeps something that can be phished out of them. A passkey is
-- neither. The private half never leaves the device, the public half is all this
-- table holds, and the signature is bound to the origin — so a convincing copy of
-- the sign-in page at a different address gets nothing.
--
-- One row per credential rather than per account, because a person has more than
-- one device and losing a laptop should not mean losing the way in. The name is
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
-- list nobody can read is a list nobody revokes from.
--
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
-- a counter that goes backwards means the credential has been cloned, which is
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
CREATE TABLE passkeys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- The credential id the authenticator generated, raw bytes, base64url as
-- stored. Unique across the instance: it is what a login is looked up by,
-- before anybody has said who they are.
credential_id TEXT NOT NULL,
public_key BLOB NOT NULL,
-- The AAGUID identifies the authenticator model, which is what lets a list say
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
aaguid TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
-- Whether the credential lives on the device itself (a resident key). Only
-- those can start a login with no email typed first, so the interface has to
-- know which it has before it offers that.
discoverable INTEGER NOT NULL DEFAULT 0,
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
-- face. A key that only proves possession is a first factor; one that verified
-- its owner is both, and that is what decides whether a password is still
-- asked for.
user_verified INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
-- The challenge a registration or a login is answered against.
--
-- Kept server-side rather than in a cookie: the whole point of the challenge is
-- that the server chose it and remembers choosing it. One row per attempt, deleted
-- when it is answered, and swept when it expires — a challenge that outlives its
-- ceremony is a replay waiting to be tried.
--
-- user_id is null for a login that has not said who it is yet, which is the case
-- a discoverable credential exists for.
CREATE TABLE webauthn_challenges (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
challenge TEXT NOT NULL,
-- 'register' or 'login'. A challenge issued for one must not be answerable by
-- the other: registering is done by somebody already signed in, and logging in
-- is not.
purpose TEXT NOT NULL,
-- The library's own session state, which carries the allowed credentials and
-- the verification requirement it will check the answer against.
session BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
CHECK (purpose IN ('register', 'login'))
);
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
+41
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/kristianwind/verdande/internal/auth"
@@ -346,3 +347,43 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
userID, scope, string(raw), time.Now().Unix())
return err
}
// --- instance-wide settings -------------------------------------------------------------
// InstanceSettings reads one scope of the instance's own settings. A missing row
// is an empty map, not an error.
//
// Separate from UserSettings because some configuration is the server's rather
// than a person's — the Gmail OAuth client is one registration that everybody
// connecting a mailbox goes through, not a preference each of them holds.
func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]any, error) {
var raw string
err := db.QueryRowContext(ctx,
`SELECT values_json FROM instance_settings WHERE scope = ?`, scope).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return map[string]any{}, nil
}
if err != nil {
return nil, err
}
values := map[string]any{}
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
}
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
_, err = db.ExecContext(ctx, `
INSERT INTO instance_settings (scope, values_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT (scope) DO UPDATE SET
values_json = excluded.values_json,
updated_at = excluded.updated_at`,
scope, string(raw), time.Now().Unix())
return err
}
+206
View File
@@ -0,0 +1,206 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// A Passkey is one credential on one device.
//
// One row per credential rather than per account: a person has a laptop and a
// phone, and losing one must not mean losing the way in. The name is theirs to
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
// read is a list nobody revokes from.
type Passkey struct {
ID string
UserID string
CredentialID string
PublicKey []byte
AAGUID string
SignCount uint32
Discoverable bool
UserVerified bool
Name string
CreatedAt time.Time
LastUsedAt time.Time
}
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
if p.ID == "" {
p.ID = NewID()
}
p.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx, `
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
return err
}
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPasskeys(rows)
}
// PasskeyByCredentialID is how a login finds the account, before anybody has said
// who they are. The credential id is unique across the instance, which is what
// makes that possible.
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE credential_id = ?`, credentialID)
if err != nil {
return nil, err
}
defer rows.Close()
list, err := scanPasskeys(rows)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, ErrNotFound
}
return &list[0], nil
}
// TouchPasskey records a use and the authenticator's counter.
//
// The counter is stored to be compared. One that goes backwards means the
// credential has been cloned, which is the single thing this design cannot
// otherwise detect — and not every authenticator keeps one, so zero means "not
// offered" rather than "suspicious".
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
_, err := db.ExecContext(ctx,
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
signCount, time.Now().Unix(), id)
return err
}
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
res, err := db.ExecContext(ctx,
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
// id read in one step and deleted in the next is a window, and the reason somebody
// is on this screen at all is usually that they think a device is gone.
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CountPasskeys is what stops somebody removing their last way in when it is the
// only one they have.
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
return n, err
}
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
out := []Passkey{}
for rows.Next() {
var p Passkey
var discoverable, verified int
var created, used int64
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
return nil, err
}
p.Discoverable = discoverable == 1
p.UserVerified = verified == 1
p.CreatedAt = time.Unix(created, 0).UTC()
if used > 0 {
p.LastUsedAt = time.Unix(used, 0).UTC()
}
out = append(out, p)
}
return out, rows.Err()
}
// --- challenges -------------------------------------------------------------------
// StoreChallenge keeps the server's side of one ceremony.
//
// Server-side rather than in a cookie: the whole point of a challenge is that the
// server chose it and remembers choosing it. It is deleted when answered and swept
// when it expires — a challenge that outlives its ceremony is a replay waiting to
// be tried.
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
id := NewID()
now := time.Now()
_, err := db.ExecContext(ctx, `
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
return id, err
}
// TakeChallenge reads a challenge and removes it in the same transaction.
//
// Taken rather than read, because a challenge is answerable exactly once. Two
// requests racing the same one would otherwise both succeed, which is the whole
// shape of a replay.
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
err = db.Tx(ctx, func(tx *sql.Tx) error {
var owner sql.NullString
var expires int64
row := tx.QueryRowContext(ctx,
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
id, purpose)
if err := row.Scan(&owner, &session, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
return err
}
if time.Now().Unix() > expires {
return ErrNotFound
}
userID = owner.String
return nil
})
return userID, session, err
}
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
// itself; an abandoned one is what this is for.
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+62 -21
View File
@@ -15,19 +15,23 @@ import (
// not. That is also why a shared project can never be in a group — see
// SetProjectGroup.
type ProjectGroup struct {
ID string
OwnerID string
Name string
Color string
Collapsed bool
SortOrder float64
CreatedAt time.Time
UpdatedAt time.Time
ID string
OwnerID string
Name string
Color string
// Description is what the group is, in the owner's words. A heading can carry
// a name; a page can carry the sentence that says why these projects are the
// same body of work.
Description string
Collapsed bool
SortOrder float64
CreatedAt time.Time
UpdatedAt time.Time
}
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
`SELECT id, owner_id, name, color, description, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE owner_id = ?
ORDER BY sort_order, created_at`, userID)
if err != nil {
@@ -40,8 +44,8 @@ func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGr
var g ProjectGroup
var collapsed int
var created, updated int64
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder,
&created, &updated); err != nil {
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &g.Description, &collapsed,
&g.SortOrder, &created, &updated); err != nil {
return nil, err
}
g.Collapsed = collapsed == 1
@@ -60,9 +64,10 @@ func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*Pro
var collapsed int
var created, updated int64
err := db.QueryRowContext(ctx,
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
`SELECT id, owner_id, name, color, description, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder, &created, &updated)
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &g.Description, &collapsed, &g.SortOrder,
&created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -101,18 +106,20 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
}
type ProjectGroupUpdate struct {
Name *string
Color *string
Collapsed *bool
SortOrder *float64
Name *string
Color *string
Description *string
Collapsed *bool
SortOrder *float64
}
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
set, args := buildUpdate(map[string]any{
"name": u.Name,
"color": u.Color,
"collapsed": u.Collapsed,
"sort_order": u.SortOrder,
"name": u.Name,
"color": u.Color,
"description": u.Description,
"collapsed": u.Collapsed,
"sort_order": u.SortOrder,
})
if len(set) == 0 {
return nil
@@ -209,3 +216,37 @@ func (db *DB) SetProjectGroup(ctx context.Context, projectID, groupID, userID st
}
return nil
}
// OpenTaskCounts is how much is left in each project of a group.
//
// One query rather than one per project: the group's page draws a list, and a
// count per row would be a query per row.
//
// Open, not total. "12" beside a project you finished last year is a number that
// means nothing; the question a list of projects answers is what is still on it.
// Sub-tasks are excluded for the same reason the project view nests them — they
// are part of their parent, not another thing to do.
func (db *DB) OpenTaskCounts(ctx context.Context, groupID, userID string) (map[string]int, error) {
rows, err := db.QueryContext(ctx, `
SELECT p.id, count(t.id)
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
AND t.deleted_at IS NULL AND t.completed_at IS NULL AND t.parent_id IS NULL
WHERE p.group_id = ? AND p.owner_id = ? AND p.deleted_at IS NULL
GROUP BY p.id`, groupID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int{}
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return nil, err
}
counts[id] = n
}
return counts, rows.Err()
}
+33
View File
@@ -630,3 +630,36 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
return nil
})
}
// SectionByName resolves a "/name" from quick add within one project.
//
// Case-insensitive, and scoped to the project the task is landing in — a section
// belongs to exactly one project, so the name cannot be looked up before that is
// decided. Two projects may both have a "Produktion" and they are different
// sections; asking without the project would be asking the wrong question.
func (db *DB) SectionByName(ctx context.Context, projectID, name string) (string, error) {
var id string
err := db.QueryRowContext(ctx, `
SELECT id FROM sections
WHERE project_id = ? AND lower(name) = lower(?) AND deleted_at IS NULL
ORDER BY sort_order
LIMIT 1`, projectID, name).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}
// SectionInProject reports whether a section id belongs to the given project.
//
// Quick add takes a section id from the client — the field at the foot of a
// section passes the one it sits in — and a "#project" in the same line can move
// the task somewhere else entirely. Without this the id would follow it, and the
// task would land in a section belonging to a project it is not in.
func (db *DB) SectionInProject(ctx context.Context, sectionID, projectID string) (bool, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM sections WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
sectionID, projectID).Scan(&n)
return n == 1, err
}
+94 -21
View File
@@ -25,15 +25,18 @@ type DB struct {
path string
}
// Open prepares the database at path and brings the schema up to date.
// dsn is the connection string.
//
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
// readers and a WebSocket fan-out, so the pragmas below matter more than usual:
// WAL lets readers run while a write is in flight, busy_timeout turns the "database
// is locked" race into a short wait, and foreign_keys is off by default in SQLite
// and has to be asked for on every connection.
func Open(path string) (*DB, error) {
dsn := "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
//
// Its own function so the migration tests can open a database with exactly the
// pragmas production runs with.
func dsn(path string) string {
return "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
"_pragma=journal_mode(WAL)",
"_pragma=busy_timeout(5000)",
"_pragma=foreign_keys(ON)",
@@ -56,8 +59,11 @@ func Open(path string) (*DB, error) {
// spilling them into the data volume.
"_pragma=temp_store(MEMORY)",
}, "&")
}
sqlDB, err := sql.Open("sqlite", dsn)
// Open prepares the database at path and brings the schema up to date.
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", dsn(path))
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
@@ -91,11 +97,36 @@ func Open(path string) (*DB, error) {
func (db *DB) Path() string { return db.path }
// rebuildMarker opts a migration out of foreign key enforcement for the length of
// its transaction.
//
// SQLite cannot alter a column's foreign key action, so changing one means
// rebuilding the table: copy it, drop the original, rename the copy back. With
// foreign keys on, the drop is a cascading delete — every row in every table that
// references `tasks` would go with it, which is the opposite of what a migration
// that exists to *stop* losing rows should do. `PRAGMA foreign_keys` is a no-op
// inside a transaction, so it has to be set on the connection before BEGIN; that
// is why migrations run on a connection of their own rather than on the pool.
//
// `PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
// dangling reference fails the migration instead of writing it.
const rebuildMarker = "-- verdande:rebuild-tables"
// migrate applies every embedded migration that has not run yet, in filename order,
// each inside its own transaction. A migration that fails leaves the schema at the
// last version that succeeded rather than half-applied.
//
// Everything here runs on one connection: a migration marked with rebuildMarker
// needs a pragma to hold across its transaction, and a pragma only applies to the
// connection it was issued on.
func (db *DB) migrate(ctx context.Context) error {
if _, err := db.ExecContext(ctx, `
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
@@ -104,7 +135,7 @@ func (db *DB) migrate(ctx context.Context) error {
}
applied := map[string]bool{}
rows, err := db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
rows, err := conn.QueryContext(ctx, `SELECT name FROM schema_migrations`)
if err != nil {
return fmt.Errorf("read schema_migrations: %w", err)
}
@@ -136,27 +167,69 @@ func (db *DB) migrate(ctx context.Context) error {
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
if err := db.applyMigration(ctx, conn, name, string(body)); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}
func (db *DB) applyMigration(ctx context.Context, conn *sql.Conn, name, body string) error {
if strings.Contains(body, rebuildMarker) {
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migration %s: disable foreign keys: %w", name, err)
}
// Back on whatever happens below: this connection goes back to the pool.
defer conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, body); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
if strings.Contains(body, rebuildMarker) {
if err := foreignKeyCheck(ctx, tx); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
return nil
}
// foreignKeyCheck reports the first row that references something that is not
// there. It names the table and the rowid, because a rebuild that drops rows on the
// floor is otherwise indistinguishable from one that worked.
func foreignKeyCheck(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
if err != nil {
return fmt.Errorf("foreign key check: %w", err)
}
defer rows.Close()
if rows.Next() {
var table, parent string
var rowid sql.NullInt64
var fkid int
if err := rows.Scan(&table, &rowid, &parent, &fkid); err != nil {
return fmt.Errorf("foreign key check: %w", err)
}
return fmt.Errorf("foreign key check: %s row %d references a missing %s",
table, rowid.Int64, parent)
}
return rows.Err()
}
// Tx runs fn inside a transaction, rolling back on error or panic.
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
+214
View File
@@ -2,9 +2,13 @@ package store
import (
"context"
"database/sql"
"fmt"
"io/fs"
"path/filepath"
"sort"
"testing"
"time"
)
// openTest gives each test its own database file. In-memory SQLite would be faster,
@@ -410,3 +414,213 @@ func ftsCount(t *testing.T, db *DB, query string) int {
}
return n
}
// The rebuild in 0008 is the one migration that does not just add: it copies the
// tasks table, drops the original and renames the copy back. Every test above this
// one starts from an empty database, where a rebuild that loses rows looks exactly
// like a rebuild that works — so this one carries data across it.
//
// What can go wrong, in the order it would: the drop cascades and takes the child
// rows with it; the copy misses a column; the FTS index is left keyed on rowids the
// rebuilt table no longer hands out; and finally the thing the migration is for,
// which is that deleting an account stops deleting other people's work.
func TestTheTasksRebuildCarriesTheDataAcross(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "upgrade.db")
// A database at 0007: the schema as it shipped in v0.10.2.
sqlDB, err := sql.Open("sqlite", dsn(path))
if err != nil {
t.Fatalf("open: %v", err)
}
defer sqlDB.Close()
if _, err := sqlDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)`); err != nil {
t.Fatal(err)
}
entries, err := fs.Glob(migrationFS, "migrations/*.sql")
if err != nil {
t.Fatal(err)
}
sort.Strings(entries)
for _, entry := range entries {
name := filepath.Base(entry)
if name >= "0008" {
break
}
body, err := migrationFS.ReadFile(entry)
if err != nil {
t.Fatal(err)
}
if _, err := sqlDB.ExecContext(ctx, string(body)); err != nil {
t.Fatalf("apply %s: %v", name, err)
}
if _, err := sqlDB.ExecContext(ctx,
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
t.Fatal(err)
}
}
// Two people, one project each, and a task in each project written by the
// other — which is the shape the migration exists for. Plus a label, a comment
// and a sub-task, so the child tables have something to lose.
now := time.Now().Unix()
for _, stmt := range []string{
`INSERT INTO users (id, email, name, password_hash, avatar_color, is_admin, created_at, updated_at)
VALUES ('u1', 'en@example.dk', 'En', 'x', 'graphite', 1, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
('u2', 'to@example.dk', 'To', 'x', 'graphite', 0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
`INSERT INTO projects (id, owner_id, name, color, created_at, updated_at)
VALUES ('p1', 'u1', 'Ens', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
('p2', 'u2', 'Tos', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
`INSERT INTO tasks (id, project_id, content, description, created_by, priority, sort_order, created_at, updated_at)
VALUES ('t1', 'p1', 'grøn hæk', 'i ens eget', 'u1', 2, 1.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
('t2', 'p2', 'skrevet af en anden', '', 'u1', 4, 2.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
`INSERT INTO tasks (id, project_id, parent_id, content, created_by, sort_order, created_at, updated_at)
VALUES ('t3', 'p1', 't1', 'undertask', 'u1', 3.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
`INSERT INTO labels (id, user_id, name, created_at) VALUES ('l1', 'u1', 'have', ` + fmt.Sprint(now) + `)`,
`INSERT INTO task_labels (task_id, label_id) VALUES ('t1', 'l1')`,
`INSERT INTO comments (id, task_id, user_id, body, created_at, updated_at)
VALUES ('c1', 't1', 'u1', 'en kommentar', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
// 0009 rebuilds activity and 0012 rebuilds attachments, so both need rows
// here: a rebuild that drops them looks exactly like one that works.
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
VALUES ('a1', 'p1', 't1', 'u1', 'task.created', '{}', ` + fmt.Sprint(now) + `),
('a2', 'p2', NULL, 'u1', 'project.created', '{}', ` + fmt.Sprint(now) + `)`,
`INSERT INTO attachments (id, task_id, filename, mime_type, size, path, uploaded_by, created_at)
VALUES ('f1', 't1', 'tegning.pdf', 'application/pdf', 1024, 'ab/cd/abcd', 'u1', ` + fmt.Sprint(now) + `)`,
`INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at)
VALUES ('g1', 'u1', 'Arbejde', 'graphite', 0, 1024, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
} {
if _, err := sqlDB.ExecContext(ctx, stmt); err != nil {
t.Fatalf("seed: %v\n%s", err, stmt)
}
}
sqlDB.Close()
// Now the upgrade, through the real runner.
db, err := Open(path)
if err != nil {
t.Fatalf("upgrade: %v", err)
}
defer db.Close()
var tasks, labels, comments int
if err := db.QueryRow(`SELECT count(*) FROM tasks`).Scan(&tasks); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT count(*) FROM task_labels`).Scan(&labels); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT count(*) FROM comments`).Scan(&comments); err != nil {
t.Fatal(err)
}
if tasks != 3 || labels != 1 || comments != 1 {
t.Fatalf("after the rebuild: %d tasks, %d labels, %d comments — want 3, 1, 1. "+
"A drop that cascaded would look exactly like this", tasks, labels, comments)
}
// 0009 and 0012 rebuild activity and attachments by the same machinery. They
// run against a live database on the next restart, and the thing that goes
// wrong in a rebuild is rows quietly not arriving.
var activity, attachments, groups int
if err := db.QueryRow(`SELECT count(*) FROM activity`).Scan(&activity); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT count(*) FROM attachments`).Scan(&attachments); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT count(*) FROM project_groups`).Scan(&groups); err != nil {
t.Fatal(err)
}
if activity != 2 || attachments != 1 || groups != 1 {
t.Fatalf("after 0009 and 0012: %d activity rows, %d attachments, %d groups — want 2, 1, 1",
activity, attachments, groups)
}
// The attachment kept its parent and its bytes. A rebuild that lost the task_id
// leaves a row nothing can reach, which is worse than losing it outright.
var fileParent, filename string
if err := db.QueryRow(
`SELECT task_id, filename FROM attachments WHERE id = 'f1'`).Scan(&fileParent, &filename); err != nil {
t.Fatal(err)
}
if fileParent != "t1" || filename != "tegning.pdf" {
t.Errorf("the attachment came across as %q on %q", filename, fileParent)
}
// And the new columns arrived with their defaults rather than as NULL, which is
// what an ALTER that forgot NOT NULL DEFAULT would give.
var groupAbout, sidebar string
if err := db.QueryRow(`SELECT description FROM project_groups WHERE id = 'g1'`).Scan(&groupAbout); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT sidebar_collapsed FROM users WHERE id = 'u1'`).Scan(&sidebar); err != nil {
t.Fatal(err)
}
if groupAbout != "" || sidebar != "[]" {
t.Errorf("new columns on existing rows: description %q, sidebar_collapsed %q", groupAbout, sidebar)
}
// Every column came across, including the generated one and the sub-task's
// self-reference.
var content, description, parent string
var priority int
if err := db.QueryRow(
`SELECT content, description, priority FROM tasks WHERE id = 't1'`).
Scan(&content, &description, &priority); err != nil {
t.Fatal(err)
}
if content != "grøn hæk" || description != "i ens eget" || priority != 2 {
t.Errorf("t1 came across as %q/%q/%d", content, description, priority)
}
if err := db.QueryRow(`SELECT parent_id FROM tasks WHERE id = 't3'`).Scan(&parent); err != nil {
t.Fatal(err)
}
if parent != "t1" {
t.Errorf("the sub-task lost its parent: %q", parent)
}
// The FTS index was rebuilt against the new rowids. Searching the fold column
// is the sharpest check: it is generated, so a copy that skipped it would still
// have the row and not the index entry.
for _, q := range []string{"grøn", "gron", "haek"} {
var n int
if err := db.QueryRow(`SELECT count(*) FROM tasks_fts WHERE tasks_fts MATCH ?`, q).Scan(&n); err != nil {
t.Fatalf("search %q: %v", q, err)
}
if n != 1 {
t.Errorf("search for %q found %d rows, want 1 — the index is keyed on rowids "+
"the rebuilt table no longer hands out", q, n)
}
}
// And the point of the whole thing.
if err := db.DeleteUser(ctx, "u1"); err != nil {
t.Fatalf("delete user: %v", err)
}
var survived int
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE id = 't2'`).Scan(&survived); err != nil {
t.Fatal(err)
}
if survived != 1 {
t.Fatal("a task written in somebody else's project went with the account")
}
var author sql.NullString
if err := db.QueryRow(`SELECT created_by FROM tasks WHERE id = 't2'`).Scan(&author); err != nil {
t.Fatal(err)
}
if author.Valid {
t.Errorf("created_by = %q, want null", author.String)
}
// Their own project still goes, and everything in it with it.
var own int
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE project_id = 'p1'`).Scan(&own); err != nil {
t.Fatal(err)
}
if own != 0 {
t.Errorf("%d tasks survived in the deleted account's own project", own)
}
}
+61 -14
View File
@@ -29,11 +29,23 @@ type Task struct {
AssigneeID string
CompletedAt *time.Time
CompletedBy string
CreatedBy string
SortOrder float64
Labels []string
CreatedAt time.Time
UpdatedAt time.Time
// CreatedBy is empty when the author's account has been deleted: `created_by`
// is ON DELETE SET NULL so the task survives the person who wrote it.
CreatedBy string
SortOrder float64
// SubtaskCount and SubtaskDone are what a row can say without being opened:
// that there is something underneath it, and how much of it is left. Counted
// for every task in a list, in the same query — a hundred extra lookups to draw
// a badge is not a badge worth having.
SubtaskCount int
SubtaskDone int
// AttachmentCount is the same idea for files. A task with a drawing on it looks
// exactly like one without, and the whole reason to attach something is that
// somebody should find it.
AttachmentCount int
Labels []string
CreatedAt time.Time
UpdatedAt time.Time
}
func (t *Task) Completed() bool { return t.CompletedAt != nil }
@@ -173,11 +185,11 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
t.created_by, t.sort_order, t.created_at, t.updated_at
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at`
ORDER BY ` + order(f)
if f.Limit > 0 {
query += ` LIMIT ?`
@@ -264,11 +276,11 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
return nil, err
}
rows, err := db.QueryContext(ctx, `
SELECT id, project_id, section_id, parent_id, content, description,
priority, due_date, due_datetime, due_timezone, duration_min,
recurrence_rule, assignee_id, completed_at, completed_by,
created_by, sort_order, created_at, updated_at
FROM tasks WHERE id = ? AND deleted_at IS NULL`, taskID)
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
if err != nil {
return nil, err
}
@@ -317,9 +329,42 @@ func (db *DB) taskLabels(ctx context.Context, taskID string) ([]string, error) {
return out, rows.Err()
}
// taskCounts are what a row can say about itself without being opened.
//
// Correlated subqueries rather than a second round of lookups: a list of fifty
// tasks would otherwise be a hundred more queries, and the whole point is that a
// row shows this without anybody asking for it. `idx_tasks_parent` covers the
// first two; the attachment count is a small table with a task_id on it.
//
// Sub-tasks are counted whether they are open or closed, and the finished ones
// again separately, because "3" and "1 of 3" are different sentences and only the
// second one says whether there is anything left to do.
const taskCounts = `
(SELECT count(*) FROM tasks c
WHERE c.parent_id = t.id AND c.deleted_at IS NULL),
(SELECT count(*) FROM tasks c
WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL),
(SELECT count(*) FROM attachments a WHERE a.task_id = t.id)`
// order is how a list of tasks is sorted, which depends on what the list is for.
//
// A plan is read by when it is due and how urgent it is. A record of what has been
// finished is read backwards from now — "what did I just do" and "what did I close
// by mistake" are both questions about the most recent thing, and neither is
// answered by a due date the task no longer has.
func order(f TaskFilter) string {
if f.CompletedOnly {
return "t.completed_at DESC, t.id DESC"
}
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
}
func scanTask(rows *sql.Rows) (Task, error) {
var t Task
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
// createdBy is null on a task whose author's account has been deleted. The work
// stays; it loses its author.
var createdBy sql.NullString
var dueDatetime, completedAt sql.NullInt64
var duration sql.NullInt64
var created, updated int64
@@ -327,13 +372,15 @@ func scanTask(rows *sql.Rows) (Task, error) {
err := rows.Scan(&t.ID, &t.ProjectID, &sectionID, &parentID, &t.Content, &t.Description,
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
&recurrence, &assignee, &completedAt, &completedBy,
&t.CreatedBy, &t.SortOrder, &created, &updated)
&createdBy, &t.SortOrder, &created, &updated,
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
if err != nil {
return t, err
}
t.SectionID = sectionID.String
t.ParentID = parentID.String
t.CreatedBy = createdBy.String
t.DueDate = dueDate.String
t.DueTimezone = dueTZ.String
t.RecurrenceRule = recurrence.String
@@ -384,7 +431,7 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
t.Content, t.Description, t.Priority, nullString(t.DueDate),
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
nullString(t.RecurrenceRule), nullString(t.AssigneeID), t.CreatedBy,
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
t.SortOrder, now.Unix(), now.Unix())
if err != nil {
return err
+68 -24
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -28,8 +29,15 @@ type User struct {
Timezone string
Locale string
IsAdmin bool
CreatedAt time.Time
UpdatedAt time.Time
// SidebarCollapsed is the sidebar headings this person has folded away, by
// key. On the account rather than in localStorage, for the same reason a
// project group carries `collapsed` on its row: folding a heading is a
// statement about the work — "I am not in Etiketter at the moment" — and that
// is as true on the laptop as on the desktop. The sidebar's *width* is the
// opposite case and is stored per browser.
SidebarCollapsed []string
CreatedAt time.Time
UpdatedAt time.Time
}
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
@@ -96,7 +104,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
}
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, created_at, updated_at`
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
var u User
@@ -104,9 +112,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
var totpEnabled, isAdmin int
var created, updated int64
var collapsed string
err := db.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &created, &updated)
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -117,11 +127,41 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
u.TOTPSecret = secret.String
u.TOTPEnabled = totpEnabled == 1
u.IsAdmin = isAdmin == 1
// A malformed value is read as "nothing folded" rather than as an error: the
// worst case is a sidebar that opens fully, which is the state it shipped in.
u.SidebarCollapsed = []string{}
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
u.CreatedAt = time.Unix(created, 0).UTC()
u.UpdatedAt = time.Unix(updated, 0).UTC()
return &u, nil
}
// SetSidebarCollapsed records which sidebar headings are folded.
//
// Its own method rather than a field on UpdateProfile: that one backs a form with
// a save button and validates three fields together, and this is a toggle that
// writes on every click. Sharing the path would mean a fold sending a name and a
// timezone it was never asked to change.
func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections []string) error {
if sections == nil {
sections = []string{}
}
raw, err := json.Marshal(sections)
if err != nil {
return err
}
res, err := db.ExecContext(ctx,
`UPDATE users SET sidebar_collapsed = ?, updated_at = ? WHERE id = ?`,
string(raw), time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// Person is the public half of a user: what somebody else is allowed to see when
// their name has to appear next to a task. No address, no hash, no timezone.
type Person struct {
@@ -175,12 +215,17 @@ type UserSummary struct {
// ProjectCount excludes the Inbox: every account has one, so counting it would
// make "1 project" mean "nothing".
ProjectCount int
// TaskCount is everything a delete would destroy, which is not the same as
// "their tasks": it is the tasks in projects they own *plus* every task they
// wrote anywhere, because tasks.created_by cascades. A number that counted
// only the first would understate the damage in exactly the case that matters
// — a colleague leaving a shared project.
// TaskCount is everything a delete would destroy: the tasks in the projects
// they own, which go with those projects. Tasks they merely *wrote* in
// somebody else's project are not counted, because they no longer go —
// `created_by` is ON DELETE SET NULL, so that work stays and loses its author.
TaskCount int
// AuthoredElsewhere is what a delete leaves behind unattributed: tasks they
// wrote in projects other people own. Separate from TaskCount because it is a
// different sentence — "this much disappears" and "this much stays without a
// name on it" — and running them together was what made the old number sound
// like a threat it no longer is.
AuthoredElsewhere int
}
// ListUsers returns every account on the instance. Administrators only — this is
@@ -192,8 +237,9 @@ func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
(SELECT count(*) FROM projects p
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE t.deleted_at IS NULL
AND (p.owner_id = u.id OR t.created_by = u.id))
WHERE t.deleted_at IS NULL AND p.owner_id = u.id),
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE t.deleted_at IS NULL AND t.created_by = u.id AND p.owner_id <> u.id)
FROM users u
ORDER BY u.is_admin DESC, u.name`)
if err != nil {
@@ -207,7 +253,7 @@ func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
var isAdmin int
var created, lastSeen int64
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
&lastSeen, &s.ProjectCount, &s.TaskCount); err != nil {
&lastSeen, &s.ProjectCount, &s.TaskCount, &s.AuthoredElsewhere); err != nil {
return nil, err
}
s.IsAdmin = isAdmin == 1
@@ -249,20 +295,18 @@ func (db *DB) SetUserAdmin(ctx context.Context, userID string, admin bool) error
// there is no way back — which is why the handler sends the counts to the
// interface first.
//
// What goes is wider than it looks. `projects.owner_id` cascades, so their
// projects go and every task in them goes too. But `tasks.created_by` cascades as
// well, so **every task they ever wrote goes, including in projects somebody else
// owns**. Retiring a colleague's account therefore removes their contributions
// from shared projects rather than leaving them behind unattributed.
// What goes: `projects.owner_id` cascades, so their projects go and every task in
// them goes too. That is the whole of it, and the handler sends the count to the
// interface first.
//
// That is the schema as it stands rather than a decision made here. Changing it —
// `created_by` as ON DELETE SET NULL, so the work stays and loses its author —
// means rebuilding the tasks table, which carries the FTS triggers with it. Worth
// doing; not worth doing quietly as part of adding a screen.
// What stays: everything they wrote in somebody else's project. `tasks.created_by`
// used to cascade as well, which meant retiring a colleague's account removed
// their contributions from shared work — see migration 0008, which rebuilt the
// table to make it ON DELETE SET NULL. The task survives and loses its author,
// which is the honest record of what happened.
//
// What survives: tasks merely *assigned* to them, which are unassigned by
// `assignee_id ON DELETE SET NULL`, and completions, by the same rule on
// `completed_by`.
// Tasks merely *assigned* to them are unassigned by `assignee_id ON DELETE SET
// NULL`, and completions by the same rule on `completed_by`.
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil {
+18
View File
@@ -78,6 +78,24 @@ gameskill:
type: bool
default: false
# Restarting from verdande's own settings, which is also how it picks up a new
# version: a container cannot replace its own image, so the panel recreates it
# and pulls :latest on the way. All three are needed together; the page names
# whichever are missing.
- key: VERDANDE_PANEL_URL
name: "Panel address, so verdande can ask to be restarted from its own settings"
type: string
default: ""
- key: VERDANDE_PANEL_TOKEN
name: "Panel API token — from an account with control of this server"
type: string
secret: true
default: ""
- key: VERDANDE_PANEL_SERVER_ID
name: "This server's id in the panel (the one in its URL)"
type: string
default: ""
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
+40
View File
@@ -466,6 +466,46 @@
/>
<figcaption>The light theme.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/week.png"
alt="Upcoming as a single week: seven columns of days, each holding the tasks due that day."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>A week at a time — so a task can be dragged across a month boundary.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/detail.png"
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>A task, opened.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/themes.png"
alt="The settings page showing the five themes as swatches."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Five themes, all of them measured for contrast.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/history.png"
alt="The instance-wide history page: who did what, in which project, filtered by person and event."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>What has been done on the server, across every project.</figcaption>
</figure>
</div>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+956 -29
View File
File diff suppressed because it is too large Load Diff
+543 -809
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -12,11 +12,14 @@
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.1.0",
"svelte-check": "^4.0.0",
"vite": "^5.4.0"
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"svelte": "^5.56.9",
"svelte-check": "^4.7.6",
"vite": "^8.2.1"
},
"engines": {
"node": ">=22.12"
}
}
+38 -1
View File
@@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) {
}
const PORT = 8097;
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
// every browser makes. The passkey test cannot run against a loopback address.
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
@@ -65,6 +68,32 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
},
/**
* A short WebKit pass over the forms, and nothing else.
*
* The rest of this suite is Chromium only on purpose: a smoke test exists to
* catch a build that does not work, and running it in three engines finds the
* same bug three times. Focus is the exception, and it cost a real report to
* learn it. Safari ignores the `autofocus` attribute on anything inserted
* after the page has loaded — so every inline form in this app opened with no
* cursor in it, and what you typed went nowhere. It does not look like a bug;
* it looks like the feature does not work. "Sections have no function,
* because you cannot create more than one" was this.
*
* One test, not four. The engines share this server and this database, so a
* second pass over tests that create projects by name collides with what the
* first pass already made — which fails as a name that is suddenly ambiguous
* rather than as anything about WebKit. The sections test is the one that
* earns the second engine, because it is the one the focus bug broke.
*
* WebKit here is the same engine Safari is, which is the whole point.
*/
{
name: 'webkit-forms',
grep: /@forms/,
use: { ...devices['Desktop Safari'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
}
],
@@ -92,7 +121,15 @@ export default defineConfig({
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
VERDANDE_BASE_URL: baseURL,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
+272
View File
@@ -0,0 +1,272 @@
/**
* Captures the screenshots the landing page, the README and the docs use.
*
* node scripts/generate-screenshots.js
*
* A script rather than a test, for the same reason as generate-icons.js: CI must
* never rewrite a tracked binary file. Run it when the interface changes.
*
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a
* running instance and a session cookie pasted in by hand — and which therefore
* ran rarely enough that the images fell many rounds behind the app they show.
* This one starts the real binary, creates its own account, fills it with the
* demo data, and takes the pictures. Nothing to set up and nothing to remember.
*
* The binary, not the dev server: the screenshots should show what ships. That
* means building the frontend and copying it into cmd/verdande/webbuild first,
* exactly as the smoke tests do — a capture of a stale embedded build would be a
* picture of an app nobody is running.
*/
import { chromium } from '@playwright/test';
import { spawn, execSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const web = join(here, '..');
const repo = join(web, '..');
const dataDir = join(web, '.screenshot-data');
const PORT = 8098;
const baseURL = `http://127.0.0.1:${PORT}`;
const USER = { name: 'Kristian', email: '[email protected]', password: 'et langt kodeord' };
// 1400×900 is what the landing page's <img> declares. A capture at another size
// would be resampled by the browser and look soft next to the text beside it.
const WIDTH = 1400;
const HEIGHT = 900;
const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')];
/**
* The pictures, and why each one is here.
*
* Every view that carries a claim the prose makes. A screenshot set that stops at
* "the task list" is a set that cannot show anything the app grew since.
*/
const SHOTS = [
{ name: 'today', path: '/', theme: 'dark' },
{ name: 'today-light', path: '/', theme: 'light' },
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
// The same project as a list, which is where sections read as bands rather than
// as columns — and the shape most people actually work in.
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
{ name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
];
async function showList(page) {
await page.getByRole('button', { name: 'Liste', exact: true }).click();
await page.waitForTimeout(600);
}
async function showMonth(page) {
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await page.waitForTimeout(800);
}
async function showWeek(page) {
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await page.waitForTimeout(800);
}
async function openFirstTask(page) {
await page.locator('.row').first().click();
await page.waitForTimeout(600);
}
console.log('building the frontend and embedding it…');
execSync('npm run build', { cwd: web, stdio: 'inherit' });
rmSync(join(repo, 'cmd', 'verdande', 'webbuild'), { recursive: true, force: true });
execSync(`cp -r ${JSON.stringify(join(web, 'build'))} ${JSON.stringify(join(repo, 'cmd', 'verdande', 'webbuild'))}`);
// A clean instance: the first screen is "create the first account", which only
// appears on an empty database.
rmSync(dataDir, { recursive: true, force: true });
console.log('starting the binary…');
// `detached`, so the whole process group can be killed at the end. `go run`
// compiles the binary and runs it as a *child*: killing `go run` leaves the server
// itself holding the port, and the next run then dies on "address already in use"
// with no clue as to what is holding it.
const server = spawn('go', ['run', '-tags', 'embedweb', './cmd/verdande'], {
cwd: repo,
detached: true,
stdio: ['ignore', 'inherit', 'inherit'],
env: {
...process.env,
VERDANDE_ADDR: `:${PORT}`,
VERDANDE_BASE_URL: baseURL,
VERDANDE_DATA_DIR: dataDir,
// An update check here would reach out to GitHub to take a screenshot.
VERDANDE_UPDATE_CHECK: 'false'
}
});
let browser;
try {
await waitForHealth();
browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: WIDTH, height: HEIGHT },
// The same pinning the smoke tests use, and for the same reason: the sign-up
// form sends navigator.language as the account's locale, and every due date
// is resolved in the account's zone.
locale: 'da-DK',
timezoneId: 'Europe/Copenhagen',
deviceScaleFactor: 2
});
await page.goto(baseURL);
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();
await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor();
console.log('seeding…');
const ids = await page.evaluate(seed);
mkdirSync(OUT[0], { recursive: true });
mkdirSync(OUT[1], { recursive: true });
for (const shot of SHOTS) {
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
await page.goto(baseURL + path);
// A fixed settle rather than a load event: the interesting part is after
// hydration and after the first fetch, and the load event says nothing
// about either.
await page.waitForTimeout(1200);
if (shot.prepare) await shot.prepare(page);
if (shot.theme) {
await page.evaluate((t) => (document.documentElement.dataset.theme = t), shot.theme);
await page.waitForTimeout(400);
}
const png = await page.screenshot();
writeFileSync(join(OUT[0], `${shot.name}.png`), png);
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
console.log(` ${shot.name}.png`);
}
} finally {
await browser?.close();
// The group, not the process: see the spawn above.
try {
process.kill(-server.pid, 'SIGKILL');
} catch {
// Already gone, which is the outcome this was for.
}
rmSync(dataDir, { recursive: true, force: true });
}
async function waitForHealth() {
for (let i = 0; i < 240; i++) {
try {
const r = await fetch(`${baseURL}/healthz`);
if (r.ok) return;
} catch {
// Not up yet. The first run compiles the binary, which takes a while.
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error('the server never came up');
}
/**
* The demo data, created through the app's own API from inside the page.
*
* From the page rather than from node, so the session cookie and the same-origin
* header the API insists on come along without being reconstructed here. Written
* as one function because it is passed to the browser as source.
*
* The content is ordinary Danish household and work tasks. Screenshots of "Task
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
* thing it is for.
*/
async function seed() {
const api = async (path, body, method = 'POST') => {
const r = await fetch(`/api/v1${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.status === 204 ? null : r.json();
};
const day = (offset) => {
const d = new Date();
d.setDate(d.getDate() + offset);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
};
// Two headings over the projects, which is what the sidebar grew for.
const arbejde = await api('/project-groups', { name: 'Arbejde' });
const privat = await api('/project-groups', { name: 'Privat' });
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
const work = await api('/projects', {
name: 'Sæsonstart',
color: 'indigo',
view_mode: 'board',
group_id: arbejde.id
});
const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id });
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' });
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
const tasks = [
// Today, in the Inbox and across the projects: the Today view is the first
// picture and it has to look like a real day.
{ content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id },
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
{
content: 'Skriv oplæg til sæsonmødet',
due_date: day(0),
priority: 2,
project_id: work.id,
section_id: planning.id,
duration_min: 90
},
{ content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
// The next couple of weeks, so Kommende and the month grid have something in
// them rather than a row of empty cells.
{ content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id },
{ content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id },
{ content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
{ content: 'Skift dæk', due_date: day(8), priority: 3 },
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
{ content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id },
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
// A few without dates, so the board has cards that are not all deadlines.
{ content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
];
for (const task of tasks) await api('/tasks', task);
// A saved filter, because the sidebar shows them and an empty heading reads as
// a feature that does not work.
await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' });
return { work: work.id, house: house.id, garden: garden.id };
}
+26
View File
@@ -407,6 +407,32 @@ button {
}
}
/* Form fields are 16px on a touch device, whatever the component asked for.
*
* Below 16px, iOS Safari zooms the page in when a field takes focus — and it does
* not zoom back out when the field is blurred. The person is left with the whole
* interface too large and has to pinch it back themselves, once per field they
* tap. The type scale here tops out at 15px, so every input in the app did it:
* quick add, the sign-in form, and the task drawer worst of all, which is four
* fields deep.
*
* `!important` on purpose. This is a browser workaround that has to beat every
* component's own typography, and each of those is a scoped class that outranks
* any selector this file could write. Narrowing it further would mean editing the
* sixty-nine places that set a field's font size and adding a rule none of them
* can be allowed to break.
*
* Touch rather than a width: an iPad in Safari does the same thing at a width no
* phone breakpoint would catch.
*/
@media (hover: none) and (pointer: coarse) {
input,
textarea,
select {
font-size: 16px !important;
}
}
.visually-hidden {
position: absolute;
width: 1px;
+80 -28
View File
@@ -22,46 +22,54 @@ export class ApiError extends Error {
}
}
/** Danish messages for the codes the API can return. The server sends English
* prose for logs; what a person reads is decided here, where the locale is known. */
import { t } from './i18n.svelte.js';
/**
* The key for each code the API can return.
*
* Keys rather than sentences, because the server's own prose is English and
* written for a log — what a person reads is decided here, where the locale is
* known. `errorcodes_test.go` walks this table against the codes Go can emit, so
* a code the server gained and this did not fails the build rather than falling
* through to that log prose.
*/
const MESSAGES = {
unauthorized: 'Forkert e-mail eller adgangskode.',
totp_required: 'Indtast koden fra din authenticator.',
forbidden: 'Det har du ikke adgang til.',
not_found: 'Findes ikke.',
conflict: 'Det er der allerede.',
rate_limited: 'For mange forsøg. Vent et øjeblik.',
validation_failed: 'Tjek felterne herunder.',
payload_too_large: 'Det er for stort.',
internal_error: 'Noget gik galt. Prøv igen.',
bad_request: 'Anmodningen kunne ikke læses.',
unauthorized: 'error.unauthorized',
totp_required: 'error.totpRequired',
forbidden: 'error.forbidden',
not_found: 'error.notFound',
conflict: 'error.conflict',
rate_limited: 'error.rateLimited',
validation_failed: 'error.validation',
payload_too_large: 'error.tooLarge',
internal_error: 'error.internal',
bad_request: 'error.badRequest',
// Client-only: a network failure is not an HTTP status, so the server has no
// code for it.
offline: 'Ingen forbindelse til serveren.',
offline: 'error.offline',
// 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
// shown as "that already exists" — 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.',
last_admin:
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.'
gmail_not_configured: 'error.gmailNotConfigured',
ai_not_configured: 'error.aiNotConfigured',
totp_not_enabled: 'error.totpNotEnabled',
inbox_protected: 'error.inboxProtected',
last_admin: 'error.lastAdmin'
};
/**
* 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.
* A code with no key 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;
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
const key = MESSAGES[err.code];
if (key) return t(key);
return err.message ?? t(MESSAGES.internal_error);
}
async function request(method, path, body, options = {}) {
@@ -160,6 +168,9 @@ export const api = {
logout: () => post('/auth/logout'),
me: () => get('/auth/me'),
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
// a save button, and this writes on every click of a chevron.
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
@@ -170,6 +181,15 @@ export const api = {
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
// --- passkeys
listPasskeys: () => get('/auth/passkeys'),
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
endSession: (id) => del(`/auth/sessions/${id}`),
// --- users, administrators only
@@ -179,6 +199,35 @@ export const api = {
deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
panelStatus: () => get('/panel'),
restartFromPanel: () => post('/panel/restart'),
// The nightly backup, which ran since the beginning with nothing to show it.
// The OAuth client itself, which is the instance's registration with Google
// rather than anybody's mailbox.
gmailClient: () => get('/gmail/client'),
setGmailClient: (data) => put('/gmail/client', data),
listBackups: () => get('/backups'),
runBackup: () => post('/backups'),
// A link rather than a fetch: the browser's own download handles a file of
// this size, and a blob in memory of the whole database does not.
backupURL: (id) => `/api/v1/backups/${id}`,
// The instance-wide history, which is the other half of the error log: that
// one is what broke, this is what was done. Paged with the cursor the server
// hands back rather than an offset — see the OpenAPI note on /activity.
auditLog: ({ before, user_id, project_id, event, limit } = {}) => {
const q = new URLSearchParams();
if (before) q.set('before', before);
if (user_id) q.set('user_id', user_id);
if (project_id) q.set('project_id', project_id);
if (event) q.set('event', event);
if (limit) q.set('limit', limit);
const query = q.toString();
return get(`/activity${query ? `?${query}` : ''}`);
},
auditEvents: () => get('/activity/events'),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
@@ -195,7 +244,9 @@ export const api = {
listProjectGroups: () => get('/project-groups'),
createProjectGroup: (name) => post('/project-groups', { name }),
getProjectGroup: (id) => get(`/project-groups/${id}`),
updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data),
uploadGroupAttachment: (id, file) => upload(`/project-groups/${id}/attachments`, file),
deleteProjectGroup: (id) => del(`/project-groups/${id}`),
reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }),
@@ -227,7 +278,8 @@ export const api = {
reopenTask: (id) => post(`/tasks/${id}/reopen`),
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
+13 -10
View File
@@ -21,17 +21,20 @@
* carries a `--color-<id>` token for each, and a Go test reads this file to check
* the server's list of accepted names has not drifted from it.
*/
// `name` is a key rather than a word: the picker calls `t()` on it. The ids stay
// exactly as they are — they are what the database stores, and `internal/store`
// has a test that reads them out of this file.
export const COLORS = [
{ id: 'graphite', name: 'Grafit' },
{ id: 'tomato', name: 'Tomat' },
{ id: 'amber', name: 'Rav' },
{ id: 'lime', name: 'Lime' },
{ id: 'green', name: 'Grøn' },
{ id: 'teal', name: 'Petrol' },
{ id: 'blue', name: 'Blå' },
{ id: 'indigo', name: 'Indigo' },
{ id: 'violet', name: 'Violet' },
{ id: 'magenta', name: 'Magenta' }
{ id: 'graphite', name: 'color.graphite' },
{ id: 'tomato', name: 'color.tomato' },
{ id: 'amber', name: 'color.amber' },
{ id: 'lime', name: 'color.lime' },
{ id: 'green', name: 'color.green' },
{ id: 'teal', name: 'color.teal' },
{ id: 'blue', name: 'color.blue' },
{ id: 'indigo', name: 'color.indigo' },
{ id: 'violet', name: 'color.violet' },
{ id: 'magenta', name: 'color.magenta' }
];
/** The CSS variable a colour id paints with, falling back to the default. */
+167 -3
View File
@@ -9,11 +9,54 @@
* them — and it gives keyboard users the browser's own behaviour for free.
*/
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { api, humanMessage } from '$lib/api.js';
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
import { focusOnMount } from '$lib/focus.js';
import TaskRow from './TaskRow.svelte';
import { t } from '$lib/i18n.svelte.js';
let { project, sections, canEdit } = $props();
let { project, sections, canEdit, onsectionadded } = $props();
// --- adding a column ------------------------------------------------------------
//
// A column on a board *is* a section, so this is where somebody looks for one.
// It was only in the list view, which meant a board — the view whose entire
// shape is its sections — had no way to grow one. Reported as "sections have no
// function: there is no way to create them", which from inside a board is
// exactly true.
let adding = $state(false);
let name = $state('');
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
let addingIn = $state(null);
let newInSection = $state('');
async function addToSection(event, sectionId) {
event.preventDefault();
const text = newInSection.trim();
if (!text) return;
newInSection = '';
addingIn = null;
try {
app.upsert(await api.quickAdd(text, project.id, sectionId));
} catch (e) {
app.toast(humanMessage(e));
}
}
async function addSection(event) {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
adding = false;
name = '';
try {
onsectionadded?.(await api.createSection(project.id, trimmed));
} catch (e) {
app.toast(humanMessage(e));
}
}
let draggingId = $state(null);
let overColumn = $state(null);
@@ -118,11 +161,65 @@
{/each}
{#if !tasks.length}
<p class="empty">Tom</p>
<p class="empty">{t('view.emptySection')}</p>
{/if}
</div>
<!-- At the foot of the column, where the next card would go. A board's
columns *are* the sections, so this is the one place where "add a
task here" needs no explanation at all. -->
{#if canEdit}
<!-- `column`, which is what the each binds. The unsectioned column has
an empty id, and that is exactly what quick add wants for "in this
project, no section". -->
{#if addingIn === column.id}
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
<input
bind:value={newInSection}
use:focusOnMount
placeholder={t('view.addTaskHere')}
aria-label={t('view.addTaskIn', { name: column.name })}
onblur={() => !newInSection.trim() && (addingIn = null)}
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
/>
</form>
{:else}
<button
class="add-task-open"
onclick={() => {
addingIn = column.id;
newInSection = '';
}}>{t('view.addTask')}</button
>
{/if}
{/if}
</section>
{/each}
{#if canEdit}
<section class="column adder">
{#if adding}
<form onsubmit={addSection}>
<input
bind:value={name}
use:focusOnMount
placeholder={t('view.sectionName')}
aria-label={t('view.newSection')}
onblur={() => !name.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
</form>
{:else}
<button
class="add"
onclick={() => {
adding = true;
name = '';
}}>{t('view.addSection')}</button
>
{/if}
</section>
{/if}
</div>
<style>
@@ -137,6 +234,43 @@
scroll-snap-type: x proximity;
}
/* Narrower than a real column and without its background: it is an invitation
at the end of the row, not a column with nothing in it. */
.adder {
background: none;
border: 1px dashed var(--line);
min-width: 180px;
padding: var(--s2);
}
.adder .add {
width: 100%;
padding: var(--s2);
font-size: var(--text-sm);
color: var(--ink-faint);
border-radius: var(--radius);
text-align: left;
}
.adder .add:hover {
color: var(--ink);
background: var(--surface);
}
.adder input {
width: 100%;
padding: var(--s2);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
}
.adder input:focus {
border-color: var(--accent);
}
.column {
flex: 0 0 300px;
max-width: 300px;
@@ -217,6 +351,36 @@
background: transparent;
}
.add-task-open {
align-self: flex-start;
padding: var(--s2);
font-size: var(--text-sm);
color: var(--ink-faint);
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.column:hover .add-task-open,
.add-task-open:focus-visible {
opacity: 1;
}
.add-task-open:hover {
color: var(--ink-muted);
}
.add-task input {
width: 100%;
padding: var(--s2);
background: var(--ground);
border: 1px solid var(--accent);
border-radius: var(--radius);
font: inherit;
font-size: var(--text-sm);
color: var(--ink);
outline: none;
}
.empty {
margin: 0;
padding: var(--s3) var(--s2);
+225 -28
View File
@@ -1,13 +1,19 @@
<script>
/**
* A month grid for one project.
* A calendar grid: a whole month, or a single week.
*
* Weeks start on Monday. That is not a preference — it is ISO 8601 and it is
* what every Danish calendar does, and a Sunday-first grid makes a Dane
* misread the whole month at a glance.
*
* One component for both spans rather than two, because the part that is easy
* to get subtly wrong is the same in both: the drag target has to decide from
* `dataTransfer.types` alone whether it accepts a drop, and a second copy of
* that is a second place for it to be almost right.
*/
import { app } from '$lib/stores.svelte.js';
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { t, tag } from '$lib/i18n.svelte.js';
let {
/**
@@ -31,34 +37,82 @@
* loaded, so a project's month has to say which project it is — Kommende,
* which shows every project, is the one that leaves this out.
*/
projectId = null
projectId = null,
/**
* 'month' for the whole month as six weeks, 'week' for one week as a single
* row.
*
* The week exists for dragging across a month boundary. A month grid is
* anchored to a month, so the two days on either side of its edge are in
* different grids and moving a task between them means changing month
* mid-drag — which the browser will not let you do, because a drag in
* flight swallows the click that would page. A week that straddles the
* 31st and the 1st has both days in the same row.
*/
span = 'month'
} = $props();
let cursor = $state(startOfMonth(new Date()));
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
/**
* The weekday names, from Intl rather than written out.
*
* Not to save seven strings — so the abbreviations are the ones the language
* actually uses. Every language shortens its weekdays differently, and guessing
* at that from a translation table is how a calendar ends up saying something
* no reader of that language would write.
*
* Monday first, because the grid is. The 1st of January 2024 was a Monday; any
* Monday would do, and a constant one keeps this out of the render path.
*/
const WEEKDAYS = $derived.by(() => {
const format = new Intl.DateTimeFormat(tag(), { weekday: 'short' });
return Array.from({ length: 7 }, (_, i) => format.format(new Date(Date.UTC(2024, 0, 1 + i))));
});
function startOfMonth(d) {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
// getDay() is Sunday-based; this converts to "days since Monday".
function startOfWeek(d) {
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
return start;
}
function startOfSpan(d) {
return span === 'week' ? startOfWeek(d) : startOfMonth(d);
}
// The cursor has to follow the span it is measured in: switching from month to
// week with a cursor on the 1st would otherwise show the week containing the
// 1st rather than the week you were looking at, and switching back would land
// on whichever month that week happened to start in.
$effect(() => {
const aligned = startOfSpan(cursor);
// Compared by value, not identity: assigning a fresh Date on every run would
// re-trigger this effect forever, since the effect reads what it writes.
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
});
function iso(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
}
// The grid always runs whole weeks, so it starts on the Monday on or before the
// first of the month and ends on the Sunday on or after the last.
// A month always runs whole weeks, so it starts on the Monday on or before the
// first and ends on the Sunday on or after the last. A week is already one.
let grid = $derived.by(() => {
const first = startOfMonth(cursor);
const start = new Date(first);
// getDay() is Sunday-based; this converts to "days since Monday".
start.setDate(first.getDate() - ((first.getDay() + 6) % 7));
const start = span === 'week' ? startOfWeek(cursor) : startOfWeek(startOfMonth(cursor));
// Six weeks covers every possible month layout, including a 31-day month
// that begins on a Sunday.
const length = span === 'week' ? 7 : 42;
const days = [];
const cell = new Date(start);
// Six weeks covers every possible month layout, including a 31-day month
// that begins on a Sunday.
for (let i = 0; i < 42; i++) {
for (let i = 0; i < length; i++) {
days.push(new Date(cell));
cell.setDate(cell.getDate() + 1);
}
@@ -84,11 +138,55 @@
);
const monthName = $derived(
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
);
function step(months) {
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1);
// "24. aug. – 30. aug. 2026", collapsed to one month name when the week does not
// straddle two. The year is said once, at the end, where it is least in the way.
const weekName = $derived.by(() => {
const from = grid[0];
const to = grid[grid.length - 1];
const day = { day: 'numeric', month: 'short' };
const left =
from.getMonth() === to.getMonth()
? from.toLocaleDateString(tag(), { day: 'numeric' })
: from.toLocaleDateString(tag(), day);
return `${left}–${to.toLocaleDateString(tag(), day)} ${to.getFullYear()}`;
});
/**
* The ISO week number, which is what a Dane means by "uge 35".
*
* Counted from the Thursday of the week, because that is the definition: week 1
* is the one containing the first Thursday of the year, so the Thursday is the
* only day guaranteed to be in the same year as the week it belongs to.
*/
function isoWeek(d) {
const thursday = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
thursday.setUTCDate(thursday.getUTCDate() - ((thursday.getUTCDay() + 6) % 7) + 3);
const first = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));
first.setUTCDate(first.getUTCDate() - ((first.getUTCDay() + 6) % 7) + 3);
return 1 + Math.round((thursday - first) / (7 * 86400000));
}
const heading = $derived(span === 'week' ? weekName : monthName);
const weekNumber = $derived(span === 'week' ? isoWeek(grid[0]) : null);
// A week shows seven cells where a month shows forty-two, so each one has the
// height to be a list rather than a hint. Truncating it to three there would
// hide work in the empty half of a tall cell.
const chipLimit = $derived(span === 'week' ? 10 : 3);
// One step is one span: a month view pages by month, a week view by week. A week
// view that paged by month would be back to the problem it exists to solve.
function step(n) {
if (span === 'week') {
const next = new Date(cursor);
next.setDate(next.getDate() + n * 7);
cursor = next;
return;
}
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + n, 1);
}
// --- dropping a task on a day ---------------------------------------------------
@@ -111,16 +209,23 @@
}
</script>
<div class="calendar">
<div class="calendar" class:week={span === 'week'}>
<header>
<button onclick={() => step(-1)} aria-label="Forrige måned">‹</button>
<h2>{monthName}</h2>
<button onclick={() => step(1)} aria-label="Næste måned">›</button>
<button class="today" onclick={() => (cursor = startOfMonth(new Date()))}>I dag</button>
<button onclick={() => step(-1)} aria-label={span === 'week' ? t('view.prevWeek') : t('view.prevMonth')}
>‹</button
>
<h2 class:week={span === 'week'}>
{heading}
{#if weekNumber}<span class="weekno">{t('view.weekNumber', { n: weekNumber })}</span>{/if}
</h2>
<button onclick={() => step(1)} aria-label={span === 'week' ? t('view.nextWeek') : t('view.nextMonth')}
>›</button
>
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
</header>
<div class="weekdays" aria-hidden="true">
{#each ['man', 'tir', 'ons', 'tor', 'fre', 'lør', 'søn'] as day}
{#each WEEKDAYS as day}
<span>{day}</span>
{/each}
</div>
@@ -128,7 +233,8 @@
<div class="grid">
{#each grid as date (date.toISOString())}
{@const tasks = tasksOn(date)}
{@const outside = date.getMonth() !== cursor.getMonth()}
<!-- A week has no outside: every day in it is the week you asked for. -->
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day"
@@ -140,9 +246,15 @@
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
>
<!-- The weekday strip above the grid is enough on a wide screen. A week
stacked into rows on a phone hides it, and a column of bare numbers
is not a week — so the cell carries its own name there. -->
{#if span === 'week'}
<span class="weekday">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
{/if}
<span class="number">{date.getDate()}</span>
{#each tasks.slice(0, 3) as task (task.id)}
{#each tasks.slice(0, chipLimit) as task (task.id)}
<!-- Draggable as well as clickable: within a month, moving something
to another day is the whole reason to be looking at a month. The
browser needs a few pixels of movement before it calls a press a
@@ -159,8 +271,8 @@
</button>
{/each}
{#if tasks.length > 3}
<span class="more">+{tasks.length - 3} mere</span>
{#if tasks.length > chipLimit}
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
{/if}
</div>
{/each}
@@ -181,6 +293,24 @@
min-width: 180px;
}
/* A date range is not a month name: "24.–30. aug. 2026" is already capitalised
where it should be, and capitalising it again gives "24.–30. Aug. 2026". */
h2.week {
text-transform: none;
min-width: 220px;
display: flex;
align-items: baseline;
gap: var(--s2);
}
.weekno {
font-size: var(--text-xs);
font-weight: 400;
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
}
header button {
width: 28px;
height: 28px;
@@ -244,6 +374,22 @@
gap: 2px;
}
/* Seven cells instead of forty-two, so the height a month has to spend on six
rows goes into one. It is what makes the week a view rather than only a wider
drop target. */
.calendar.week .day {
min-height: 320px;
}
/* Only shown when the grid has stacked; the strip above says it otherwise. */
.weekday {
display: none;
font-size: var(--text-xs);
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Days from the neighbouring months stay visible but recede — removing them
would leave ragged holes at the corners of the grid. */
.day.outside {
@@ -311,9 +457,6 @@
padding-left: var(--s1);
}
/* On a phone a seven-column grid of 92px cells is unreadable. The cells shrink
and the chips give way to a count — the month shape is the useful part at
that width, not the titles. */
@media (max-width: 620px) {
.day {
min-height: 56px;
@@ -342,5 +485,59 @@
.more {
font-size: 10px;
}
/* A week on a phone is not seven columns of 45px. It becomes seven rows —
the same information in the shape the screen has room for, and each row
is still a drop target, which is the whole point of the view.
The chips stay readable here, unlike in the month grid above: a
full-width row has room for a title, and a week somebody opened on
purpose is one they want to read rather than count. */
.calendar.week .grid {
grid-template-columns: 1fr;
}
.calendar.week .day {
min-height: 0;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: var(--s2);
padding: var(--s2);
}
.calendar.week .weekdays {
display: none;
}
.calendar.week .weekday {
display: inline;
}
.calendar.week .chip {
font-size: var(--text-xs);
height: auto;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 2px solid var(--line-strong);
background: var(--surface-raised);
}
.calendar.week .chip[data-priority='1'] {
border-left-color: var(--p1);
background: var(--surface-raised);
}
.calendar.week .chip[data-priority='2'] {
border-left-color: var(--p2);
background: var(--surface-raised);
}
.calendar.week .chip[data-priority='3'] {
border-left-color: var(--p3);
background: var(--surface-raised);
}
.calendar.week .more {
font-size: var(--text-xs);
}
}
</style>
+5 -4
View File
@@ -1,6 +1,7 @@
<script>
/** Cmd+K: search across everything the user can see. */
import { api } from '$lib/api.js';
import { t } from '$lib/i18n.svelte.js';
import { goto } from '$app/navigation';
let { open = $bindable(false) } = $props();
@@ -87,13 +88,13 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="scrim" onclick={() => (open = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Søg">
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label={t('nav.search')}>
<input
bind:this={input}
bind:value={query}
{onkeydown}
placeholder="Søg i opgaver og projekter"
aria-label="Søg"
placeholder={t('nav.searchLong')}
aria-label={t('nav.search')}
autocomplete="off"
spellcheck="false"
/>
@@ -115,7 +116,7 @@
{/each}
</ul>
{:else if query.trim()}
<p class="empty">Ingen resultater.</p>
<p class="empty">{t('nav.noResults')}</p>
{/if}
</div>
</div>
+4 -4
View File
@@ -14,7 +14,7 @@
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
@@ -144,15 +144,15 @@
bind:value={text}
{onkeydown}
type="text"
placeholder="Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”"
aria-label="Ny opgave"
placeholder={t('task.placeholder')}
aria-label={t('task.new')}
autocomplete="off"
spellcheck="false"
/>
</div>
{#if text.trim()}
<button type="submit" class="submit" disabled={submitting}>Tilføj</button>
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
{/if}
</form>
+236 -129
View File
@@ -1,8 +1,10 @@
<script>
import { app, sidebar } from '$lib/stores.svelte.js';
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -18,7 +20,13 @@
newName = '';
adding = false;
const project = await app.createProject(name);
if (project) goto(`/projekt/${project.id}`);
if (!project) return;
// Closes the drawer as well as navigating. On a phone the sidebar is an
// overlay, and creating a project moved the page underneath it while leaving
// it covering the screen — every link in here closes it, and this makes the
// same move for the same reason.
onnavigate?.();
goto(`/projekt/${project.id}`);
}
async function signOut() {
@@ -79,8 +87,6 @@
let newGroupName = $state('');
let renamingGroup = $state(null);
let groupName = $state('');
/** The group whose swatches are open, if any. */
let coloringGroup = $state(null);
async function createGroup(event) {
event.preventDefault();
@@ -104,11 +110,9 @@
// Says what happens to the projects, because "slet gruppen" reads like it
// might take them with it. It does not: only the heading goes.
const kept =
count === 1
? 'Projektet i den bliver, men ligger bagefter uden gruppe.'
: `De ${count} projekter bliver, men ligger bagefter uden gruppe.`;
if (!confirm(count ? `Slet gruppen "${group.name}"? ${kept}` : `Slet gruppen "${group.name}"?`))
return;
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
const question = t('sidebar.deleteGroup', { name: group.name });
if (!confirm(count ? `${question} ${kept}` : question)) return;
await app.deleteGroup(group.id);
}
@@ -324,7 +328,7 @@
}
</script>
<nav class="sidebar" aria-label="Hovedmenu">
<nav class="sidebar" aria-label={t('nav.main')}>
<div class="brand">
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
starts with in the elder futhark. One glyph, no wordmark beside it —
@@ -349,11 +353,11 @@
ondrop={onDropOnToday}
>
<span class="dot today" aria-hidden="true"></span>
I dag
{t('nav.today')}
</a>
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
Kommende
{t('nav.upcoming')}
</a>
{#if app.projects.some((p) => p.shared)}
<!-- Only where delegating is possible at all. On an instance with one
@@ -361,9 +365,17 @@
entry is a question the sidebar keeps asking and answering "no". -->
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
Venter på andre
{t('nav.delegated')}
</a>
{/if}
<!-- After the three views of what is left, because it is the one that is not
about what is left. It earns a permanent place rather than a toggle in
each view: somebody looking for a task they closed by mistake does not
know which view they closed it in. -->
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('done.title')}
</a>
{#if app.inbox}
{@render projectRow(app.inbox, false)}
{/if}
@@ -398,13 +410,43 @@
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{project.name}
{projectName(project)}
{#if project.shared}
<span class="count">{project.member_count}</span>
{/if}
</a>
{/snippet}
<!--
A fixed heading that folds, the same way a project group's does.
One snippet for all four rather than four copies of a chevron and an aria
attribute: they have to behave identically, and the group heading is already
the proof that they drift otherwise — it grew a fold, a colour and two
actions while these stayed plain text.
The whole heading is the target, not the chevron. At this size a lone chevron
is something you miss, and the name is where the eye already is.
-->
{#snippet foldHeading(key, label)}
<h2 class="fold">
<button
onclick={() => app.toggleSection(key)}
aria-expanded={!app.sectionCollapsed(key)}
>
<svg
class="chevron"
class:collapsed={app.sectionCollapsed(key)}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
{label}
</button>
</h2>
{/snippet}
<div class="group">
<!-- The heading is also the drop target for "no group": without it, a project
dragged into a group could only be got out again by emptying the group,
@@ -422,14 +464,14 @@
ondragleave={() => (overGroup = null)}
ondrop={(e) => onDropInGroup(e, '')}
>
<h2>Projekter</h2>
{@render foldHeading('projects', t('sidebar.projects'))}
<button
class="icon"
onclick={() => {
adding = !adding;
addingGroup = false;
}}
aria-label="Nyt projekt">+</button
aria-label={t('sidebar.newProject')}>+</button
>
<button
class="icon new-group"
@@ -438,8 +480,8 @@
adding = false;
newGroupName = '';
}}
aria-label="Ny gruppe"
title="Ny gruppe"
aria-label={t('sidebar.newGroup')}
title={t('sidebar.newGroup')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
@@ -449,12 +491,11 @@
{#if adding}
<form onsubmit={createProject}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={newName}
autofocus
placeholder="Projektnavn"
aria-label="Projektnavn"
use:focusOnMount
placeholder={t('sidebar.projectName')}
aria-label={t('sidebar.projectName')}
onblur={() => !newName.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
@@ -463,24 +504,25 @@
{#if addingGroup}
<form onsubmit={createGroup}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={newGroupName}
autofocus
placeholder="Gruppens navn"
aria-label="Gruppens navn"
use:focusOnMount
placeholder={t('sidebar.groupName')}
aria-label={t('sidebar.groupName')}
onblur={() => !newGroupName.trim() && (addingGroup = false)}
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
/>
</form>
{/if}
{#each ungrouped as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if !app.sectionCollapsed('projects')}
{#each ungrouped as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if own.length === 0 && !adding}
<p class="empty">Ingen projekter endnu.</p>
{#if own.length === 0 && !adding}
<p class="empty">{t('sidebar.noProjects')}</p>
{/if}
{/if}
</div>
@@ -507,15 +549,46 @@
ondrop={(e) => onGroupDrop(e, group)}
>
{#if renamingGroup === group.id}
<form onsubmit={(e) => renameGroup(e, group)}>
<!-- svelte-ignore a11y_autofocus -->
<!-- Renaming is where the colour is chosen too.
The dot used to be a button in the row, which made the heading
carry four controls — dot, name, Omdøb, Slet — on a line narrow
enough that they crowded the name. Both are edits to the same
thing, so both belong in the same moment: you open the group to
change it, and change what you came to change.
`focusout` rather than `blur` closes it, and only when focus has
left the form altogether — clicking a swatch is still inside it.
The swatches also refuse focus on mousedown, because Safari and
Firefox do not focus a button on click, so `relatedTarget` would
be null and the form would shut under the pointer. -->
<form
class="renaming"
onsubmit={(e) => renameGroup(e, group)}
onfocusout={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
}}
>
<input
bind:value={groupName}
autofocus
aria-label="Gruppens navn"
onblur={() => (renamingGroup = null)}
use:focusOnMount
aria-label={t('sidebar.groupName')}
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
/>
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
{#each COLORS as color (color.id)}
<button
type="button"
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={t(color.name)}
aria-label={t(color.name)}
aria-pressed={(group.color ?? 'graphite') === color.id}
onmousedown={(e) => e.preventDefault()}
onclick={() => app.setGroupColor(group.id, color.id)}
></button>
{/each}
</div>
</form>
{:else}
<!-- The button sits inside the heading, not around it, the same way a
@@ -524,32 +597,35 @@
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The dot is the control as well as the preview: what you press is
the thing you are about to change. It also keeps the heading's
actions down to two — they carry `opacity: 0` rather than
`display: none`, so they take their width whether or not you can
see them, and a third one squeezed the group's name until it
truncated. -->
<!-- The chevron folds; the name opens the group's own page. Two
targets in one heading, because they are two different intentions
and the old single one could only ever serve the smaller. -->
<button
class="dot group-dot"
style="background: {colorVar(group.color)}"
onclick={() => (coloringGroup = coloringGroup === group.id ? null : group.id)}
aria-expanded={coloringGroup === group.id}
aria-label="Farve på {group.name}"
title="Farve"
></button>
class="chevron-button"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed
? t('sidebar.unfold', { name: group.name })
: t('sidebar.fold', { name: group.name })}
>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<h2 class="fold">
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
<a href="/gruppe/{group.id}" onclick={onnavigate}>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
></span>
{group.name}
</button>
</a>
</h2>
<span class="count">{inside.length}</span>
<button
@@ -557,37 +633,18 @@
onclick={() => {
renamingGroup = group.id;
groupName = group.name;
}}>Omdøb</button
}}>{t('sidebar.rename')}</button
>
<button class="group-action remove" onclick={() => removeGroup(group)}>Slet</button>
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
{/if}
</div>
{#if coloringGroup === group.id}
<div class="swatches" role="group" aria-label="Farve på {group.name}">
{#each COLORS as color (color.id)}
<button
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={color.name}
aria-label={color.name}
aria-pressed={(group.color ?? 'graphite') === color.id}
onclick={() => {
app.setGroupColor(group.id, color.id);
coloringGroup = null;
}}
></button>
{/each}
</div>
{/if}
{#if !group.collapsed}
{#each inside as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if !inside.length}
<p class="empty">Tom — træk et projekt herop.</p>
<p class="empty">{t('sidebar.emptyGroup')}</p>
{/if}
{/if}
</div>
@@ -595,43 +652,49 @@
{#if shared.length}
<div class="group">
<div class="group-head"><h2>Delt med mig</h2></div>
{#each shared as project (project.id)}
{@render projectRow(project, false)}
{/each}
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
{#if !app.sectionCollapsed('shared')}
{#each shared as project (project.id)}
{@render projectRow(project, false)}
{/each}
{/if}
</div>
{/if}
{#if filters.length}
<div class="group">
<div class="group-head"><h2>Filtre</h2></div>
{#each filters as filter (filter.id)}
<a
href="/filter/{filter.id}"
class:active={current === `/filter/${filter.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{filter.name}
</a>
{/each}
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
{#if !app.sectionCollapsed('filters')}
{#each filters as filter (filter.id)}
<a
href="/filter/{filter.id}"
class:active={current === `/filter/${filter.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{filter.name}
</a>
{/each}
{/if}
</div>
{/if}
{#if labels.length}
<div class="group">
<div class="group-head"><h2>Etiketter</h2></div>
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
href="/etiket/{encodeURIComponent(label.name)}"
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{label.name}
<span class="count">{label.task_count}</span>
</a>
{/each}
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
{#if !app.sectionCollapsed('labels')}
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
href="/etiket/{encodeURIComponent(label.name)}"
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{label.name}
<span class="count">{label.task_count}</span>
</a>
{/each}
{/if}
</div>
{/if}
@@ -648,20 +711,20 @@
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
{t('nav.settings')}
</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}
<span class="offline" title="Ændringer fra andre vises ikke lige nu">Offline</span>
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
{/if}
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">Log ud</span>
<span class="signout">{t('nav.signOut')}</span>
</button>
</div>
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
@@ -672,7 +735,7 @@
class:resizing
role="separator"
aria-orientation="vertical"
aria-label="Bredde på sidebjælken"
aria-label={t('nav.sidebarWidth')}
tabindex="0"
onpointerdown={startResize}
onpointermove={onResize}
@@ -704,13 +767,20 @@
position: relative;
}
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
The visible line stays the border; this only widens what the pointer hits. */
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
the border; this only widens what the pointer hits.
Flush with the inside edge rather than straddling the border. It used to sit
at `right: -3px`, and an absolutely positioned child still counts towards its
containing block's scrollable overflow — so those three pixels gave the whole
sidebar a horizontal scrollbar on every desktop, always, whatever was in it.
Seven pixels inside the edge is the same target for anybody aiming at the
border, and it is a target that is actually inside the box it belongs to. */
.resize {
position: absolute;
top: 0;
bottom: 0;
right: -3px;
right: 0;
width: 7px;
cursor: col-resize;
z-index: 10;
@@ -866,6 +936,25 @@
min-width: 0;
}
/* The chevron on its own, so the name beside it can be a link to the group's
page. Sized as a target rather than as a glyph: 11px of arrow is something
you miss. */
.chevron-button {
flex: none;
width: 18px;
height: 18px;
display: grid;
place-items: center;
border-radius: var(--radius-sm);
color: inherit;
}
.chevron-button:hover {
color: var(--ink);
background: var(--surface);
}
.fold a,
.fold button {
display: flex;
align-items: center;
@@ -881,10 +970,15 @@
white-space: nowrap;
}
.fold a:hover,
.fold button:hover {
color: var(--ink);
}
.fold a {
text-decoration: none;
}
.chevron {
width: 11px;
height: 11px;
@@ -929,21 +1023,12 @@
margin-left: 0;
}
/* The group's own mark, in front of the chevron so the colours of the headings
line up with each other rather than with the projects underneath. */
/* The group's own mark, between the chevron and the name — inside the fold
button, so it cannot push the heading in. It is a mark now and not a
control: the colour is chosen while renaming, which is the other edit to
the same thing. */
.group-dot {
margin-left: var(--s2);
padding: 0;
box-shadow: 0 0 0 2px var(--surface-sunken);
transition: box-shadow var(--fast) var(--ease);
}
.group-dot:hover,
.group-dot:focus-visible {
box-shadow:
0 0 0 2px var(--surface-sunken),
0 0 0 3px var(--line-strong);
outline: none;
flex: none;
}
/* Projects inside a group are indented to the group's name, not to its dot:
@@ -963,11 +1048,20 @@
padding-left: calc(var(--s2) + var(--s4));
}
/* Inside the rename form now, so it needs no padding of its own — the form
already sits where the heading did. */
.renaming {
display: flex;
flex-direction: column;
gap: var(--s2);
width: 100%;
min-width: 0;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
padding: var(--s2) var(--s2) var(--s3);
}
.swatch {
@@ -1147,6 +1241,7 @@
.avatar {
width: 22px;
height: 22px;
flex: none;
border-radius: var(--radius-full);
display: grid;
place-items: center;
@@ -1156,7 +1251,18 @@
flex: none;
}
/* `min-width: 0`, or the ellipsis never happens.
A flex item's default is `min-width: auto`, which refuses to shrink below its
content — so `overflow: hidden` had nothing to hide and the row simply grew
wider than the sidebar. And the sidebar has `overflow-y: auto`, which CSS
resolves to `auto` on the other axis too, so the result was a horizontal
scrollbar under the whole menu on a perfectly ordinary desktop.
Not fixed with `overflow-x: hidden` on the sidebar: the resize handle sits at
`right: -3px` and would be clipped away. The row shrinking is the fix; the
test asserting the sidebar never scrolls sideways is the backstop. */
.user-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1164,6 +1270,7 @@
.signout {
margin-left: auto;
flex: none;
font-size: var(--text-xs);
color: var(--ink-faint);
opacity: 0;
+103 -41
View File
@@ -18,6 +18,9 @@
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { available, signIn } from '$lib/passkey.js';
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
@@ -29,6 +32,41 @@
let fields = $state({});
let busy = $state(false);
// --- passkey -----------------------------------------------------------------
let passkeyReady = $state(false);
$effect(() => {
available().then((ok) => (passkeyReady = ok));
});
/**
* No email is asked for and none is sent: the device knows which account its
* key belongs to. That is not only convenience — it means this page cannot be
* used to find out who has an account here.
*/
async function signInWithPasskey() {
error = '';
busy = true;
try {
const result = await signIn();
if (result.totp_required) {
mode = 'totp';
} else {
app.user = result.user;
await app.load();
}
} catch (e) {
// A cancelled prompt is not a failure. Somebody changed their mind, and
// an error for that reads as though the key was rejected.
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
error = humanMessage(e);
}
} finally {
busy = false;
}
}
/**
* The token from an emailed link, if this is one.
*
@@ -143,69 +181,68 @@
{#if mode === 'loading'}
<p class="lede">&nbsp;</p>
{:else if mode === 'setup'}
<p class="lede">Opret den første konto. Den bliver administrator.</p>
<p class="lede">{t('auth.firstAccount')}</p>
<label>
Navn
{t('auth.name')}
<input bind:value={name} autocomplete="name" required />
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
{#if fields.name}<span class="field-error">{t('auth.nameRequired')}</span>{/if}
</label>
<label>
E-mail
{t('auth.email')}
<input bind:value={email} type="email" autocomplete="username" required />
{#if fields.email}<span class="field-error">Skal være en e-mailadresse</span>{/if}
{#if fields.email}<span class="field-error">{t('auth.emailInvalid')}</span>{/if}
</label>
<label>
Adgangskode
{t('auth.password')}
<input bind:value={password} type="password" autocomplete="new-password" required />
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
<span class="hint">{t('auth.passwordHint')}</span>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Opret konto</button>
<button type="submit" disabled={busy}>{t('auth.createAccount')}</button>
{:else if mode === 'totp'}
<p class="lede">Indtast koden fra din authenticator.</p>
<p class="lede">{t('auth.totpPrompt')}</p>
<label>
Kode
<!-- svelte-ignore a11y_autofocus -->
{t('auth.code')}
<input
bind:value={code}
autofocus
use:focusOnMount
inputmode="numeric"
autocomplete="one-time-code"
placeholder="123456"
class="code"
required
/>
<span class="hint">Har du mistet din telefon, virker en af dine gendannelseskoder her.</span>
<span class="hint">{t('auth.totpRecovery')}</span>
</label>
<button type="submit" disabled={busy}>Fortsæt</button>
<button type="submit" disabled={busy}>{t('auth.continue')}</button>
{:else if mode === 'forgot'}
<p class="lede">Vi sender et link til at vælge en ny adgangskode.</p>
<p class="lede">{t('auth.resetPrompt')}</p>
<label>
E-mail
{t('auth.email')}
<input bind:value={email} type="email" autocomplete="username" required />
</label>
<button type="submit" disabled={busy}>Send link</button>
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
<button type="submit" disabled={busy}>{t('auth.sendLink')}</button>
<button type="button" class="link" onclick={() => (mode = 'login')}>{t('auth.back')}</button>
{:else if mode === 'invite' || mode === 'reset'}
{#if !token}
<!-- A link that lost its token on the way. Saying so beats a form that
cannot succeed and a message that arrives only after it is filled in. -->
<p class="lede">
Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.
{t('auth.linkBroken')}
</p>
<a class="link" href="/">Til log ind</a>
<a class="link" href="/">{t('auth.toSignIn')}</a>
{:else if app.user}
<!-- Already somebody else. The invite is tied to an address, so using it
from this session would create a second account for a person who is
right here in the first one. -->
<p class="lede">
Du er logget ind som {app.user.name}. Log ud først, og åbn så linket igen.
{t('auth.alreadySignedIn', { name: app.user.name })}
</p>
<button
type="button"
@@ -213,61 +250,70 @@
onclick={async () => {
await api.logout();
location.reload();
}}>Log ud</button
}}>{t('auth.signOut')}</button
>
{:else if mode === 'invite'}
<p class="lede">Du er inviteret. Vælg et navn og en adgangskode.</p>
<p class="lede">{t('auth.invited')}</p>
<label>
Navn
{t('auth.name')}
<input bind:value={name} autocomplete="name" required />
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
{#if fields.name}<span class="field-error">{t('auth.nameRequired')}</span>{/if}
</label>
<label>
Adgangskode
{t('auth.password')}
<input bind:value={password} type="password" autocomplete="new-password" required />
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
<span class="hint">{t('auth.passwordHint')}</span>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Opret konto</button>
<button type="submit" disabled={busy}>{t('auth.createAccount')}</button>
{:else}
<p class="lede">Vælg en ny adgangskode.</p>
<p class="lede">{t('auth.pickNew')}</p>
<label>
Ny adgangskode
{t('auth.newPassword')}
<input bind:value={password} type="password" autocomplete="new-password" required />
<span class="hint">Mindst 10 tegn. Alle andre enheder bliver logget ud.</span>
<span class="hint">{t('auth.newPasswordHint')}</span>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Gem adgangskoden</button>
<button type="submit" disabled={busy}>{t('auth.savePassword')}</button>
{/if}
{:else if mode === 'done'}
<p class="lede">Adgangskoden er skiftet. Log ind med den nye.</p>
<a class="link" href="/">Til log ind</a>
<p class="lede">{t('auth.passwordChanged')}</p>
<a class="link" href="/">{t('auth.toSignIn')}</a>
{:else if mode === 'sent'}
<!-- Deliberately not "we sent an email": that would confirm the address
has an account here to anybody who tried it. -->
<p class="lede">
Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.
{t('auth.resetSent')}
</p>
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
<button type="button" class="link" onclick={() => (mode = 'login')}>{t('auth.back')}</button>
{:else}
<p class="lede">Log ind for at fortsætte.</p>
<p class="lede">{t('auth.signInPrompt')}</p>
<label>
E-mail
{t('auth.email')}
<input bind:value={email} type="email" autocomplete="username" required />
</label>
<label>
Adgangskode
{t('auth.password')}
<input bind:value={password} type="password" autocomplete="current-password" required />
</label>
<button type="submit" disabled={busy}>Log ind</button>
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
<!-- Only when the browser has somewhere to keep a key. Offering it where
it cannot work is a button that fails for reasons nobody can act on. -->
{#if passkeyReady}
<button type="button" class="passkey" disabled={busy} onclick={signInWithPasskey}>
{busy ? t('passkey.signingIn') : t('passkey.signIn')}
</button>
{/if}
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
Glemt adgangskode?
{t('auth.forgot')}
</button>
{/if}
@@ -380,6 +426,22 @@
cursor: default;
}
/* Set apart from the primary button but not made secondary: it is an equal way
in, not a fallback. */
.passkey {
width: 100%;
padding: var(--s2) var(--s4);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink);
transition: border-color var(--fast) var(--ease);
}
.passkey:hover {
border-color: var(--accent);
}
.link {
color: var(--ink-faint);
font-size: var(--text-sm);
+73 -42
View File
@@ -11,7 +11,9 @@
* necessarily the last one sent.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { app, projectName } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
let { task, onclose } = $props();
@@ -34,8 +36,8 @@
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
{ rule: 'FREQ=MONTHLY', label: 'Hver måned' },
{ rule: 'FREQ=YEARLY', label: 'Hvert år' }
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
];
let subtasks = $state([]);
@@ -96,8 +98,21 @@
.catch(() => {});
});
/** Sends a patch through the store, so the row behind the drawer updates too. */
/**
* Sends a patch through the store, so the row behind the drawer updates too.
*
* `task` can be null by the time this runs. The three fields below save on
* blur, and closing the drawer *is* a blur — the input is being unmounted, and
* the browser fires blur on the way out. If the task left `app.tasks` in the
* same moment (it was completed, it was moved, another browser deleted it),
* `app.detailTask` is already null and this is a handler for a task that is no
* longer there.
*
* It threw `Cannot read properties of null` and stopped the page. Nothing to
* save is not an error; it is the ordinary end of an edit nobody made.
*/
async function save(patch) {
if (!task) return;
await app.update(task.id, patch);
}
@@ -114,6 +129,7 @@
* worse than one that does nothing.
*/
async function saveContent() {
if (!task) return;
const trimmed = content.trim();
if (!trimmed || trimmed === task.content) {
content = task.content;
@@ -161,7 +177,7 @@
if (patch.priority) priority = patch.priority;
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
if (patch.labels) labels = patch.labels.join(', ');
app.toast(`Læst som ${took.join(', ')}.`);
app.toast(t('detail.readAs', { what: took.join(', ') }));
}
}
@@ -181,11 +197,12 @@
}
function saveDescription() {
if (description === (task.description ?? '')) return;
if (!task || description === (task.description ?? '')) return;
save({ description });
}
function saveLabels() {
if (!task) return;
const list = labels
.split(',')
.map((l) => l.trim())
@@ -347,9 +364,9 @@
<!-- 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">
<aside class="drawer" aria-label={t('detail.task')}>
<header>
<button class="close" onclick={onclose} aria-label="Luk">
<button class="close" onclick={onclose} aria-label={t('detail.close')}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
@@ -358,43 +375,43 @@
<div class="scroll">
<div class="field">
<!-- svelte-ignore a11y_autofocus -->
<input
class="title"
use:focusOnMount
bind:value={content}
onblur={saveContent}
aria-label="Opgavens tekst"
aria-label={t('detail.text')}
/>
</div>
<div class="field">
<label for="description">Beskrivelse</label>
<label for="description">{t('detail.description')}</label>
<textarea
id="description"
rows="4"
bind:value={description}
onblur={saveDescription}
placeholder="Detaljer, links, hvad der skal huskes"
placeholder={t('detail.descriptionPlaceholder')}
></textarea>
</div>
<div class="grid">
<div class="field">
<label for="priority">Prioritet</label>
<label for="priority">{t('detail.priority')}</label>
<select
id="priority"
bind:value={priority}
onchange={() => save({ priority: Number(priority) })}
>
<option value={1}>P1 — haster</option>
<option value={1}>{t('detail.p1')}</option>
<option value={2}>P2</option>
<option value={3}>P3</option>
<option value={4}>Ingen</option>
<option value={4}>{t('detail.none')}</option>
</select>
</div>
<div class="field">
<label for="due">Forfalder</label>
<label for="due">{t('detail.due')}</label>
<input
id="due"
type="date"
@@ -405,27 +422,27 @@
</div>
<div class="field">
<label for="labels">Etiketter</label>
<label for="labels">{t('detail.labels')}</label>
<input
id="labels"
bind:value={labels}
onblur={saveLabels}
placeholder="adskilt af komma"
placeholder={t('detail.labelsPlaceholder')}
/>
</div>
<div class="grid">
<div class="field">
<label for="project">Projekt</label>
<label for="project">{t('detail.project')}</label>
<select id="project" bind:value={projectId} onchange={saveProject}>
{#each app.projects as project (project.id)}
<option value={project.id}>{project.name}</option>
<option value={project.id}>{projectName(project)}</option>
{/each}
</select>
</div>
<div class="field">
<label for="repeat">Gentages</label>
<label for="repeat">{t('detail.repeats')}</label>
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
{#each REPEATS as option (option.rule)}
<option value={option.rule}>{option.label}</option>
@@ -442,9 +459,9 @@
{#if members.length > 1}
<div class="field">
<label for="assignee">Ansvarlig</label>
<label for="assignee">{t('detail.assignee')}</label>
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
<option value="">Ingen</option>
<option value="">{t('detail.none')}</option>
{#each members as member (member.user_id)}
<option value={member.user_id}>{member.name}</option>
{/each}
@@ -453,7 +470,7 @@
{/if}
<section>
<h3>Undertasks</h3>
<h3>{t('detail.subtasks')}</h3>
{#each subtasks as subtask (subtask.id)}
<div class="subtask" class:done={subtask.completed}>
@@ -461,26 +478,26 @@
class="check"
class:checked={subtask.completed}
onclick={() => toggleSubtask(subtask)}
aria-label={subtask.completed ? 'Genåbn' : 'Markér som færdig'}
aria-label={subtask.completed ? t('detail.reopen') : t('task.complete')}
>
<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 class="remove" onclick={() => removeSubtask(subtask)} aria-label={t('detail.delete')}>
×
</button>
</div>
{/each}
<form onsubmit={addSubtask}>
<input bind:value={newSubtask} placeholder="Tilføj en undertask" aria-label="Ny undertask" />
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
</form>
</section>
<section>
<h3>Filer</h3>
<h3>{t('detail.files')}</h3>
{#each attachments as attachment (attachment.id)}
<div class="file">
@@ -488,7 +505,7 @@
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 class="remove" onclick={() => removeAttachment(attachment)} aria-label={t('detail.delete')}>
×
</button>
</div>
@@ -496,18 +513,18 @@
<label class="upload">
<input type="file" onchange={upload} disabled={uploading} />
<span>{uploading ? 'Lægger op …' : 'Vedhæft en fil'}</span>
<span>{uploading ? t('detail.uploading') : t('detail.attachFile')}</span>
</label>
</section>
<section>
<h3>Påmindelser</h3>
<h3>{t('detail.reminders')}</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">
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
×
</button>
</div>
@@ -517,14 +534,14 @@
<input
type="datetime-local"
bind:value={newReminder}
aria-label="Ny påmindelse"
aria-label={t('detail.newReminder')}
/>
<button class="add" type="submit">Tilføj</button>
<button class="add" type="submit">{t('detail.add')}</button>
</form>
</section>
<section>
<h3>Kommentarer</h3>
<h3>{t('detail.comments')}</h3>
{#each comments as comment (comment.id)}
<article class="comment">
@@ -544,7 +561,7 @@
{/each}
</div>
{#if comment.user_id === app.user?.id}
<button class="remove" onclick={() => removeComment(comment)} aria-label="Slet">
<button class="remove" onclick={() => removeComment(comment)} aria-label={t('detail.delete')}>
×
</button>
{/if}
@@ -555,10 +572,10 @@
<textarea
rows="2"
bind:value={newComment}
placeholder="Skriv en kommentar"
aria-label="Ny kommentar"
placeholder={t('detail.writeComment')}
aria-label={t('detail.newComment')}
></textarea>
<button class="add" type="submit" disabled={posting}>Skriv</button>
<button class="add" type="submit" disabled={posting}>{t('detail.post')}</button>
</form>
</section>
</div>
@@ -575,7 +592,12 @@
.drawer {
position: fixed;
inset: 0 0 0 auto;
width: min(480px, 100vw);
/* `100%` rather than `100vw`. For a fixed element a percentage resolves
against the initial containing block, which excludes the scrollbar gutter
— `100vw` does not, so the drawer was a few pixels wider than the page and
the whole document could be pushed sideways. */
width: min(480px, 100%);
max-width: 100%;
z-index: 80;
display: flex;
flex-direction: column;
@@ -625,7 +647,12 @@
.scroll {
flex: 1;
min-width: 0;
overflow-y: auto;
/* Nothing inside may widen the drawer. A pasted URL in a comment or a long
unbroken word would otherwise stretch the flex column and take the page
with it. */
overflow-x: hidden;
overscroll-behavior: contain;
padding: var(--s4) var(--s4) var(--s8);
display: flex;
@@ -883,8 +910,12 @@
}
@media (max-width: 560px) {
/* Full width, stated as four insets rather than a width: the box is then
defined by the viewport's edges and cannot be a rounding error wider than
the thing it is pinned to. */
.drawer {
width: 100vw;
inset: 0;
width: auto;
border-left: 0;
}
}
+19 -4
View File
@@ -12,8 +12,9 @@
* drawn where the row would land.
*/
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { api } from '$lib/api.js';
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import TaskRow from './TaskRow.svelte';
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
@@ -33,7 +34,12 @@
}
function onDragOver(event, task) {
if (!canEdit || !carries(event, TASK) || !draggingId || draggingId === task.id) return;
// `carries` rather than `draggingId`: a task dragged from another list — the
// unsectioned rows above, or another section — never called this component's
// own dragstart, so its `draggingId` is null. Refusing on that made every
// list reject everything that came from outside it, which is the whole point
// of dragging between them.
if (!canEdit || !carries(event, TASK) || draggingId === task.id) return;
accept(event);
// Which half of the row the pointer is in decides the gap. Comparing against
@@ -54,13 +60,22 @@
// gesture and for sections with no rows to aim at. A drop that landed on a
// row has already said exactly where it goes, so it stops here.
event.stopPropagation();
const id = draggingId;
// From the drag itself, not from `draggingId`, which is only set when the
// drag *started* in this list. A task dragged in from the unsectioned rows
// or another section left it null, so this returned early — and it had
// already called stopPropagation, so the section around it never got the
// drop either. The result: a task could only be dropped into a section with
// no rows in it to aim at, which made every section look like it could hold
// exactly one task.
const id = dragged(event, TASK) || 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);
// The dragged task may not be in this list at all — that is the cross-list
// case — so `without` is "this list minus it" either way.
const without = ordered.filter((t) => t.id !== id);
const at = without.findIndex((t) => t.id === target.id);
if (at < 0) return;
@@ -101,7 +116,7 @@
);
} catch {
app.replace(id, previous);
app.toast('Kunne ikke flytte opgaven.');
app.toast(t('task.moveFailed'));
}
}
+70 -13
View File
@@ -9,12 +9,13 @@
* feels like a mis-click rather than an accomplishment.
*/
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
let { task, onedit } = $props();
let leaving = $state(false);
const PRIORITY_LABEL = { 1: 'Prioritet 1', 2: 'Prioritet 2', 3: 'Prioritet 3', 4: 'Ingen prioritet' };
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
async function toggle() {
if (task.completed) {
@@ -33,17 +34,17 @@
const due = new Date(date + 'T00:00:00');
const days = Math.round((due - new Date(today.toDateString())) / 86400000);
if (days === 0) return { text: 'I dag', tone: 'today' };
if (days === 1) return { text: 'I morgen', tone: 'soon' };
if (days === -1) return { text: 'I går', tone: 'overdue' };
if (days < 0) return { text: `${Math.abs(days)} dage forsinket`, tone: 'overdue' };
if (days === 0) return { text: t('task.today'), tone: 'today' };
if (days === 1) return { text: t('task.tomorrow'), tone: 'soon' };
if (days === -1) return { text: t('task.yesterday'), tone: 'overdue' };
if (days < 0) return { text: t('task.overdue', { n: Math.abs(days) }), tone: 'overdue' };
if (days < 7)
return {
text: due.toLocaleDateString('da-DK', { weekday: 'long' }),
text: due.toLocaleDateString(tag(), { weekday: 'long' }),
tone: 'soon'
};
return {
text: due.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' }),
text: due.toLocaleDateString(tag(), { day: 'numeric', month: 'short' }),
tone: 'later'
};
}
@@ -53,7 +54,7 @@
let due = $derived(dueLabel(task.due_date));
let time = $derived(
task.due_datetime
? new Date(task.due_datetime).toLocaleTimeString('da-DK', {
? new Date(task.due_datetime).toLocaleTimeString(tag(), {
hour: '2-digit',
minute: '2-digit'
})
@@ -66,8 +67,8 @@
class="check"
class:checked={task.completed || leaving}
onclick={toggle}
aria-label={task.completed ? 'Genåbn opgave' : 'Markér som færdig'}
title={PRIORITY_LABEL[task.priority]}
aria-label={task.completed ? t('task.reopen') : t('task.complete')}
title={priorityLabel(task.priority)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path class="tick" d="M6 12.5l4 4 8-8.5" />
@@ -83,13 +84,13 @@
<span class="description">{task.description}</span>
{/if}
{#if assignee || due || task.labels?.length || task.recurrence_text}
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
<span class="meta">
{#if assignee}
<!-- First in the row, because "who" changes what the rest of the line
means: a date on somebody else's task is their deadline, not
yours. -->
<span class="assignee" title="Ansvarlig: {assignee.name}">
<span class="assignee" title={t('task.assignee', { name: assignee.name })}>
<span class="face" style="background: {assignee.avatar_color}">
{assignee.name[0]?.toUpperCase() ?? '?'}
</span>
@@ -102,7 +103,7 @@
</span>
{/if}
{#if task.recurrence_text}
<span class="repeat" title="Gentages {task.recurrence_text}">
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 014-4h14" />
@@ -112,6 +113,36 @@
{task.recurrence_text}
</span>
{/if}
<!-- What is underneath, said as a fraction rather than a total: "3" tells
you there is something there, "1/3" tells you whether there is
anything left to do, which is the question the list is for. -->
{#if task.subtask_count}
<span
class="badge"
class:all-done={task.subtask_done === task.subtask_count}
title={t('task.subtasks', { done: task.subtask_done, total: task.subtask_count })}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6h11" />
<path d="M9 12h11" />
<path d="M9 18h11" />
<path d="M4 6l1 1 2-2" />
<path d="M4 12l1 1 2-2" />
<path d="M4 18l1 1 2-2" />
</svg>
{task.subtask_done}/{task.subtask_count}
</span>
{/if}
{#if task.attachment_count}
<span class="badge" title={t('task.attachments', { n: task.attachment_count })}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
/>
</svg>
{task.attachment_count}
</span>
{/if}
{#each task.labels ?? [] as label}
<span class="label">{label}</span>
{/each}
@@ -291,6 +322,32 @@
flex: none;
}
/* A mark, not a chip: it sits in the same row as the date and the labels and
must not out-shout them. The task's own text is the thing being read. */
.badge {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--ink-faint);
flex: none;
}
.badge svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Everything underneath is closed. Worth saying, because "3/3" and "1/3" are
the two answers you actually look for and only one of them is good news. */
.badge.all-done {
color: var(--accent);
}
.label {
color: var(--ink-muted);
background: var(--surface-raised);
+44
View File
@@ -0,0 +1,44 @@
/**
* What each recorded activity event is called.
*
* The keys are the server's and they are stable; this is the only place they
* become Danish. It lives in its own module because two surfaces read the same log
* now — a project's own history panel, and the administrator's instance-wide audit
* page — and two copies of nineteen strings is two places for them to drift apart.
*
* An unknown key falls through to the key itself rather than being hidden: a build
* that records something this table has not learned yet should still show that it
* happened.
*/
export const EVENTS = {
'project.created': 'oprettede projektet',
'project.updated': 'ændrede projektet',
'project.deleted': 'slettede projektet',
'project.imported': 'importerede projektet',
'section.created': 'oprettede en sektion',
'section.updated': 'ændrede en sektion',
'section.deleted': 'slettede en sektion',
'member.invited': 'inviterede',
'member.added': 'tilføjede',
'member.removed': 'fjernede',
'member.role_changed': 'ændrede rollen for',
'task.created': 'oprettede en opgave',
'task.updated': 'ændrede en opgave',
'task.completed': 'lukkede en opgave',
'task.reopened': 'genåbnede en opgave',
'task.moved': 'flyttede en opgave',
'task.deleted': 'slettede en opgave',
'task.split': 'delte en opgave op',
'comment.created': 'skrev en kommentar'
};
/** The name of an event, or the raw key when it is one this build does not know. */
export function eventName(event) {
return EVENTS[event] ?? event;
}
/** The bit of context an entry carries, when it has one worth reading. */
export function eventDetail(entry) {
const p = entry.payload ?? {};
return p.name ?? p.email ?? p.role ?? '';
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Focuses an element the moment it is inserted.
*
* <input use:focusOnMount />
*
* Replaces the `autofocus` attribute, which does not work in Safari for anything
* added after the page has loaded. Every inline form in this app is added after
* the page has loaded — the one that renames a project, the one that adds a
* section, the one that names a group — so in Safari they all appeared with no
* cursor in them. What you typed went nowhere.
*
* That is worse than it sounds, because it does not look like a bug. It looks like
* the feature does not work: "sections have no function, because you cannot create
* more than one" was this, and only this.
*
* The second attempt on the next frame is for the case where the element is
* inserted inside something that is still settling — a drawer sliding in, a list
* re-keying. Focusing an element the browser is still laying out can silently do
* nothing, and there is no event to wait for that means "now".
*/
export function focusOnMount(node) {
node.focus?.();
if (document.activeElement !== node) {
requestAnimationFrame(() => node.focus?.());
}
}

Some files were not shown because too many files have changed in this diff Show More