Compare commits

...
25 Commits
Author SHA1 Message Date
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-08-17 23:04:09 +02:00
Kristian WindandClaude Opus 5 c2e2d66e76 feat(admin): keep server errors where a restart cannot take them
Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at
11:49, twice from another at 10:45 — and there is no way to find out what
any of them were. verdande logs the real cause of every 500, but on a Rune
the log belongs to the container and the container is replaced on every
restart. So an operator is told that something broke and given nothing to
act on, which is the worst of both: alarming and useless.

Every 500 now writes a row: what the handler was attempting in its own
words, the error it hit, the method and path, who was asking, and the
request id that ties it back to the log line for whoever still has it. The
"what" is the part a status code cannot give — "list projects" and "move
task" are the difference between a fault you can find and one you can only
count.

Written best-effort and never checked. This is the path that is already
reporting a failure, and a diagnostic that can itself fail the request
would turn one broken screen into two.

Threading the request through `internal()` and `storeError()` touched 108
call sites across 19 files, which is the price of not having had it there
from the start. It is mechanical and the compiler checked all of it.

Kept for thirty days by the nightly sweep: long enough to answer "what
happened last week", short enough that a fault loop cannot fill the volume
the tasks live on.

Read at Indstillinger → Fejl, administrators only — it carries paths,
account names and the server's own error strings. The empty state says
"Ingen fejl registreret. Det er den rigtige tilstand", because a blank
panel should not read as a broken one.

The test drops a table and asks for the endpoint that reads it: the same
code path a disk error takes, and the row that comes back has to name the
operation, the account and the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:58:02 +02:00
Kristian WindandClaude Opus 5 4be934ccc1 feat(pwa): the PNG icons the manifest and iOS have always wanted
The manifest pointed at icon.svg and nothing else. Chrome and Firefox
install happily on that; Safari does not read the manifest's icons at all
and looks for /apple-touch-icon.png by name, so an installed PWA on iOS got
a screenshot of the page as its home-screen icon.

This was written down as blocked: there is no SVG renderer on this machine,
which is why the references were removed rather than left pointing at files
that did not exist. Playwright is an SVG renderer, and it has been a
dependency the whole time.

scripts/generate-icons.js renders the same SVG at 192, 512 and 180. A script
rather than a test, so CI never runs it and never rewrites a tracked binary
— run it when the icon changes. The smoke test already fetches every icon
the manifest names, and now asks for the Apple one by name too, so a
reference with no file behind it fails there rather than on somebody's
phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:37:52 +02:00
Kristian WindandClaude Opus 5 7094e05ddd feat(sidebar): drop a task on "I dag"
"Do it today" is the most-made rescheduling there is, and the sidebar is
where the pointer already is when you have picked a task up.

Only that one of the two date views. "Kommende" is a range rather than a
day — it starts today and runs a week — so a drop on it would have to
invent a date its own label does not name. The month grid inside it takes a
drop per cell, which is where a specific day is actually on offer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:35:40 +02:00
Kristian WindandClaude Opus 5 35d9e3bdac feat: an assignee on the row, and the activity log made visible
Release / Build and publish image (push) Failing after 10m2s
Two more things the database has been recording and the interface has
never shown.

**Who a task is waiting on, on the row.** Only when it is not you: your own
list would otherwise carry your own face on every line, which is a column
of one initial telling you nothing. The point of showing an assignee is
that it is somebody else.

That needed names, and a task carries only an `assignee_id`. `GET /people`
is the primitive: everybody you share a project with, plus yourself,
fetched once per session. Not per row, and not per project either — a list
would have to know which project each task belongs to before it could ask
for that project's members. Deliberately *not* the instance's user list,
which is `/users` and administrators only: a task list has no business
enumerating everybody with an account here.

It sits first in the meta line, because who changes what the rest of the
line means — a date on somebody else's task is their deadline, not yours.

**And the activity log.** Nineteen kinds of entry have been written since
the beginning — every project, section, member, task and comment event —
and `GET /projects/{id}/activity` has answered honestly the whole time with
nothing on the other end. There is a Historik panel on the project now,
loaded when it is opened rather than with the page, and the event keys
become Danish in exactly one place. An unknown key falls through to itself
rather than being hidden: a build that records something this table has not
learned should still show that it happened.

One test assertion had to change, and it is worth saying why. It matched
"males inden fredag" against a row whose title is "males inden" — quick add
read "fredag" as the due date — so it was really matching the title
concatenated with the metadata beside it. Putting the assignee between them
broke it. The assertion now names the task's own text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:31:43 +02:00
Kristian WindandClaude Opus 5 b6ea87ac19 feat(projects): change a member's role from the member list
An invite sent with the wrong role could not be corrected. The only way
back was to remove the person and invite them again — and removing a member
unassigns every task they were responsible for, so fixing a dropdown cost
somebody their work. This is the other side of "an external user cannot
create tasks": the rule was right, the role was wrong, and there was no way
to say so.

PATCH against a membership that already exists, not the upsert AddMember
does. Somebody who is not a member is not silently made one: that is an
invite, it belongs to the invite flow, and doing it here would let a typo
in a user id add a stranger to a project without anybody being asked.

The owner is refused, with the real reason rather than the true but useless
one. Ownership lives on the project rather than in project_members, so the
plain answer would have been "not a member" — which is correct and tells
nobody anything. Ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom.

In the list, the owner's standing is text and everybody else's is a
dropdown. A disabled control would be a thing that exists to say no.

And the roles are finally in Danish. The API's words are `owner`, `editor`
and `viewer`, and they were being printed straight into a Danish page; the
list now uses the same three phrases the invite form beside it already did.

The smoke test drives both browsers, because the interesting part is that
the other one's permissions really change: a viewer is told the project is
read-only and has no quick-add box, the owner promotes them from the list,
and after a reload the box is there and the notice is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:14:33 +02:00
125 changed files with 9583 additions and 1874 deletions
+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}" \
+13 -1
View File
@@ -65,6 +65,9 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | Done, tested |
| Assignee shown on every task that is somebody else's | Done, smoke-tested |
| Activity log, visible per project | Done, smoke-tested |
| Change a member's role without removing them | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
@@ -72,6 +75,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested |
| User administration: invite to the instance, promote, remove | Done, tested |
| Server errors kept in the database, readable after a restart | Done, tested |
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
| Invite and password-reset links that open the right page | Done, smoke-tested |
| Drag to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested |
@@ -113,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
+563 -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]
@@ -856,6 +893,315 @@ 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/errors:
get:
tags: [auth]
summary: What has gone wrong lately. Administrators only.
description: |
Every 500 the API has answered, newest first, with what the handler was
attempting and the error it hit.
These are written to the database rather than only to the log because the
log does not survive. On a Rune the log belongs to the container, and the
container is replaced on every restart — so a panel watcher reports "HTTP
5xx, twice, at 11:49" long after the line explaining it has gone, which
tells an operator that something broke and gives them no way to find out
what.
Kept for thirty days by the nightly sweep: long enough to answer "what
happened last week", short enough that a fault loop cannot fill the volume
the tasks live on.
parameters:
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: object
properties:
id: { type: string }
at: { type: string, format: date-time }
method: { type: string }
path: { type: string }
status: { type: integer }
what:
type: string
description: What the handler was attempting, in its own words.
example: list projects
message: { type: string }
user_name:
type: string
description: |
Who was asking, when there was somebody. A fault only
one account hits is a different problem from one
everybody hits.
request_id:
type: string
description: Ties the row back to the log line, for whoever still has it.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/users:
get:
tags: [auth]
@@ -958,11 +1304,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:
@@ -1046,6 +1393,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.
@@ -1073,6 +1451,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]
@@ -1211,9 +1624,46 @@ paths:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
- { name: userID, in: path, required: true, schema: { type: string } }
patch:
tags: [projects]
summary: Change what an existing member may do. The project's owner only.
description: |
Editor or viewer. An UPDATE against a membership that already exists —
somebody who is not a member is not silently made one, because that is an
invite and belongs to the invite flow.
Not the owner: ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom. The
owner also has no membership row at all — ownership lives on the project —
so this answers 409 with the real reason rather than "not a member".
Correcting a role used to mean removing the person and inviting them
again, which also unassigns every task they were responsible for.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role]
properties:
role: { type: string, enum: [editor, viewer] }
responses:
"204": { description: Changed. }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: The owner's standing cannot be changed.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
description: |
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
responses:
"204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" }
@@ -1315,6 +1765,36 @@ paths:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/people:
get:
tags: [views]
summary: Everybody you share a project with, and yourself.
description: |
What an interface needs to put a name and a face on an `assignee_id` — in
a task row, in a filter, anywhere a person appears beside work. One
request per session rather than a lookup per row, and per project members
would mean knowing which project every task belongs to first.
Deliberately not the instance's user list: that is
`/users`, administrators only. A task list has no business enumerating
everybody with an account here.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
people:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
avatar_color: { type: string }
/api/v1/delegated:
get:
tags: [views]
@@ -2395,6 +2875,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:
@@ -2429,9 +2917,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
@@ -2488,7 +3027,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 }
@@ -2589,6 +3133,12 @@ components:
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 }
@@ -2597,6 +3147,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: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+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, "anden@example.dk", "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, "anden@example.dk", "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(), "kristian@example.dk")
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, "anden@example.dk", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
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, "anden@example.dk", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
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": "anden@example.dk", "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)
}
}
+86 -19
View File
@@ -8,6 +8,8 @@ import (
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
@@ -25,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 {
@@ -88,7 +135,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
if err != nil {
s.internal(w, "create session", err)
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
@@ -130,7 +177,7 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
s.internal(w, "promote session", err)
s.internal(w, r, "promote session", err)
return
}
// The cookie is reissued so its lifetime matches the session's new one.
@@ -210,13 +257,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
s.storeError(w, "update profile", err)
s.storeError(w, r, "update profile", err)
return
}
updated, err := s.db.UserByID(r.Context(), user.ID)
if err != nil {
s.internal(w, "reload user", err)
s.internal(w, r, "reload user", err)
return
}
writeJSON(w, http.StatusOK, toUserJSON(updated))
@@ -263,7 +310,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
@@ -277,14 +324,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
"an account already exists for that email address")
return
}
s.internal(w, "create user", err)
s.internal(w, r, "create user", err)
return
}
// Accepting the invite is what adds the membership and burns the token, in one
// transaction: a signup that half-succeeded would leave a usable invite behind.
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
s.internal(w, "accept invite", err)
s.internal(w, r, "accept invite", err)
return
}
@@ -328,7 +375,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
@@ -341,7 +388,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
return
}
s.internal(w, "create first admin", err)
s.internal(w, r, "create first admin", err)
return
}
@@ -355,7 +402,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
n, err := s.db.UserCount(r.Context())
if err != nil {
s.internal(w, "count users", err)
s.internal(w, r, "count users", err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
@@ -364,7 +411,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
if err != nil {
s.internal(w, "create session", err)
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, s.cfg.SessionTTL)
@@ -393,7 +440,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if err == nil {
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
if err != nil {
s.internal(w, "create password reset", err)
s.internal(w, r, "create password reset", err)
return
}
link := s.cfg.BaseURL + "/reset?token=" + token
@@ -431,13 +478,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
// Every session ends: a password reset is what somebody does when they think
// another person has been in their account.
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
s.internal(w, "update password", err)
s.internal(w, r, "update password", err)
return
}
@@ -469,7 +516,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
keep := ""
@@ -477,7 +524,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
keep = session.ID
}
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
s.internal(w, "update password", err)
s.internal(w, r, "update password", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -520,7 +567,27 @@ func inboxName(locale string) string {
// internal logs the real error and tells the client nothing about it. The detail
// belongs in the log, where the operator can see it; in the response it would be a
// description of the server's internals handed to whoever asked.
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
//
// It is also written to the database. On a Rune the log is the container's, and
// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at
// 11:49" long after the line explaining it has gone, which tells an operator that
// something broke and gives them no way to find out what. The row outlives the
// container; Indstillinger → Fejl is where it is read.
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
s.log.Error(what, "err", err)
userID := ""
if u := userFrom(r.Context()); u != nil {
userID = u.ID
}
// Best effort, and deliberately not checked: this is the path that is already
// reporting a failure, and a diagnostic that can fail the request would turn
// one broken screen into two.
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
}
+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, "anden@example.dk", "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(), "kristian@example.dk")
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)
}
}
}
+6 -6
View File
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil {
s.internal(w, "caldav home", err)
s.internal(w, r, "caldav home", err)
return
}
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Depth") != "0" {
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, "caldav tasks", err)
s.internal(w, r, "caldav tasks", err)
return
}
for _, t := range tasks {
@@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, "caldav report", err)
s.internal(w, r, "caldav report", err)
return
}
byID := map[string]store.Task{}
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
update.DueDatetime = parsed.DueDatetime
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
s.internal(w, "caldav update", err)
s.internal(w, r, "caldav update", err)
return
}
if parsed.Completed && !existing.Completed() {
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
task.Content = "(uden titel)"
}
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
s.internal(w, "caldav create", err)
s.internal(w, r, "caldav create", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.internal(w, "caldav delete", err)
s.internal(w, r, "caldav delete", err)
return
}
w.WriteHeader(http.StatusNoContent)
+49 -23
View File
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
comments, err := s.db.ListComments(r.Context(), taskID)
if err != nil {
s.internal(w, "list comments", err)
s.internal(w, r, "list comments", err)
return
}
out := make([]commentJSON, 0, len(comments))
@@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
if err != nil {
s.internal(w, "list attachments", err)
s.internal(w, r, "list attachments", err)
return
}
direct := make([]attachmentJSON, 0, len(files))
@@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
if err != nil {
s.internal(w, "create comment", err)
s.internal(w, r, "create comment", err)
return
}
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
@@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
if err != nil {
s.storeError(w, "update comment", err)
s.storeError(w, r, "update comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
taskID, err := s.db.CommentTask(r.Context(), commentID)
if err != nil {
s.storeError(w, "comment task", err)
s.storeError(w, r, "comment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
@@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
s.storeError(w, "delete comment", err)
s.storeError(w, r, "delete comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, "store upload", err)
s.internal(w, r, "store upload", err)
return
}
@@ -240,7 +240,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, "record attachment", err)
s.internal(w, r, "record attachment", err)
return
}
@@ -300,23 +300,55 @@ 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, "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
}
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "get attachment", err)
s.storeError(w, r, "get attachment", err)
return
}
@@ -354,22 +386,16 @@ 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, "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
}
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "delete attachment", err)
s.storeError(w, r, "delete attachment", err)
return
}
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"net/http"
"time"
)
// What has gone wrong lately, kept where a restart cannot take it.
//
// The panel's watcher reports "HTTP 5xx, twice, at 11:49" — which is enough to
// know something broke and nothing at all to act on. The line that explains it is
// in the container's log, and a Rune's container is replaced on every restart, so
// by the time anybody looks the explanation is gone. These rows are the same
// information written somewhere that survives.
type serverErrorJSON struct {
ID string `json:"id"`
At string `json:"at"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
// What the handler was doing, in its own words — "list projects", "move task".
What string `json:"what"`
Message string `json:"message"`
// Who was asking, when there was somebody: a fault only one account hits is a
// different problem from one everybody hits.
UserName string `json:"user_name,omitempty"`
// RequestID ties the row back to the log line, for whoever still has it.
RequestID string `json:"request_id,omitempty"`
}
func (s *Server) handleListErrors(w http.ResponseWriter, r *http.Request) {
errs, err := s.db.ListErrors(r.Context(), parseLimit(r.URL.Query().Get("limit"), 100, 500))
if err != nil {
s.internal(w, r, "list errors", err)
return
}
out := make([]serverErrorJSON, 0, len(errs))
for _, e := range errs {
out = append(out, serverErrorJSON{
ID: e.ID, At: e.At.Format(time.RFC3339), Method: e.Method, Path: e.Path,
Status: e.Status, What: e.What, Message: e.Message,
UserName: e.UserName, RequestID: e.RequestID,
})
}
writeJSON(w, http.StatusOK, map[string]any{"errors": out})
}
+13 -13
View File
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
Limit: 2000,
})
if err != nil {
s.internal(w, "ics feed", err)
s.internal(w, r, "ics feed", err)
return
}
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil {
s.internal(w, "ics feed projects", err)
s.internal(w, r, "ics feed projects", err)
return
}
names := map[string]string{}
@@ -89,7 +89,7 @@ func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
if err != nil {
s.internal(w, "ics token", err)
s.internal(w, r, "ics token", err)
return
}
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
@@ -103,11 +103,11 @@ func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, "generate feed token", err)
s.internal(w, r, "generate feed token", err)
return
}
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
s.internal(w, "set feed token", err)
s.internal(w, r, "set feed token", err)
return
}
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
@@ -151,7 +151,7 @@ func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
reminders, err := s.db.ListReminders(r.Context(), taskID)
if err != nil {
s.internal(w, "list reminders", err)
s.internal(w, r, "list reminders", err)
return
}
out := make([]reminderJSON, 0, len(reminders))
@@ -197,7 +197,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
if err != nil {
s.internal(w, "create reminder", err)
s.internal(w, r, "create reminder", err)
return
}
@@ -211,7 +211,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, "delete reminder", err)
s.storeError(w, r, "delete reminder", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list templates", err)
s.internal(w, r, "list templates", err)
return
}
out := make([]templateJSON, 0, len(templates))
@@ -273,7 +273,7 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
if err != nil {
s.storeError(w, "save template", err)
s.storeError(w, r, "save template", err)
return
}
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
@@ -295,7 +295,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
if err != nil {
s.storeError(w, "get template", err)
s.storeError(w, r, "get template", err)
return
}
@@ -311,7 +311,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
if err != nil {
s.internal(w, "create from template", err)
s.internal(w, r, "create from template", err)
return
}
s.activity(r, project.ID, "", "project.created", map[string]any{
@@ -323,7 +323,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
if err != nil {
s.storeError(w, "delete template", err)
s.storeError(w, r, "delete template", err)
return
}
w.WriteHeader(http.StatusNoContent)
+10 -10
View File
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list filters", err)
s.internal(w, r, "list filters", err)
return
}
out := make([]filterJSON, 0, len(filters))
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
f.SortOrder = *req.SortOrder
}
if err := s.db.CreateFilter(r.Context(), f); err != nil {
s.internal(w, "create filter", err)
s.internal(w, r, "create filter", err)
return
}
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
@@ -108,7 +108,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
})
if err != nil {
s.storeError(w, "update filter", err)
s.storeError(w, r, "update filter", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -117,7 +117,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
if err != nil {
s.storeError(w, "delete filter", err)
s.storeError(w, r, "delete filter", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -129,7 +129,7 @@ func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
if err != nil {
s.storeError(w, "get filter", err)
s.storeError(w, r, "get filter", err)
return
}
s.runQuery(w, r, f.Query)
@@ -167,7 +167,7 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string)
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, "run filter", err)
s.internal(w, r, "run filter", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
@@ -210,7 +210,7 @@ type labelJSON struct {
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list labels", err)
s.internal(w, r, "list labels", err)
return
}
out := make([]labelJSON, 0, len(labels))
@@ -242,7 +242,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return
}
s.internal(w, "create label", err)
s.internal(w, r, "create label", err)
return
}
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
@@ -261,7 +261,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return
}
s.storeError(w, "update label", err)
s.storeError(w, r, "update label", err)
return
}
s.publishToOwner(r, "label.changed", nil)
@@ -271,7 +271,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
if err != nil {
s.storeError(w, "delete label", err)
s.storeError(w, r, "delete label", err)
return
}
s.publishToOwner(r, "label.changed", nil)
+135 -10
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,
@@ -37,13 +69,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
pkce, err := gmail.NewPKCE()
if err != nil {
s.internal(w, "gmail pkce", err)
s.internal(w, r, "gmail pkce", err)
return
}
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
settings["pkce_verifier"] = pkce.Verifier
@@ -53,7 +85,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
@@ -83,7 +115,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
verifier, _ := settings["pkce_verifier"].(string)
@@ -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)
@@ -138,7 +170,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
s.internal(w, "save gmail tokens", err)
s.internal(w, r, "save gmail tokens", err)
return
}
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
@@ -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)
}
+8 -8
View File
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
project := todoist.FromRows(rows, name)
result, err := s.importProject(r, user, project)
if err != nil {
s.internal(w, "import project", err)
s.internal(w, r, "import project", err)
return
}
writeJSON(w, http.StatusCreated, result)
@@ -240,7 +240,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
result, err := s.importProject(r, user, project)
if err != nil {
s.internal(w, "import csv", err)
s.internal(w, r, "import csv", err)
return
}
writeJSON(w, http.StatusCreated, result)
@@ -255,12 +255,12 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
exported, err := s.buildExport(r, user, project)
if err != nil {
s.internal(w, "export project", err)
s.internal(w, r, "export project", err)
return
}
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(ctx, user.ID, true)
if err != nil {
s.internal(w, "export projects", err)
s.internal(w, r, "export projects", err)
return
}
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, "export tasks", err)
s.internal(w, r, "export tasks", err)
return
}
for _, t := range tasks {
@@ -445,14 +445,14 @@ func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request)
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, "export ics", err)
s.internal(w, r, "export ics", err)
return
}
+17 -17
View File
@@ -37,7 +37,7 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
if err != nil {
s.internal(w, "mail token", err)
s.internal(w, r, "mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{
@@ -49,11 +49,11 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, "generate mail token", err)
s.internal(w, r, "generate mail token", err)
return
}
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
s.internal(w, "set mail token", err)
s.internal(w, r, "set mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{
@@ -134,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err)
s.internal(w, r, "inbox", err)
return
}
}
@@ -145,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, "create task from mail", err)
s.internal(w, r, "create task from mail", err)
return
}
@@ -187,7 +187,7 @@ type aiSettings struct {
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
// The key is never sent back. A settings page that repopulates a password
@@ -216,7 +216,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
existing, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
// An empty key means "leave it alone", not "delete it" — otherwise saving a
@@ -231,7 +231,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
"model": req.Model, "api_key": key,
}
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
s.internal(w, "save ai settings", err)
s.internal(w, r, "save ai settings", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -266,7 +266,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
}
cfg, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
if !cfg.Configured() {
@@ -278,7 +278,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
@@ -299,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
Priority: task.Priority, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
s.internal(w, "create subtask", err)
s.internal(w, r, "create subtask", err)
return
}
created = append(created, toTaskJSON(*child))
@@ -315,7 +315,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
cfg, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
if !cfg.Configured() {
@@ -326,7 +326,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
if err != nil {
s.internal(w, "list tasks", err)
s.internal(w, r, "list tasks", err)
return
}
if len(tasks) == 0 {
@@ -372,7 +372,7 @@ type gmailSettings struct {
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
if err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
str := func(key string) string {
@@ -396,14 +396,14 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
values["trigger"] = req.Trigger
values["label"] = req.Label
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
s.internal(w, "save gmail settings", err)
s.internal(w, r, "save gmail settings", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -412,7 +412,7 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
// handleDisconnectGmail forgets the connection, tokens and all.
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
s.internal(w, "disconnect gmail", err)
s.internal(w, r, "disconnect gmail", 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://127.0.0.1/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, "anden@example.dk", "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(), "kristian@example.dk")
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)
}
}
+1 -1
View File
@@ -154,7 +154,7 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
user, err := s.db.UserByAPIToken(r.Context(), key)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
s.internal(w, "mcp key", err)
s.internal(w, r, "mcp key", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
+1 -1
View File
@@ -83,7 +83,7 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
// as everybody being logged out at once — the frontend clears its state
// and shows the sign-in screen — and nothing in the logs says why.
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
s.internal(w, "authenticate", err)
s.internal(w, r, "authenticate", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
+6 -6
View File
@@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
if err != nil {
s.internal(w, "list notifications", err)
s.internal(w, r, "list notifications", err)
return
}
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
if err != nil {
s.internal(w, "count notifications", err)
s.internal(w, r, "count notifications", err)
return
}
@@ -50,7 +50,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
if err != nil {
s.internal(w, "mark notifications read", err)
s.internal(w, r, "mark notifications read", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -148,7 +148,7 @@ type pushSubscriptionRequest struct {
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
public, _, err := s.vapidKeys(r.Context())
if err != nil {
s.internal(w, "vapid keys", err)
s.internal(w, r, "vapid keys", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
@@ -172,7 +172,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
UserAgent: r.UserAgent(),
})
if err != nil {
s.internal(w, "save push subscription", err)
s.internal(w, r, "save push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
s.internal(w, "delete push subscription", err)
s.internal(w, r, "delete push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
+153 -16
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,
}
}
@@ -34,7 +37,7 @@ func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list project groups", err)
s.internal(w, r, "list project groups", err)
return
}
out := make([]projectGroupJSON, 0, len(groups))
@@ -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,11 +77,14 @@ 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
}
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
s.internal(w, "create project group", err)
s.internal(w, r, "create project group", err)
return
}
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
@@ -105,28 +112,158 @@ 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, "update project group", err)
s.storeError(w, r, "update project group", err)
return
}
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
if err != nil {
s.storeError(w, "get project group", err)
s.storeError(w, r, "get project group", err)
return
}
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
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.
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
s.storeError(w, "delete project group", err)
s.storeError(w, r, "delete project group", err)
return
}
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
@@ -145,7 +282,7 @@ func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Reque
return
}
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
s.internal(w, "reorder project groups", err)
s.internal(w, r, "reorder project groups", err)
return
}
w.WriteHeader(http.StatusNoContent)
+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, "anden@example.dk", "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)
}
}
}
+83 -24
View File
@@ -45,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
if err != nil {
s.internal(w, "list projects", err)
s.internal(w, r, "list projects", err)
return
}
out := make([]projectJSON, 0, len(projects))
@@ -59,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
writeJSON(w, http.StatusOK, toProjectJSON(*p))
@@ -105,7 +105,7 @@ func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
s.internal(w, "reorder projects", err)
s.internal(w, r, "reorder projects", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -138,9 +138,21 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
p.ViewMode = *req.ViewMode
}
if err := s.db.CreateProject(r.Context(), p); err != nil {
s.internal(w, "create project", err)
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))
@@ -174,7 +186,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
ViewMode: req.ViewMode, Archived: req.Archived,
})
if err != nil {
s.storeError(w, "update project", err)
s.storeError(w, r, "update project", err)
return
}
// Grouping is written separately because it is scoped differently: the fields
@@ -184,7 +196,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
if req.GroupID != nil {
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
userFrom(r.Context()).ID); err != nil {
s.storeError(w, "set project group", err)
s.storeError(w, r, "set project group", err)
return
}
}
@@ -192,7 +204,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
@@ -206,7 +218,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
return
}
s.storeError(w, "delete project", err)
s.storeError(w, r, "delete project", err)
return
}
s.activity(r, projectID, "", "project.deleted", nil)
@@ -230,7 +242,7 @@ type trashedProjectJSON struct {
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list trashed projects", err)
s.internal(w, r, "list trashed projects", err)
return
}
@@ -252,7 +264,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
// deliberately invisible to it. Ownership is what governs restoring.
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, "project owner", err)
s.storeError(w, r, "project owner", err)
return
}
if owner != userFrom(r.Context()).ID {
@@ -260,7 +272,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
s.storeError(w, "restore project", err)
s.storeError(w, r, "restore project", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -306,7 +318,7 @@ type sectionJSON struct {
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
s.internal(w, "list sections", err)
s.internal(w, r, "list sections", err)
return
}
out := make([]sectionJSON, 0, len(sections))
@@ -336,7 +348,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
sec.SortOrder = *req.SortOrder
}
if err := s.db.CreateSection(r.Context(), sec); err != nil {
s.internal(w, "create section", err)
s.internal(w, r, "create section", err)
return
}
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
@@ -350,7 +362,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
sectionID := chi.URLParam(r, "sectionID")
projectID, err := s.db.SectionProject(r.Context(), sectionID)
if err != nil {
s.storeError(w, "section project", err)
s.storeError(w, r, "section project", err)
return "", "", false
}
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
@@ -381,7 +393,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
Name: req.Name, SortOrder: req.SortOrder,
}); err != nil {
s.storeError(w, "update section", err)
s.storeError(w, r, "update section", err)
return
}
s.activity(r, projectID, "", "section.updated", nil)
@@ -394,7 +406,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
s.storeError(w, "delete section", err)
s.storeError(w, r, "delete section", err)
return
}
s.activity(r, projectID, "", "section.deleted", nil)
@@ -414,7 +426,7 @@ type memberJSON struct {
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
s.internal(w, "list members", err)
s.internal(w, r, "list members", err)
return
}
out := make([]memberJSON, 0, len(members))
@@ -463,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
@@ -475,7 +487,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
s.internal(w, "add member", err)
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
@@ -485,7 +497,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, "create invite", err)
s.internal(w, r, "create invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
@@ -502,13 +514,60 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
}
type memberRoleRequest struct {
Role string `json:"role"`
}
// handleSetMemberRole changes an existing member between editor and viewer.
//
// Until now the only way to correct an invite sent with the wrong role was to
// remove the person and invite them again — which also unassigns everything they
// were responsible for, because removing a member clears their tasks' assignee.
// Fixing a dropdown should not cost somebody their work.
func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
var req memberRoleRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
role := store.Role(req.Role)
if !role.Valid() || role == store.RoleOwner {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
// The owner has no project_members row at all — ownership lives on the project
// — so this would report "not a member", which is true and completely
// unhelpful. Say the real reason instead.
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, r, "project owner", err)
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner's standing cannot be changed; ownership is transferred, not granted")
return
}
if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil {
s.storeError(w, r, "set member role", err)
return
}
s.activity(r, projectID, "", "member.role_changed",
map[string]any{"user_id": userID, "role": string(role)})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, "project owner", err)
s.storeError(w, r, "project owner", err)
return
}
if userID == owner {
@@ -517,7 +576,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
s.storeError(w, "remove member", err)
s.storeError(w, r, "remove member", err)
return
}
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
@@ -529,13 +588,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
// to learn.
func (s *Server) storeError(w http.ResponseWriter, what string, err error) {
func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) {
switch {
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
case errors.Is(err, store.ErrEmailInUse):
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
default:
s.internal(w, what, err)
s.internal(w, r, what, err)
}
}
+171
View File
@@ -194,3 +194,174 @@ func TestListMembersWorksForTheInbox(t *testing.T) {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
}
// A role sent by mistake used to be uncorrectable: the only way back was to remove
// the person and invite them again, which unassigns every task they were
// responsible for. Fixing a dropdown should not cost somebody their work.
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "anden@example.dk", "Anden")
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anden@example.dk", "role": "viewer",
})
// A viewer cannot write. That is the rule, not the bug.
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
map[string]any{"content": "prøv", "project_id": projectID}); resp.StatusCode != http.StatusNotFound {
t.Fatalf("a viewer created a task: status %d", resp.StatusCode)
}
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "editor"})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("promote to editor: status %d", resp.StatusCode)
}
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
map[string]any{"content": "nu kan jeg", "project_id": projectID}); resp.StatusCode != http.StatusCreated {
t.Errorf("an editor could not create a task: status %d", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
for _, raw := range members["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == memberUser.ID && m["role"] != "editor" {
t.Errorf("role = %v after the change, want editor", m["role"])
}
}
}
// The owner is not a member row, and ownership is not a role you can hand out.
func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "anden@example.dk", "Anden")
owner, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anden@example.dk", "role": "editor",
})
// Demoting the owner would leave a project nobody can administer.
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+owner.ID,
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("changing the owner's role: status %d, want 409", resp.StatusCode)
}
// And no member may promote themselves to owner.
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "owner"}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("granting ownership: status %d, want 422", resp.StatusCode)
}
// Nor may an editor change anybody's role: this is the owner's to do.
if resp, _ := member.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("an editor changed a role: status %d, want 404", resp.StatusCode)
}
// Somebody with no membership at all is not quietly given one.
stranger := ts.newUser(t, "tredje@example.dk", "Tredje")
_ = stranger
strangerUser, _ := ts.db.UserByEmail(t.Context(), "tredje@example.dk")
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+strangerUser.ID,
map[string]any{"role": "editor"}); resp.StatusCode != http.StatusNotFound {
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)
}
}
+28
View File
@@ -144,6 +144,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)
@@ -236,6 +237,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) {
@@ -264,6 +276,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
r.Get("/people", s.handlePeople)
r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) {
@@ -292,6 +305,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
@@ -303,6 +317,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// delete somebody else's.
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
// 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)
@@ -318,8 +344,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
+2 -2
View File
@@ -41,7 +41,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := s.db.ListSessions(r.Context(), user.ID)
if err != nil {
s.internal(w, "list sessions", err)
s.internal(w, r, "list sessions", err)
return
}
@@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "sessionID")
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
s.storeError(w, "delete session", err)
s.storeError(w, r, "delete session", err)
return
}
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
+30 -23
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,
}
@@ -99,7 +106,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
if err != nil {
s.internal(w, "list tasks", err)
s.internal(w, r, "list tasks", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
@@ -116,7 +123,7 @@ func toTaskList(tasks []store.Task) []taskJSON {
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
writeJSON(w, http.StatusOK, toTaskJSON(*t))
@@ -162,7 +169,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
if projectID == "" {
var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err)
s.internal(w, r, "inbox", err)
return
}
}
@@ -226,7 +233,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
s.internal(w, "create task", err)
s.internal(w, r, "create task", err)
return
}
// The store wrote the labels; the struct it was handed does not know that, and
@@ -302,13 +309,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
s.storeError(w, "update task", err)
s.storeError(w, r, "update task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
@@ -327,13 +334,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
}
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "complete task", err)
s.storeError(w, r, "complete task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
@@ -369,13 +376,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
s.storeError(w, "reopen task", err)
s.storeError(w, r, "reopen task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
@@ -394,11 +401,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.storeError(w, "delete task", err)
s.storeError(w, r, "delete task", err)
return
}
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
@@ -427,7 +434,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
@@ -449,12 +456,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
}
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
s.storeError(w, "move task", err)
s.storeError(w, r, "move task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
@@ -502,7 +509,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
if projectID == "" {
var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err)
s.internal(w, r, "inbox", err)
return
}
}
@@ -523,7 +530,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
s.internal(w, "create task", err)
s.internal(w, r, "create task", err)
return
}
t.Labels = parsed.Labels
+3 -3
View File
@@ -36,7 +36,7 @@ type createdTokenJSON struct {
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list api tokens", err)
s.internal(w, r, "list api tokens", err)
return
}
@@ -96,7 +96,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
if err != nil {
s.internal(w, "create api token", err)
s.internal(w, r, "create api token", err)
return
}
@@ -117,7 +117,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
if err != nil {
s.storeError(w, "delete api token", err)
s.storeError(w, r, "delete api token", err)
return
}
w.WriteHeader(http.StatusNoContent)
+9 -9
View File
@@ -29,12 +29,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
if err != nil {
s.internal(w, "generate totp secret", err)
s.internal(w, r, "generate totp secret", err)
return
}
// Stored but not enabled: confirm decides that.
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
s.internal(w, "store totp secret", err)
s.internal(w, r, "store totp secret", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
@@ -63,15 +63,15 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
codes, hashes, err := auth.NewRecoveryCodes()
if err != nil {
s.internal(w, "generate recovery codes", err)
s.internal(w, r, "generate recovery codes", err)
return
}
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
s.internal(w, "store recovery codes", err)
s.internal(w, r, "store recovery codes", err)
return
}
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
s.internal(w, "enable totp", err)
s.internal(w, r, "enable totp", err)
return
}
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
s.internal(w, "disable totp", err)
s.internal(w, r, "disable totp", err)
return
}
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
@@ -127,11 +127,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
codes, hashes, err := auth.NewRecoveryCodes()
if err != nil {
s.internal(w, "generate recovery codes", err)
s.internal(w, r, "generate recovery codes", err)
return
}
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
s.internal(w, "store recovery codes", err)
s.internal(w, r, "store recovery codes", err)
return
}
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
@@ -140,7 +140,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "count recovery codes", err)
s.internal(w, r, "count recovery codes", err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
+16 -13
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 {
@@ -54,12 +56,12 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.db.ListUsers(r.Context())
if err != nil {
s.internal(w, "list users", err)
s.internal(w, r, "list users", err)
return
}
invites, err := s.db.ListPendingInvites(r.Context())
if err != nil {
s.internal(w, "list pending invites", err)
s.internal(w, r, "list pending invites", err)
return
}
@@ -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".
@@ -128,7 +131,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
inviter.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, "create instance invite", err)
s.internal(w, r, "create instance invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
@@ -170,7 +173,7 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
s.storeError(w, "set user admin", err)
s.storeError(w, r, "set user admin", err)
return
}
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
@@ -197,7 +200,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
s.storeError(w, "delete user", err)
s.storeError(w, r, "delete user", err)
return
}
s.log.Info("user deleted", "by", me.ID, "user", userID)
@@ -206,7 +209,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, "delete invite", err)
s.storeError(w, r, "delete invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -218,7 +221,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
target, err := s.db.UserByID(r.Context(), userID)
if err != nil {
s.storeError(w, "get user", err)
s.storeError(w, r, "get user", err)
return err
}
if !target.IsAdmin {
@@ -226,7 +229,7 @@ func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userI
}
admins, err := s.db.CountAdmins(r.Context())
if err != nil {
s.internal(w, "count admins", err)
s.internal(w, r, "count admins", err)
return err
}
if admins <= 1 {
+113 -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))
}
}
}
@@ -277,3 +313,70 @@ func indexAfter(s, needle string) int {
}
return 0
}
// A 500 has to leave something behind that a restart cannot take.
//
// The panel's watcher already reports that one happened. What it cannot report is
// what it was: that lives in the container's log, and a Rune replaces the
// container on every restart, so the explanation is usually gone before anybody
// looks.
func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Nothing has gone wrong yet.
_, empty := ts.do(t, "GET", "/api/v1/errors", nil)
if rows, _ := empty["errors"].([]any); len(rows) != 0 {
t.Fatalf("a fresh instance has errors already: %v", rows)
}
// Break something the handler cannot survive: the table it reads is gone, so
// the query fails and the handler answers 500. Dropping it is a blunt way to
// provoke that, and a faithful one — it is the same code path a disk error or
// a corrupt page would take.
if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil {
t.Fatal(err)
}
resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/errors", nil)
rows, _ := body["errors"].([]any)
if len(rows) != 1 {
t.Fatalf("want one recorded error, got %v", rows)
}
row := rows[0].(map[string]any)
if row["what"] != "list project groups" {
t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"])
}
if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" {
t.Errorf("path/method = %v %v", row["method"], row["path"])
}
if row["status"] != float64(500) {
t.Errorf("status = %v", row["status"])
}
if row["message"] == "" {
t.Error("no message: the row says something broke and not what")
}
if row["user_name"] != "Kristian" {
t.Errorf("user_name = %v — a fault only one account hits is a different problem",
row["user_name"])
}
if row["request_id"] == "" {
t.Error("no request id, so the row cannot be tied back to the log line")
}
}
// The list is an administrator's: it carries paths, accounts and the server's own
// error strings.
func TestTheErrorLogIsAdminsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want 403", resp.StatusCode)
}
}
+32 -7
View File
@@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
Limit: 500,
})
if err != nil {
s.internal(w, "today", err)
s.internal(w, r, "today", err)
return
}
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
Limit: 1000,
})
if err != nil {
s.internal(w, "upcoming", err)
s.internal(w, r, "upcoming", err)
return
}
@@ -92,6 +92,31 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": out})
}
// handlePeople lists everybody the caller shares a project with, so an interface
// can put a name and a face on an assignee id.
//
// One request per session rather than a lookup per row, and not the instance's
// user list — that is the administrator's page. A task list has no business
// enumerating everybody with an account here.
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list people", err)
return
}
out := make([]personJSON, 0, len(people))
for _, p := range people {
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
type personJSON struct {
ID string `json:"id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
}
type delegatedPerson struct {
UserID string `json:"user_id"`
Name string `json:"name"`
@@ -117,7 +142,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, "delegated", err)
s.internal(w, r, "delegated", err)
return
}
@@ -132,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
people, err := s.db.PeopleByIDs(r.Context(), ids)
if err != nil {
s.internal(w, "delegated people", err)
s.internal(w, r, "delegated people", err)
return
}
@@ -184,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
if err != nil {
s.internal(w, "search tasks", err)
s.internal(w, r, "search tasks", err)
return
}
@@ -192,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// second FTS index for that would be more machinery than the problem needs.
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil {
s.internal(w, "search projects", err)
s.internal(w, r, "search projects", err)
return
}
matched := []projectJSON{}
@@ -214,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
parseLimit(r.URL.Query().Get("limit"), 50, 200))
if err != nil {
s.internal(w, "list activity", err)
s.internal(w, r, "list activity", err)
return
}
+47
View File
@@ -153,3 +153,50 @@ func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
t.Errorf("a task in a project they cannot see: %v", people)
}
}
// /people is what puts a name on an assignee id. It must be the people you share
// work with — not the instance's user list, which is the administrator's page.
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
colleague := ts.newUser(t, "anders@example.dk", "Anders")
ts.newUser(t, "fremmed@example.dk", "Fremmed")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites", map[string]any{
"email": "anders@example.dk", "role": "editor",
})
_, body := ts.do(t, "GET", "/api/v1/people", nil)
names := map[string]bool{}
for _, raw := range body["people"].([]any) {
p := raw.(map[string]any)
names[p["name"].(string)] = true
if p["avatar_color"] == "" {
t.Errorf("no avatar colour for %v — the row cannot draw a face", p["name"])
}
}
if !names["Kristian"] {
t.Error("yourself is missing, so a client cannot tell 'me' from 'somebody else' against one list")
}
if !names["Anders"] {
t.Error("somebody sharing a project is missing")
}
if names["Fremmed"] {
t.Error("an account with no project in common is listed — this is not the user directory")
}
// And it is symmetric: the person invited sees the person who invited them.
_, theirs := colleague.do(t, "GET", "/api/v1/people", nil)
seen := map[string]bool{}
for _, raw := range theirs["people"].([]any) {
seen[raw.(map[string]any)["name"].(string)] = true
}
if !seen["Kristian"] || !seen["Anders"] {
t.Errorf("the member cannot see who they share with: %v", seen)
}
if seen["Fremmed"] {
t.Errorf("a stranger is listed: %v", seen)
}
}
+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
}
+55 -36
View File
@@ -55,6 +55,7 @@ func (r *Runner) Start(ctx context.Context) {
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
r.every(ctx, "trash", time.Hour, r.emptyTrash)
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
// Ten minutes is a compromise: Gmail's push notifications would be instant but
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
// account for a to-do app. Ten minutes is fast enough for something somebody
@@ -161,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)
}
@@ -185,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.
@@ -199,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
}
@@ -215,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
}
@@ -265,6 +285,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error {
return nil
}
// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer
// "what happened last week" and short enough that a fault loop cannot fill the
// volume the tasks live on.
func (r *Runner) purgeErrors(ctx context.Context) error {
n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour)
if err != nil {
return err
}
if n > 0 {
r.log.Debug("old error rows removed", "count", n)
}
return nil
}
func (r *Runner) purgeSessions(ctx context.Context) error {
n, err := r.db.PurgeExpiredSessions(ctx)
if err != nil {
@@ -276,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)
}
+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
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"context"
"time"
)
// A ServerError is one 500 the API answered, kept so it can be read after the
// container that produced it has gone.
type ServerError struct {
ID string
At time.Time
Method string
Path string
Status int
What string
Message string
UserID string
UserName string
RequestID string
}
// RecordError stores one failure.
//
// Errors here are dropped rather than returned: this is called from the path that
// is already reporting a failure to somebody, and a diagnostic that can itself
// fail the request would turn one broken screen into two.
func (db *DB) RecordError(ctx context.Context, e ServerError) {
var user any
if e.UserID != "" {
user = e.UserID
}
_, _ = db.ExecContext(ctx,
`INSERT INTO error_log (id, at, method, path, status, what, message, user_id, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
NewID(), time.Now().Unix(), e.Method, truncate(e.Path, 500), e.Status,
truncate(e.What, 200), truncate(e.Message, 2000), user, truncate(e.RequestID, 100))
}
// ListErrors returns the most recent failures, newest first.
func (db *DB) ListErrors(ctx context.Context, limit int) ([]ServerError, error) {
rows, err := db.QueryContext(ctx, `
SELECT e.id, e.at, e.method, e.path, e.status, e.what, e.message,
COALESCE(e.user_id, ''), COALESCE(u.name, ''), e.request_id
FROM error_log e
LEFT JOIN users u ON u.id = e.user_id
ORDER BY e.at DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ServerError{}
for rows.Next() {
var e ServerError
var at int64
if err := rows.Scan(&e.ID, &at, &e.Method, &e.Path, &e.Status, &e.What,
&e.Message, &e.UserID, &e.UserName, &e.RequestID); err != nil {
return nil, err
}
e.At = time.Unix(at, 0).UTC()
out = append(out, e)
}
return out, rows.Err()
}
// PurgeOldErrors is run by the nightly sweep. A diagnostic that grows without
// limit is a disk that fills for a reason nobody expected.
func (db *DB) PurgeOldErrors(ctx context.Context, keep time.Duration) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM error_log WHERE at < ?`, time.Now().Add(-keep).Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
@@ -0,0 +1,27 @@
-- Server errors, kept where they can outlive the container that produced them.
--
-- verdande already logs the real cause of every 500 to stdout. On a Rune that is
-- the container's log, and every restart starts a new one — so the panel can
-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone.
-- An operator is then told that something broke and given no way to find out what.
--
-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that
-- grows without limit is a disk that fills for a reason nobody expected.
CREATE TABLE error_log (
id TEXT PRIMARY KEY,
at INTEGER NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL,
-- what: the operation the handler was attempting, in the words it uses in the
-- log — "list projects", "move task". The detail a stack trace would give and
-- a status code cannot.
what TEXT NOT NULL,
message TEXT NOT NULL,
-- Whose request it was, so a fault that only one account hits is visible as
-- one. NULL for anything that failed before authentication.
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
request_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_error_log_at ON error_log (at DESC);
@@ -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;
+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
}
+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()
}
+27
View File
@@ -389,6 +389,33 @@ func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role
return err
}
// SetMemberRole changes what an existing member may do.
//
// An UPDATE rather than the upsert AddMember does, and the difference is the
// point: this is a PATCH against a membership that exists. Somebody who is not a
// member is not silently made one — that is an invite, it belongs to the invite
// flow, and doing it here would let a typo in a user id add a stranger to a
// project without anybody being asked.
//
// Owner is refused for the same reason AddMember refuses it: ownership is
// transferred, not granted, and two owners is a state with no way to settle a
// disagreement about who may remove whom.
func (db *DB) SetMemberRole(ctx context.Context, projectID, userID string, role Role) error {
if !role.Valid() || role == RoleOwner {
return errors.New("store: members may be editor or viewer")
}
res, err := db.ExecContext(ctx,
`UPDATE project_members SET role = ? WHERE project_id = ? AND user_id = ?`,
string(role), projectID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
// Anything assigned to them is unassigned rather than left pointing at
+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)
}
}
+47 -13
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,7 +185,7 @@ 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 ") + `
@@ -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,29 @@ 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)`
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 +359,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 +418,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
+112 -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 {
@@ -274,6 +318,50 @@ func (db *DB) DeleteUser(ctx context.Context, userID string) error {
return nil
}
// ListPeople returns everybody the caller shares at least one project with, plus
// the caller.
//
// The set the interface needs to put a name on an `assignee_id` — in a task row,
// in a filter, anywhere a person appears next to work. Fetched once rather than
// looked up per row, and deliberately *not* the instance's user list: that is the
// administrator's page, and a task list has no business enumerating everybody with
// an account here.
//
// Themselves included, because a client comparing "is this me" against the same
// list it draws from has one source rather than two.
func (db *DB) ListPeople(ctx context.Context, userID string) ([]Person, error) {
rows, err := db.QueryContext(ctx, `
SELECT DISTINCT u.id, u.name, u.avatar_color
FROM users u
WHERE u.id = ?
-- the owners of projects the caller is a member of
OR u.id IN (SELECT p.owner_id FROM projects p
JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ? AND p.deleted_at IS NULL)
-- and the members of every project the caller can see
OR u.id IN (SELECT m2.user_id FROM project_members m2
JOIN projects p2 ON p2.id = m2.project_id
WHERE p2.deleted_at IS NULL
AND (p2.owner_id = ?
OR EXISTS (SELECT 1 FROM project_members m3
WHERE m3.project_id = p2.id AND m3.user_id = ?)))
ORDER BY u.name`, userID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
people := []Person{}
for rows.Next() {
var p Person
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
return nil, err
}
people = append(people, p)
}
return people, rows.Err()
}
// UserCount is what decides whether the instance still needs its first admin.
func (db *DB) UserCount(ctx context.Context) (int, error) {
var n int
+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: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+687 -20
View File
@@ -153,9 +153,11 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
'Integrationer',
'AI',
'API-tokens',
// Only an administrator sees this one, and the account these tests run as
// Only an administrator sees these two, and the account these tests run as
// is the first account, which is one.
'Brugere',
'Historik',
'Fejl',
'Data og skabeloner'
]) {
await tabs.getByRole('link', { name: label, exact: true }).click();
@@ -183,6 +185,12 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
}
// Safari never reads the manifest's icons; it looks for this by name, and
// without it an installed PWA on iOS gets a screenshot of the page as its
// home-screen icon.
const appleIcon = await request.get('/apple-touch-icon.png');
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
// The service worker is what Web Push needs; without it a subscription cannot
// be created at all.
const worker = await request.get('/sw.js');
@@ -256,10 +264,15 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
await sidebar.getByLabel('Gruppens navn').press('Enter');
// `exact`, because the heading's colour dot is a button too and its label is
// "Farve på Arbejde" — and Playwright matches an accessible name by substring
// unless told otherwise.
const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true });
// The name is a link to the group's own page; the chevron beside it is a
// separate button that folds. Two intentions, two targets — the single one they
// used to share could only ever serve the smaller.
//
// `exact` on the link, because a group's name is a user-chosen word that can
// turn up inside another control's name, and Playwright matches an accessible
// name by substring unless told otherwise.
const heading = sidebar.getByRole('link', { name: 'Arbejde', exact: true });
const fold = sidebar.getByRole('button', { name: /^Fold Arbejde/ });
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
@@ -270,7 +283,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
// Both buttons still worked, so every assertion in this file passed.
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
[...el.querySelectorAll('button')].map((b) => {
// Links as well as buttons: the group's name is a link to its page now, and
// a measurement that skipped it would miss the widest thing in the row.
[...el.querySelectorAll('button, a')].map((b) => {
const r = b.getBoundingClientRect();
return { text: b.textContent.trim(), left: r.left, right: r.right };
})
@@ -295,25 +310,43 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Folded, reloaded, still folded. localStorage would have passed the first
// half of that and failed the second on another machine.
await heading.click();
await expect(heading).toHaveAttribute('aria-expanded', 'false');
await fold.click();
await expect(fold).toHaveAttribute('aria-expanded', 'false');
await page.reload();
await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute(
await expect(sidebar.getByRole('button', { name: /^Fold Arbejde/ })).toHaveAttribute(
'aria-expanded',
'false'
);
await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click();
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
// Colour, and indentation. The dot is the control as well as the preview, so
// the thing you press is the thing you are about to change.
await sidebar.getByRole('button', { name: 'Farve på Arbejde' }).click();
// Colour, which is chosen while renaming rather than from the row. Both are
// edits to the same thing, and a heading carrying four controls crowded the
// name it exists to show.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
await sidebar.getByRole('button', { name: 'Petrol' }).click();
// Still open: picking a colour must not close the form under the pointer, which
// is what a plain blur-to-cancel does in the two browsers that do not focus a
// button on click.
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
await sidebar.getByLabel('Gruppens navn').press('Enter');
const painted = await sidebar.locator('.folder-head .group-dot').evaluate((el) =>
getComputedStyle(el).backgroundColor
);
const painted = await sidebar
.locator('.folder-head .group-dot')
.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
// The heading itself is *not* indented — only what is filed under it. A heading
// that starts further in than the rows above it says the heading is inside
// something, which it is not.
const headLeft = await sidebar
.locator('.folder-head')
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
const looseLeft = await sidebar
.locator('.views a')
.first()
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
// A project inside a group starts further in than a row outside one. Measured
// rather than asserted on a class, because indentation is a fact about where
// the row's contents land — and measured at the *content* edge, not the box:
@@ -337,14 +370,14 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeVisible();
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeVisible();
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
expect(trouble).toEqual([]);
@@ -373,6 +406,11 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
// Waited for. Creating a project navigates, and `goto` is a promise nobody here
// is holding — quick add fired before it landed puts the task in the Inbox with
// no date, where the Today view is right not to show it. Intermittent, and it
// reads as the quick add having failed.
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
await page.getByLabel('Ny opgave').fill('brænde kaffe');
await page.getByLabel('Ny opgave').press('Enter');
@@ -425,6 +463,8 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
await sidebar.getByLabel('Projektnavn').press('Enter');
// Waited for before navigating away, or the create can lose its own redirect.
await expect(sidebar.getByRole('link', { name: 'Ærinder' })).toBeVisible();
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Liste', exact: true }).click();
@@ -438,6 +478,14 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
// Onto "I dag" in the sidebar: the most-made rescheduling there is, and the
// sidebar is where the pointer already is. "Kommende" deliberately does not
// take one — it is a range, not a day, so a drop would have to invent a date
// its label does not name.
await row.dragTo(sidebar.getByRole('link', { name: 'I dag' }));
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
await row.dragTo(day('I morgen'));
// And onto a project, which is a move rather than a field: the task has to
// find a place among that project's tasks, and leave its old section behind.
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
@@ -450,7 +498,7 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
// browser is pinned to Europe/Copenhagen and this process is not, and for two
// hours of every day they disagree about what today is.
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Kalender' }).click();
await page.getByRole('button', { name: 'Måned', exact: true }).click();
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
await expect(chip).toBeVisible();
@@ -570,7 +618,10 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await sidebar.getByRole('link', { name: 'Venter på andre' }).click();
await expect(page.getByRole('heading', { name: 'Venter på andre' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nabo' })).toBeVisible();
await expect(page.getByText('males inden fredag')).toBeVisible();
// The task's own text, not the whole row's: quick add read "fredag" as the due
// date, so the title is "males inden" and the rest of that line is metadata —
// which now has the assignee in it too.
await expect(page.getByText('males inden', { exact: true })).toBeVisible();
expect(trouble).toEqual([]);
});
@@ -705,6 +756,622 @@ test('den sidste administrator kan hverken fjernes eller slette sig selv', async
await expect(mine.getByRole('button')).toHaveCount(0);
});
/**
* A member's role can be corrected without removing them.
*
* This is what a viewer who cannot add tasks looks like from the other side. The
* rule itself is right — a viewer may not write — but until now the only way to
* fix an invite sent with the wrong role was to remove the person and invite them
* again, which unassigns every task they were responsible for on the way past.
*
* Driven end to end because the interesting part is that the *other* browser's
* permissions actually change: the server decides, and the page has to agree.
*/
test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Fælles');
await sidebar.getByLabel('Projektnavn').press('Enter');
// Waited for, like everywhere else here: creating a project navigates, and the
// controls below only exist once it has.
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
await page.getByRole('button', { name: 'Del' }).click();
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
await page.getByLabel('Rolle').selectOption('viewer');
await page.getByRole('button', { name: 'Inviter' }).click();
const link = await page.locator('.link-out code').textContent();
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
await andreas.getByLabel('Navn').fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await andreas
.getByRole('navigation', { name: 'Hovedmenu' })
.getByRole('link', { name: /Fælles/ })
.click();
// A viewer sees the project and is told, plainly, that it is read-only.
await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible();
await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0);
// The owner promotes them from the member list. The owner's own row is text,
// not a dropdown: ownership is transferred, not granted.
await page.reload();
await page.getByRole('button', { name: /Delt/ }).click();
await expect(page.getByText('Ejer')).toBeVisible();
await page.getByLabel('Rolle for andreas').selectOption('editor');
await andreas.reload();
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
// Now that they can edit, hand them something — and the row says whose it is.
// Only when it is somebody else's: your own list would otherwise carry your own
// face on every line, which is a column of one initial telling you nothing.
await page.getByLabel('Ny opgave').fill('brænde kaffe');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('brænde kaffe', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
await page.keyboard.press('Escape');
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
// And not on a task that is nobody's.
await page.getByLabel('Ny opgave').fill('min egen');
await page.getByLabel('Ny opgave').press('Enter');
await expect(
page.locator('.row').filter({ hasText: 'min egen' }).locator('.assignee')
).toHaveCount(0);
// The log has recorded all of this since the beginning and nothing showed it.
await page.getByRole('button', { name: 'Historik' }).click();
await expect(page.getByText('oprettede projektet')).toBeVisible();
await expect(page.getByText('ændrede rollen for')).toBeVisible();
await context.close();
expect(trouble).toEqual([]);
});
/**
* A task dragged across a month boundary.
*
* The month grid could not do this. It is anchored to a month, so the two days on
* either side of its edge sit in different grids, and getting from one to the other
* means paging — which cannot be done mid-drag, because a drag in flight swallows
* the click that would page it. The week view exists for exactly this: a week that
* straddles the 31st and the 1st has both days in the same row.
*
* The dates are worked out in the browser, not here. Playwright pins the page to
* Europe/Copenhagen and this process is not pinned to anything, so for two hours of
* every day the two disagree about what today is.
*/
test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// The first day, starting tomorrow, whose next day is in another month *and* in
// the same Monday-to-Sunday week. Such a pair exists at every month boundary
// except one that falls between a Sunday and a Monday, so searching forward
// finds one within about two months whatever today is.
const { from, to } = await page.evaluate(() => {
const iso = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
const monday = (d) => {
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
return iso(m);
};
const day = new Date();
for (let i = 0; i < 90; i++) {
day.setDate(day.getDate() + 1);
const next = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
if (day.getMonth() !== next.getMonth() && monday(day) === monday(next)) {
return { from: iso(day), to: iso(next) };
}
}
throw new Error('no month boundary inside a week in the next 90 days');
});
const box = page.getByLabel('Ny opgave');
await box.fill(`skifte dæk ${from}`);
await box.press('Enter');
// Not asserted here: this is the Today view and the task is dated weeks out, so
// it is correctly absent. The week grid below is where it has to turn up.
await expect(box).toHaveValue('');
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
// Forward a week at a time until the row is the one holding that date. Bounded,
// because a loop that pages forever on a broken button is a timeout with no
// explanation in it.
const cell = page.locator(`[data-date="${from}"]`);
const next = page.getByRole('button', { name: 'Næste uge' });
for (let i = 0; i < 15 && (await cell.count()) === 0; i++) {
await next.click();
}
await expect(cell, `uge-visningen nåede aldrig frem til ${from}`).toBeVisible();
// The claim the view is for: both sides of the month boundary, in one row.
await expect(page.locator(`[data-date="${to}"]`)).toBeVisible();
expect(from.slice(0, 7), 'de to dage skulle ligge i hver sin måned').not.toEqual(to.slice(0, 7));
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
await expect(chip).toBeVisible();
await chip.dragTo(page.locator(`[data-date="${to}"]`));
await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible();
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* The instance-wide history.
*
* The per-project panel has shown the same rows for a while; this is the question
* it cannot answer, which is what happened in a project the administrator is not a
* member of. The test uses a second person's own project for exactly that reason —
* asserting against a project the admin can already see would pass with the old
* per-project endpoint behind it.
*/
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
browser,
page
}) => {
const trouble = watchForTrouble(page);
// A second account, with a project of its own. storageState is spelled out:
// a "fresh" context inherits the signed-in one otherwise, which is the single
// case this test is not about.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const theirs = await context.newPage();
const email = `historik-${Date.now()}@example.dk`;
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill(email);
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
expect(link).toContain('/invite?token=');
await theirs.goto(link);
await theirs.getByLabel('Navn').fill('Sigrid');
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await theirs.getByRole('button', { name: 'Opret konto' }).click();
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
const sidebar = theirs.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Sigrids eget');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Sigrids eget' })).toBeVisible();
// The administrator cannot open that project, and can still see that it happened.
await page.goto('/indstillinger/historik');
const rows = page.locator('.rows li').filter({ hasText: 'Sigrids eget' });
await expect(rows.first()).toBeVisible();
await expect(rows.first().getByText('oprettede projektet')).toBeVisible();
// `exact`, or this matches the project name "Sigrids eget" in the same row.
await expect(rows.first().getByText('Sigrid', { exact: true })).toBeVisible();
// And the filters narrow it rather than emptying it.
await page.getByLabel('Hændelse').selectOption('project.created');
await expect(page.locator('.rows li').filter({ hasText: 'Sigrids eget' }).first()).toBeVisible();
await page.getByRole('button', { name: 'Ryd' }).click();
await context.close();
expect(trouble).toEqual([]);
});
/**
* The project page on a phone.
*
* Every other test here runs at desktop width, and this is what that misses: the
* header is a flex row of a title and four controls, and 390px does not have room
* for them. Without wrapping, the heading was squeezed to its min-content — which
* with `overflow-wrap: anywhere` is one character — so a project called
* "Skovvænget" rendered as a vertical column of letters while the buttons ran off
* the right edge. It looked like a broken font.
*/
test('projektsiden er brugbar på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
// The sidebar is a drawer at this width, so the project is reached through it.
await page.getByRole('button', { name: 'Vis menu' }).click();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Skovvænget');
await sidebar.getByLabel('Projektnavn').press('Enter');
const heading = page.getByRole('heading', { name: 'Skovvænget' });
await expect(heading).toBeVisible();
const box = await heading.boundingBox();
// Wide enough to hold the word, and short enough not to be a column of
// letters. One line of "Skovvænget" at --text-2xl is nowhere near 200px tall.
expect(box.width, 'overskriften er klemt sammen').toBeGreaterThan(120);
expect(box.height, 'overskriften er brudt op i én bogstavkolonne').toBeLessThan(120);
// And nothing sticks out past the right edge of the page.
const sideways = () =>
page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(await sideways(), 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
// The task drawer, too. It was `width: 100vw`, which includes the scrollbar
// gutter where a percentage does not, so the drawer came out wider than the
// page and the whole document could be pushed sideways.
await page.getByLabel('Ny opgave').fill('mal væggen');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('mal væggen', { exact: true }).click();
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
expect(await sideways(), 'opgaveruden skubber siden sidelæns').toBeLessThanOrEqual(1);
expect(trouble).toEqual([]);
});
/**
* A group is 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 the documents that belong to all of
* it rather than to any one project inside it.
*/
test('en gruppe er en side med sine projekter, en beskrivelse og filer', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
await sidebar.getByLabel('Gruppens navn').fill('Værksted');
await sidebar.getByLabel('Gruppens navn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Værksted' })).toBeVisible();
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Drejebænk');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Drejebænk' })).toBeVisible();
await page.getByLabel('Ny opgave').fill('bestil stål');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('bestil stål', { exact: true })).toBeVisible();
// Into the group, then onto its page — which is what the heading is a link to
// now. The chevron beside it still folds; they are two intentions and the old
// single target could only serve the smaller one.
await sidebar
.getByRole('link', { name: 'Drejebænk' })
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await sidebar.getByRole('link', { name: 'Værksted' }).click();
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
const row = page.locator('.projects a').filter({ hasText: 'Drejebænk' });
await expect(row).toBeVisible();
await expect(row.getByText('1', { exact: true }), 'antallet af åbne opgaver').toBeVisible();
// The description, which is the part a heading cannot carry. Click to write,
// blur to save — the same shape a task's description has.
await page.getByRole('button', { name: /Skriv hvad det her er/ }).click();
const about = page.getByLabel('Om gruppen');
await expect(about).toBeVisible();
await about.fill('Alt der larmer og støver.');
await about.blur();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
// And it survives a reload, which is the difference between the page keeping up
// and the server taking it.
await page.reload();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
await expect(page.locator('.projects a').filter({ hasText: 'Drejebænk' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The account's language changes the interface, not only the parser.
*
* `locale` has been on the account since the beginning and 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".
*
* Asserted on both directions and on a date, because the date formatter is the
* half that is easy to leave behind: `toLocaleDateString('da-DK', …)` was written
* into a dozen components, and an English interface still saying "24. aug." is the
* shape of a half-finished translation.
*
* It puts Danish back at the end. Everything after this reads Danish labels, and a
* test that leaves the account in English fails the ones that follow it rather
* than itself — which is the worst way for a suite to report anything.
*/
test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeVisible();
await page.getByLabel('Sprog').selectOption('en');
await page.getByRole('button', { name: 'Gem' }).first().click();
// The sidebar redraws without a reload: i18n.locale is $state, which is the
// whole reason that module is .svelte.js.
const english = page.getByRole('navigation', { name: 'Main menu' });
await expect(english.getByRole('link', { name: 'Today' })).toBeVisible();
await expect(english.getByRole('link', { name: 'Upcoming' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
// And it survives a reload, which is the difference between the page keeping up
// and the account actually holding it.
await page.reload();
await expect(
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Today' })
).toBeVisible();
// A date, in the half that is easy to leave in Danish. The month grid's heading
// is `toLocaleDateString`, which was `'da-DK'` in a dozen components before
// `tag()` existed — an English interface still saying "december" is exactly the
// shape of a half-finished translation.
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Month', exact: true }).click();
await expect(page.locator('.calendar h2')).not.toContainText(
/januar|februar|marts|maj|juni|juli|oktober|december/i
);
// The Inbox keeps its name: it is a project row created at signup, not a
// string in the interface, and the person can rename it like any other.
// Danish again, for everything that runs after this.
await page.goto('/indstillinger');
await page.getByLabel('Language').selectOption('da');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(
page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'I dag' })
).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* Finished tasks can be looked at, and are out of the way by default.
*
* A task list is what is left to do, so closing something has to remove it from
* the plan — but "what did I get done" is a real question and the answer was
* nowhere. 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 back in the middle makes the plan longer
* without making it say more.
*
* The setting is in localStorage, so this also checks it survives a reload — the
* half that a toggle held only in component state would fail.
*/
test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Loftet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Loftet' })).toBeVisible();
for (const what of ['rydde op', 'male gavlen']) {
await page.getByLabel('Ny opgave').fill(what);
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText(what, { exact: true })).toBeVisible();
}
// Close one. It leaves the list, which is the behaviour being protected here.
await page
.locator('.row')
.filter({ hasText: 'rydde op' })
.getByRole('button', { name: 'Markér som færdig' })
.click();
await expect(page.getByText('rydde op', { exact: true })).toBeHidden();
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
// And it can be looked at again, under its own heading at the bottom.
await page.getByRole('button', { name: 'Vis færdige' }).click();
const done = page.locator('section.done');
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
// Still exactly where it was in the plan — reopened tasks are the only ones
// that come back up.
await expect(done.getByText('male gavlen', { exact: true })).toHaveCount(0);
// The choice is in localStorage, so it holds across a reload.
await page.reload();
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Skjul færdige' }).click();
await expect(page.locator('section.done')).toHaveCount(0);
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The sidebar never scrolls sideways.
*
* It grew a horizontal scrollbar on an ordinary desktop, under the whole menu.
* The cause was one flex item: the account name had `overflow: hidden` and no
* `min-width: 0`, so it refused to shrink below its content and pushed the row
* past the column — and `overflow-y: auto` on the sidebar resolves to `auto` on
* the other axis too, which turned "a row that is too wide" into a scrollbar.
*
* Asserted with a long name, because the name is the part that varies and a short
* one hides the whole class of bug. The mobile test measures the *document*; this
* measures the sidebar itself, which is a different box and was never checked.
*/
test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
// A name long enough to overflow the column at its default width.
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
await page.getByRole('button', { name: 'Gem' }).first().click();
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
// And the whole page, for the same reason at a different scale.
const page_sideways = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
// Put the name back, so the tests after this one see the account they expect.
await page.getByLabel('Navn').fill(USER.name);
await page.getByRole('button', { name: 'Gem' }).first().click();
expect(trouble).toEqual([]);
});
/**
* A project can have more than one section.
*
* Reported from use: sections "have no function, because you cannot create more
* than one". The API makes as many as you ask for — there is a Go test that makes
* three — so whatever this is, it is in the page. Every other test here makes
* exactly one section, which is the shape of test that cannot see it.
*/
test('et projekt kan have flere sektioner @forms', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ombygning');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Ombygning' })).toBeVisible();
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await expect(field, `feltet kom ikke frem for "${name}"`).toBeVisible();
await field.fill(name);
await field.press('Enter');
await expect(
page.getByRole('heading', { name, exact: true }),
`sektionen "${name}" kom ikke på siden`
).toBeVisible();
}
// All three at once, still there — an each-block that threw would have stopped
// rendering at the one that broke it.
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
}
// And they survive a reload, which is the difference between "the page kept up"
// and "the server took it".
await page.reload();
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
}
// The board is where somebody actually looks for a section, because a column on
// a board *is* one — and it had no way to make one at all. Reported as
// "sections have no function: there is no way to create them", which from
// inside a board was exactly true.
await page.getByRole('button', { name: 'Board', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
await field.fill('Afsluttet');
await field.press('Enter');
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
await page.reload();
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* Form fields on a touch device are at least 16px.
*
* Not a matter of taste. Below 16px, iOS Safari zooms the page in when a field
* takes focus and does not zoom back out when it is blurred — so the person is
* left with the whole interface too large and has to pinch it back by hand, once
* per field they tap. The type scale tops out at 15px, so every input in the app
* did it; the task drawer worst, being four fields deep.
*
* Chromium cannot reproduce the zoom, but it can be asked the question that causes
* it, which is what this checks. A touch context rather than a narrow viewport:
* the rule is keyed on `(hover: none) and (pointer: coarse)`, because an iPad does
* the same thing at a width no phone breakpoint would catch.
*/
test('felter er mindst 16px, så iOS ikke zoomer ind og bliver der', async ({ browser }) => {
const phone = { viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true };
const tooSmall = async (page, where) => {
const bad = await page.evaluate(() =>
[...document.querySelectorAll('input, textarea, select')]
.filter((el) => el.getClientRects().length)
.map((el) => ({
what: el.tagName.toLowerCase() + (el.type ? `[type=${el.type}]` : ''),
size: parseFloat(getComputedStyle(el).fontSize)
}))
.filter((f) => f.size < 16)
);
expect(bad, `for små felter på ${where}`).toEqual([]);
};
// The sign-in form, in a context with the session spelled out as empty —
// newContext inherits the project's storageState, so a "fresh" browser is
// otherwise already signed in and the form never renders.
const signedOut = await browser.newContext({
...phone,
storageState: { cookies: [], origins: [] }
});
const anon = await signedOut.newPage();
await anon.goto('/');
await expect(anon.getByRole('button', { name: 'Log ind' })).toBeVisible();
await tooSmall(anon, 'login-siden');
await signedOut.close();
const context = await browser.newContext(phone);
const page = await context.newPage();
const trouble = watchForTrouble(page);
await page.goto('/');
await expect(page.getByLabel('Ny opgave')).toBeVisible();
await tooSmall(page, 'I dag');
// The task drawer, which is where it was worst: four fields deep. Dated today,
// or it goes to the Inbox and is correctly absent from the view being looked at.
await page.getByLabel('Ny opgave').fill('slibe gulvet i dag');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('slibe gulvet', { exact: true }).click();
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
await tooSmall(page, 'opgavedetaljer');
// And the settings pages, which are almost entirely fields.
await page.goto('/indstillinger');
await expect(page.locator('section.panel').first()).toBeVisible();
await tooSmall(page, 'indstillinger');
await context.close();
expect(trouble).toEqual([]);
});
/**
* The MCP connector address must not fall through to the app shell.
*
+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"
}
}
+26
View File
@@ -65,6 +65,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']
}
],
+40
View File
@@ -0,0 +1,40 @@
/**
* Renders static/icon.svg to the PNGs the manifest and iOS need.
*
* node scripts/generate-icons.js
*
* A script rather than a test, so CI never runs it and never rewrites a tracked
* binary. Run it when the icon changes; the smoke test fetches every icon the
* manifest names, so a reference with no file behind it fails there.
*
* It drives Chromium because there is no SVG renderer on this machine — which is
* why these references were once removed rather than left pointing at files that
* did not exist. Playwright is a renderer, and it is already a dependency.
*/
import { chromium } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
const SIZES = [
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
{ file: 'static/icon-192.png', size: 192 },
{ file: 'static/icon-512.png', size: 512 },
// And the one Safari wants by name. It ignores the manifest's icons entirely
// and looks for this, which is why an installed PWA on iOS otherwise gets a
// screenshot of the page as its home-screen icon.
{ file: 'static/apple-touch-icon.png', size: 180 }
];
const svg = readFileSync('static/icon.svg', 'utf8');
const browser = await chromium.launch();
const page = await browser.newPage();
for (const { file, size } of SIZES) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
);
writeFileSync(file, await page.locator('svg').screenshot());
console.log(`${file} ${size}×${size}`);
}
await browser.close();
+264
View File
@@ -0,0 +1,264 @@
/**
* 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: 'kristian@example.dk', 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' },
{ 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 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;
+4
View File
@@ -7,6 +7,10 @@
<meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<!-- Safari ignores the manifest's icons entirely and looks for this by name.
Without it an installed PWA on iOS gets a screenshot of the page as its
home-screen icon. Generated from the same SVG by scripts/generate-icons.js. -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>verdande</title>
<script>
// Applied before first paint, so a dark-theme user never sees a white
+71 -27
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 }),
@@ -178,6 +189,34 @@ export const api = {
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
// 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 }),
@@ -194,7 +233,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 }),
@@ -205,6 +246,8 @@ export const api = {
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
activity: (projectId) => get(`/projects/${projectId}/activity`),
@@ -305,5 +348,6 @@ export const api = {
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
delegated: () => get('/delegated'),
people: () => get('/people'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};
+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. */
+91 -3
View File
@@ -9,11 +9,37 @@
* 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('');
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 +144,36 @@
{/each}
{#if !tasks.length}
<p class="empty">Tom</p>
<p class="empty">{t('view.emptySection')}</p>
{/if}
</div>
</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 +188,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;
+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>
+268 -128
View File
@@ -3,6 +3,8 @@
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);
}
@@ -224,6 +228,34 @@
await app.reorderProjects(ordered.map((p) => p.id));
}
// --- a task dropped on "I dag" ---------------------------------------------------
/**
* "Gør det i dag" is the most-made rescheduling there is, and the sidebar is
* where the pointer already goes.
*
* Only this one of the two date views. "Kommende" is a range rather than a
* day — it starts today and runs a week — so a drop on it would have to invent
* a date the label does not name. The month grid inside it takes drops per
* cell, which is where a specific day is actually offered.
*/
let overToday = $state(false);
function todayISO() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
now.getDate()
).padStart(2, '0')}`;
}
async function onDropOnToday(event) {
event.preventDefault();
const id = dragged(event, TASK);
overToday = false;
if (!id) return;
await app.reschedule(id, todayISO());
}
// --- a task dropped on a project ------------------------------------------------
/** The project row lit up because a task is hovering over it. */
@@ -296,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 —
@@ -306,13 +338,26 @@
</div>
<div class="views">
<a href="/" class:active={current === '/'} onclick={onnavigate}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<a
href="/"
class:active={current === '/'}
class:receiving={overToday}
onclick={onnavigate}
ondragover={(e) => {
if (!carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
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
@@ -320,7 +365,7 @@
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}
{#if app.inbox}
@@ -364,6 +409,36 @@
</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,
@@ -381,14 +456,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"
@@ -397,8 +472,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" />
@@ -408,12 +483,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)}
/>
@@ -422,24 +496,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>
@@ -466,15 +541,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
@@ -483,32 +589,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
@@ -516,37 +625,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>
@@ -554,43 +644,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}
@@ -607,20 +703,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.
@@ -631,7 +727,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}
@@ -663,13 +759,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;
@@ -825,6 +928,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;
@@ -840,10 +962,15 @@
white-space: nowrap;
}
.fold a:hover,
.fold button:hover {
color: var(--ink);
}
.fold a {
text-decoration: none;
}
.chevron {
width: 11px;
height: 11px;
@@ -888,21 +1015,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:
@@ -922,11 +1040,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 {
@@ -1106,6 +1233,7 @@
.avatar {
width: 22px;
height: 22px;
flex: none;
border-radius: var(--radius-full);
display: grid;
place-items: center;
@@ -1115,7 +1243,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;
@@ -1123,6 +1262,7 @@
.signout {
margin-left: auto;
flex: none;
font-size: var(--text-xs);
color: var(--ink-faint);
opacity: 0;
+42 -41
View File
@@ -18,6 +18,8 @@
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';
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
@@ -143,69 +145,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 +214,61 @@
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>
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
Glemt adgangskode?
{t('auth.forgot')}
</button>
{/if}
+71 -40
View File
@@ -12,6 +12,8 @@
*/
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';
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,18 +422,18 @@
</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>
@@ -425,7 +442,7 @@
</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;
}
}
+101 -12
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,25 +34,27 @@
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'
};
}
// Only when it is somebody else's: see app.assigneeOf.
let assignee = $derived(app.assigneeOf(task));
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'
})
@@ -64,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" />
@@ -81,15 +84,26 @@
<span class="description">{task.description}</span>
{/if}
{#if 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={t('task.assignee', { name: assignee.name })}>
<span class="face" style="background: {assignee.avatar_color}">
{assignee.name[0]?.toUpperCase() ?? '?'}
</span>
{assignee.name}
</span>
{/if}
{#if due}
<span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if}
</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" />
@@ -99,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}
@@ -259,6 +303,51 @@
stroke-linejoin: round;
}
.assignee {
display: inline-flex;
align-items: center;
gap: var(--s1);
color: var(--ink-muted);
}
.face {
width: 14px;
height: 14px;
border-radius: var(--radius-full);
display: inline-grid;
place-items: center;
font-size: 9px;
font-weight: 560;
color: #fff;
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?.());
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* The interface's language.
*
* Every string a person reads used to be written into the component that drew it,
* in Danish. The account already had a `locale` — but 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.
*
* Danish is the fallback and the source of truth. It is the language the app was
* written in, and it is the one whose phrasing was argued over; English is a
* translation of it. A key missing from English therefore falls through to Danish
* rather than to the key, because a Danish sentence in an English screen is
* legible and `settings.notifications.title` is not.
*
* `t()` reads `i18n.locale`, which is `$state`, so every component that calls it
* re-renders when the account's language changes. That is what makes the setting
* take effect without a reload — and it is why this file is `.svelte.js`.
*/
import { da } from './locales/da.js';
import { en } from './locales/en.js';
const DICTIONARIES = { da, en };
class I18n {
/**
* Starts Danish and is set from the account as soon as the session loads.
*
* Not read from localStorage on the way in. The language belongs to the
* account — the same person reading in Danish on their laptop wants Danish on
* their phone — unlike the sidebar's width, which belongs to the screen.
*/
locale = $state('da');
set(locale) {
this.locale = locale in DICTIONARIES ? locale : 'da';
}
}
export const i18n = new I18n();
/**
* The string for `key`, with `{name}` placeholders filled from `params`.
*
* Placeholders rather than string concatenation, because word order is not a
* constant across languages: "3 tasks left" and "3 opgaver tilbage" happen to
* agree, and "shared with me" and "delt med mig" do not.
*/
export function t(key, params) {
const dict = DICTIONARIES[i18n.locale] ?? da;
const raw = dict[key] ?? da[key] ?? key;
if (!params) return raw;
return raw.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
}
/**
* The plural form for `n`.
*
* Both languages here have exactly two forms, so this takes the two rather than
* pulling in a plural-rules engine for a case neither language has. The count is
* substituted as `{n}`, so "1 opgave" and "3 opgaver" are both one lookup.
*/
export function plural(n, oneKey, manyKey) {
return t(n === 1 ? oneKey : manyKey, { n });
}
/**
* The BCP 47 tag for `Intl` — dates, times and numbers.
*
* The whole reason this exists: `toLocaleDateString('da-DK', …)` was written into
* a dozen components, so an English interface would still have said "24. aug." and
* started its weeks wherever da-DK does. One place to ask, so there is one place
* to be wrong.
*
* `en-GB` rather than `en-US`: the app is day-month everywhere else, weeks start
* on Monday, and an American date format inside a European layout reads as a bug.
*/
export function tag() {
return i18n.locale === 'en' ? 'en-GB' : 'da-DK';
}
+591
View File
@@ -0,0 +1,591 @@
/**
* Danish, and the source of truth.
*
* This is the language the app was written in and the one whose phrasing was
* argued over; `en.js` is a translation of it. A key missing from English falls
* through to here rather than to the key itself, because a Danish sentence in an
* English screen can be read and `settings.notifications.title` cannot.
*
* Keys are grouped by where they are read, not by what they say. Two screens that
* happen to use the word "Slet" get a key each: they are two decisions, and the
* day one of them needs to say "Fjern" instead, a shared key is a rewrite of both.
*
* `{name}` placeholders are filled by `t()`. They exist because word order is not
* a constant across languages — "3 tasks left" and "3 opgaver tilbage" happen to
* agree, "shared with me" and "delt med mig" do not.
*/
export const da = {
// --- the shell ---------------------------------------------------------------
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
'nav.inbox': 'Indbakke',
'nav.settings': 'Indstillinger',
'nav.signOut': 'Log ud',
'nav.main': 'Hovedmenu',
'nav.search': 'Søg',
'nav.searchLong': 'Søg i opgaver og projekter',
'nav.noResults': 'Ingen resultater.',
'nav.showSidebar': 'Vis sidebjælken',
'nav.hideSidebar': 'Skjul sidebjælken',
'nav.showMenu': 'Vis menu',
'nav.sidebarWidth': 'Bredde på sidebjælken',
'nav.toggleTheme': 'Skift mellem lyst og mørkt tema',
'nav.offline': 'Offline',
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
'nav.doItToday': 'Gør det i dag',
// --- the sidebar's own sections ------------------------------------------------
'sidebar.projects': 'Projekter',
'sidebar.shared': 'Delt med mig',
'sidebar.filters': 'Filtre',
'sidebar.labels': 'Etiketter',
'sidebar.newProject': 'Nyt projekt',
'sidebar.newGroup': 'Ny gruppe',
'sidebar.projectName': 'Projektnavn',
'sidebar.groupName': 'Gruppens navn',
'sidebar.noProjects': 'Ingen projekter endnu.',
'sidebar.emptyGroup': 'Tom — træk et projekt herop.',
'sidebar.rename': 'Omdøb',
'sidebar.delete': 'Slet',
'sidebar.fold': 'Fold {name} sammen',
'sidebar.unfold': 'Fold {name} ud',
'sidebar.deleteGroup': 'Slet gruppen "{name}"?',
'sidebar.deleteGroupOne': 'Projektet i den bliver, men ligger bagefter uden gruppe.',
'sidebar.deleteGroupMany': 'De {n} projekter bliver, men ligger bagefter uden gruppe.',
// --- a task in a list ----------------------------------------------------------
'task.add': 'Tilføj',
'task.new': 'Ny opgave',
'task.placeholder': 'Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”',
'task.complete': 'Markér som færdig',
'task.reopen': 'Genåbn opgave',
'task.today': 'I dag',
'task.tomorrow': 'I morgen',
'task.yesterday': 'I går',
'task.overdue': '{n} dage forsinket',
'task.repeats': 'Gentages {rule}',
'task.assignee': 'Ansvarlig: {name}',
'task.subtasks': '{done} af {total} undertasks er lukket',
'task.attachments': '{n} vedhæftet',
'task.priority': 'Prioritet {n}',
'task.noPriority': 'Ingen prioritet',
// --- a project group -----------------------------------------------------------
'group.colorOf': 'Farve på {name}',
// --- settings: the frame -------------------------------------------------------
'settings.title': 'Indstillinger',
'settings.tab.account': 'Konto',
'settings.tab.notifications': 'Notifikationer',
'settings.tab.integrations': 'Integrationer',
'settings.tab.ai': 'AI',
'settings.tab.tokens': 'API-tokens',
'settings.tab.users': 'Brugere',
'settings.tab.history': 'Historik',
'settings.tab.errors': 'Fejl',
'settings.tab.data': 'Data og skabeloner',
// --- settings: the account -----------------------------------------------------
'account.profile': 'Profil',
'account.profileHint':
'Tidszonen er ikke pynt: alle datoer i appen bliver afgjort i den, så “i morgen kl. 9” betyder noget andet, når du skifter den.',
'account.name': 'Navn',
'account.email': 'E-mail',
'account.emailFixed': 'Kan ikke ændres her.',
'account.timezone': 'Tidszone',
'account.parseLanguage': 'Sprog',
'account.danish': 'Dansk',
'account.english': 'English',
'account.parseHint':
'Både fladens sprog og den grammatik, hurtig tilføjelse tolker en linje med — “hver mandag” eller “every monday”.',
'account.save': 'Gem',
'account.saved': 'Gemt.',
'account.appearance': 'Udseende',
'account.appearanceHint':
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
'account.password': 'Adgangskode',
'account.passwordHint':
'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.',
'account.currentPassword': 'Nuværende adgangskode',
'account.newPassword': 'Ny adgangskode',
'account.changePassword': 'Skift adgangskode',
'account.passwordChanged': 'Skiftet.',
'account.totp': 'To-faktor',
'account.totpHint': 'En engangskode fra en authenticator-app oven i adgangskoden.',
'account.writeThemDown': 'Skriv dem ned nu.',
'account.recoveryHint': 'De vises kun denne ene gang — serveren har kun deres hash.',
'account.gotThem': 'Jeg har dem',
'account.passwordRequired':
'Kræves til begge handlinger nedenfor. At slå to-faktor fra er præcis det, en fremmed med din session ville gøre først.',
'account.newRecoveryCodes': 'Nye gendannelseskoder',
'account.totpOff': 'Slå to-faktor fra',
'account.scanHint': 'Scan i din authenticator-app, eller indtast nøglen manuelt.',
'account.codeFromApp': 'Koden fra appen',
'account.totpProofHint':
'To-faktor er først slået til, når en kode har bevist, at appen faktisk har nøglen.',
'account.confirm': 'Bekræft',
'account.cancel': 'Fortryd',
'account.totpOn': 'Slå to-faktor til',
'account.devices': 'Enheder',
'account.devicesHint':
'Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift adgangskode.',
'account.thisDevice': 'denne enhed',
'account.signOutDevice': 'Log ud',
'account.totpEnabled': 'Slået til.',
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
'account.recoveryLeftMany': '{n} gendannelseskoder tilbage.',
// --- settings: notifications ---------------------------------------------------
'push.title': 'Notifikationer på enheden',
'push.hint':
'Påmindelser og beskeder fra andre, også når fanen er lukket. Beskeden krypteres til din browser undervejs — push-tjenesten kan ikke læse den.',
'push.unsupported':
'Denne browser understøtter det ikke. Safari kræver, at appen er lagt på hjemmeskærmen; over almindelig HTTP virker det kun på localhost.',
'push.blocked':
'Browseren har blokeret notifikationer for dette site. Det kan kun laves om i browserens egne indstillinger — en side kan ikke spørge igen, når svaret har været nej.',
'push.on': 'Slået til på denne enhed.',
'push.turnOff': 'Slå fra',
'push.turnOn': 'Slå til på denne enhed',
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
'push.recent': 'Seneste',
'push.none': 'Ingenting endnu.',
'push.open': 'Åbn',
'push.markAllRead': 'Markér alle som læst',
'push.version': 'Version',
'push.running': 'Kører',
'push.seeRelease': 'Se udgivelsen',
// --- settings: API tokens ------------------------------------------------------
'tokens.title': 'API-tokens',
'tokens.hint':
'Det, et script, en CalDAV-klient eller en MCP-forbindelse logger ind med. En token bærer dine rettigheder præcist — den kan intet, du ikke selv kan.',
'tokens.copyNow': 'Kopiér den nu.',
'tokens.onceOnly': 'Den vises kun denne ene gang — serveren har kun dens hash.',
'tokens.copy': 'Kopiér',
'tokens.copied': 'Kopieret.',
'tokens.done': 'Færdig',
'tokens.mcpBefore': 'Skal du bruge den som',
'tokens.mcpLink': 'MCP-forbindelse',
'tokens.mcpAfter': 'i Claude? Så er det denne adresse, dialogen skal have:',
'tokens.copyAddress': 'Kopiér adressen',
'tokens.name': 'Navn',
'tokens.namePlaceholder': 'fx min bærbare, eller backup-scriptet',
'tokens.expires': 'Udløber',
'tokens.never': 'Aldrig',
'tokens.in30': 'Om 30 dage',
'tokens.in90': 'Om 90 dage',
'tokens.inYear': 'Om et år',
'tokens.expiryHint':
'En kalenderklient, der har abonneret i årevis, har brug for “aldrig”. Et script, du kører én gang, har ikke.',
'tokens.create': 'Lav en token',
'tokens.none': 'Ingen tokens endnu.',
'tokens.revoke': 'Tilbagekald',
// --- settings: AI --------------------------------------------------------------
'ai.title': 'AI',
'ai.hint':
'Bruges to steder: at dele en opgave op i undertasks, og at skrive et kort overblik over det, der er udestående. Nøglen ligger på din server og sendes kun til den udbyder, du vælger her.',
'ai.provider': 'Udbyder',
'ai.off': 'Slået fra',
'ai.model': 'Model',
'ai.baseURL': 'Adresse',
'ai.apiKey': 'API-nøgle',
'ai.save': 'Gem',
'ai.saved': 'Gemt.',
'ai.weekly': 'Ugentligt overblik',
'ai.weeklyHint': 'En kort note om, hvad der ser vigtigst ud lige nu.',
'push.unreadOne': '{n} ulæst.',
'push.unreadMany': '{n} ulæste.',
'ai.keyStored': 'Der er gemt en nøgle — lad feltet stå tomt',
'ai.keyBlankHint':
'Feltet er tomt med vilje. Nøglen sendes aldrig tilbage til browseren — en indstillingsside, der fylder et kodeordsfelt ud igen, er en side, der en dag lækker en nøgle ud i et skærmbillede.',
'ai.keyNewHint': 'Gemmes på serveren og vises aldrig igen.',
'tokens.lastUsed': 'sidst brugt {when}',
'tokens.neverUsed': 'aldrig brugt',
'tokens.expired': 'udløbet {when}',
'tokens.expiresOn': 'udløber {when}',
'tokens.revokeNamed': 'Tilbagekald {name}',
// --- settings: people ----------------------------------------------------------
'users.invite': 'Inviter en bruger',
'users.inviteHint':
'Der er ingen åben registrering. Du sender et link, og personen vælger selv navn og adgangskode — så er der ikke en adgangskode, to mennesker kender.',
'users.emailAddress': 'E-mailadresse',
'users.emailPlaceholder': 'navn@example.dk',
'users.mustBeEmail': 'Skal være en e-mailadresse.',
'users.sendInvite': 'Send invitation',
'users.inviteMade': 'Invitationen er oprettet. Send linket til personen:',
'users.pending': 'Afventer svar',
'users.pendingHint':
'Invitationer, der er sendt og ikke brugt endnu. Kun linkets hash er gemt, så det kan ikke vises igen — er det gået til den forkerte, så træk det tilbage.',
'users.revoke': 'Træk tilbage',
'users.accounts': 'Konti',
'users.accountsHint': 'Alle på denne instans.',
'users.admin': 'administrator',
'users.you': 'dig',
'users.makeAdmin': 'Gør til administrator',
'users.removeAdmin': 'Fjern administrator',
'users.delete': 'Slet',
'users.deleteQuestion': 'Slet {name} <{email}> for altid?',
'users.deleteTakes': 'Det sletter {projects} og {tasks} i dem.',
'users.deleteLeaves': '{tasks} i andres projekter bliver stående uden forfatter.',
'users.deleteFinal': 'Det kan ikke fortrydes. Der er ingen papirkurv for konti.',
'users.revokeQuestion':
'Træk invitationen til {email} tilbage? Linket holder op med at virke.',
'users.projectOne': 'projekt',
'users.projectMany': 'projekter',
'users.taskOne': 'opgave',
'users.taskMany': 'opgaver',
'users.never': 'aldrig set',
// --- settings: history and errors -----------------------------------------------
'history.title': 'Historik',
'history.hint':
'Alt, der er blevet gjort på denne server, på tværs af alle projekter — også dem, du ikke selv er med i. Projekternes egne historik-paneler viser det samme, ét projekt ad gangen; det her er spørgsmålet, de ikke kan svare på. Rækkerne bliver stående, når en konto slettes: det, der blev gjort, holder op med at have et navn, ikke med at være sket.',
'history.person': 'Person',
'history.all': 'Alle',
'history.project': 'Projekt',
'history.event': 'Hændelse',
'history.clear': 'Ryd',
'history.more': 'Hent flere',
'history.loading': 'Henter…',
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
'history.nothing': 'Der er ikke registreret noget endnu.',
'history.deletedAccount': 'En slettet konto',
'errors.title': 'Serverfejl',
'errors.hint':
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
'errors.none': 'Ingen fejl registreret. Det er den rigtige tilstand.',
// --- the views -------------------------------------------------------------------
'view.delegated': 'Venter på andre',
'view.delegatedEmpty': 'Du venter ikke på nogen.',
'view.labelEmpty': 'Ingen åbne opgaver med denne etiket.',
'view.filterEmpty': 'Ingenting matcher lige nu.',
'view.filterFailed': 'Filteret kunne ikke køres.',
'view.upcoming': 'Kommende',
'view.mode': 'Visning',
'view.list': 'Liste',
'view.week': 'Uge',
'view.month': 'Måned',
'view.board': 'Board',
'view.calendar': 'Kalender',
'view.emptySection': 'Tom',
'view.addSection': '+ Tilføj sektion',
'view.sectionName': 'Sektionens navn',
'view.newSection': 'Ny sektion',
'view.prevMonth': 'Forrige måned',
'view.nextMonth': 'Næste måned',
'view.prevWeek': 'Forrige uge',
'view.nextWeek': 'Næste uge',
'view.weekNumber': 'uge {n}',
'view.more': '+{n} mere',
// --- how long ago ----------------------------------------------------------------
'when.justNow': 'lige nu',
'when.minutes': 'for {n} min. siden',
'when.hours': 'for {n} timer siden',
'when.days': 'for {n} dage siden',
'when.never': 'aldrig',
'when.neverSignedIn': 'aldrig logget ind',
// --- settings: data ---------------------------------------------------------------
'data.import': 'Importér fra Todoist',
'data.csvFile': 'CSV-fil',
'data.importing': 'Importerer …',
'data.openProject': 'Åbn projektet',
'data.trash': 'Papirkurv',
'data.trashEmpty': 'Papirkurven er tom.',
'data.restore': 'Hent tilbage',
'data.backups': 'Sikkerhedskopier',
'data.backupNow': 'Tag en kopi nu',
'data.backingUp': 'Kopierer…',
'data.backupMade': 'Kopi taget — {size}',
'data.backupFailed': 'mislykkedes',
'data.download': 'Hent',
'data.noBackups': 'Der er ikke taget nogen kopi endnu.',
'data.swept': 'ryddet væk',
'data.empty': 'tom',
'data.export': 'Eksport',
'data.exportAccount': 'Hele kontoen som JSON',
'data.exportOne': 'Ét projekt',
'data.pickProject': 'Vælg et projekt',
'data.csv': 'CSV',
'data.ics': 'Kalenderfil',
'data.templates': 'Skabeloner',
'data.saveAsTemplate': 'Gem et projekt som skabelon',
'data.templateName': 'Navn på skabelonen',
'data.saveTemplate': 'Gem som skabelon',
'data.noTemplates': 'Ingen skabeloner endnu.',
'data.use': 'Brug',
'data.delete': 'Slet',
'data.newProjectName': 'Navn på det nye projekt',
'data.startDate': 'Startdato',
'data.startDateHint': 'Dag nul for skabelonens datoer. Tom betyder i dag.',
'data.createProject': 'Opret projektet',
// --- a group's own page ------------------------------------------------------------
'group.notFound': 'Gruppen findes ikke, eller den er ikke din.',
'group.failed': 'Gruppen kunne ikke hentes.',
'group.retry': 'Prøv igen',
'group.about': 'Om gruppen',
'group.aboutPlaceholder': 'Hvad er det her for et stykke arbejde?',
'group.writeAbout': 'Skriv hvad det her er for et stykke arbejde.',
'group.saving': 'Gemmer…',
'group.projects': 'Projekter',
'group.projectOne': '1 projekt',
'group.projectMany': '{n} projekter',
'group.noProjects':
'Ingen projekter i gruppen endnu. Træk et herop i sidebjælken, eller vælg gruppen, når du opretter et.',
'group.documents': 'Dokumenter',
'group.documentsHint':
'Filer, der hører til hele gruppen frem for til én opgave i den — kontrakten, tegningen, den aftale alt det andet hviler på.',
'group.remove': 'Fjern',
'group.removeNamed': 'Fjern {name}',
'group.removeQuestion': 'Fjern "{name}"?',
'group.addFile': '+ Læg en fil op',
'group.uploading': 'Lægger op…',
// --- keyboard shortcuts --------------------------------------------------------------
'keys.title': 'Genveje',
'data.importHint':
'Vælg CSV-eksporten af ét projekt. Prioriteterne bliver konverteret, ikke kopieret: Todoist skriver 4 for det, grænsefladen kalder P1.',
'data.trashHint':
'Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som de var — bortset fra opgaver, du havde slettet hver for sig først; dem var der en grund til.',
'data.backupsHint':
'Databasen kopieres hver nat og de seneste fjorten gemmes. Kopierne er talt, ikke dateret: en container, der har stået slukket en måned, må ikke komme tilbage og slette dem alle for at være for gamle.',
'data.backupsAttachments':
'Vedhæftede filer er ikke med. De er indholdsadresserede og bliver kun lagt til, så fjorten ens kopier af dem ville fylde det drev op, de skulle beskytte — tag hele /data, hvis du vil have det hele.',
'data.restoreHint':
'At lægge en kopi tilbage er ikke en knap her, og det er med vilje: en database kan ikke skiftes ud under en server, der skriver i den, og en halvt gennemført udskiftning ødelægger netop det, den skulle redde. Stop containeren, læg filen i stedet for verdande.db i datamappen, slet -wal og -shm ved siden af, og start den igen.',
'data.exportHint':
'Alt, i den rå form. Det er garantien for, at du kan gå igen — en eksport, der er blevet pyntet, er en eksport, der har mistet noget.',
'data.templatesHint':
'Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er relative, så de bliver regnet ud fra den startdato, du vælger.',
'keys.navigation': 'Navigation',
'keys.jumpToQuickAdd': 'Hop til feltet “tilføj opgave”',
'keys.thisPage': 'Denne side',
'keys.inQuickAdd': 'I hurtig tilføjelse',
'keys.intoProject': 'Læg opgaven i et projekt',
'keys.setLabel': 'Sæt en etiket',
'keys.priority': 'Prioritet',
'keys.date': 'Dato — dansk og engelsk',
'keys.time': 'Klokkeslæt',
'keys.clear': 'Ryd feltet',
// --- signing in ------------------------------------------------------------------
'auth.firstAccount': 'Opret den første konto. Den bliver administrator.',
'auth.name': 'Navn',
'auth.nameRequired': 'Skal udfyldes',
'auth.email': 'E-mail',
'auth.emailInvalid': 'Skal være en e-mailadresse',
'auth.password': 'Adgangskode',
'auth.passwordHint': 'Mindst 10 tegn. En sætning er både nemmere og stærkere.',
'auth.createAccount': 'Opret konto',
'auth.totpPrompt': 'Indtast koden fra din authenticator.',
'auth.code': 'Kode',
'auth.totpRecovery': 'Har du mistet din telefon, virker en af dine gendannelseskoder her.',
'auth.continue': 'Fortsæt',
'auth.resetPrompt': 'Vi sender et link til at vælge en ny adgangskode.',
'auth.sendLink': 'Send link',
'auth.back': 'Tilbage',
'auth.linkBroken': 'Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.',
'auth.toSignIn': 'Til log ind',
'auth.signOut': 'Log ud',
'auth.invited': 'Du er inviteret. Vælg et navn og en adgangskode.',
'auth.pickNew': 'Vælg en ny adgangskode.',
'auth.newPassword': 'Ny adgangskode',
'auth.newPasswordHint': 'Mindst 10 tegn. Alle andre enheder bliver logget ud.',
'auth.savePassword': 'Gem adgangskoden',
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
'auth.signInPrompt': 'Log ind for at fortsætte.',
'auth.signIn': 'Log ind',
'auth.forgot': 'Glemt adgangskode?',
// --- a task, opened ---------------------------------------------------------------
'detail.task': 'Opgave',
'detail.close': 'Luk',
'detail.text': 'Opgavens tekst',
'detail.description': 'Beskrivelse',
'detail.descriptionPlaceholder': 'Detaljer, links, hvad der skal huskes',
'detail.priority': 'Prioritet',
'detail.p1': 'P1 — haster',
'detail.none': 'Ingen',
'detail.due': 'Forfalder',
'detail.labels': 'Etiketter',
'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt',
'detail.repeats': 'Gentages',
'detail.monthly': 'Hver måned',
'detail.yearly': 'Hvert år',
'detail.assignee': 'Ansvarlig',
'detail.subtasks': 'Undertasks',
'detail.addSubtask': 'Tilføj en undertask',
'detail.newSubtask': 'Ny undertask',
'detail.files': 'Filer',
'detail.delete': 'Slet',
'detail.reminders': 'Påmindelser',
'detail.newReminder': 'Ny påmindelse',
'detail.sent': 'sendt',
'detail.add': 'Tilføj',
'detail.comments': 'Kommentarer',
'detail.writeComment': 'Skriv en kommentar',
'detail.newComment': 'Ny kommentar',
'detail.post': 'Skriv',
'detail.readAs': 'Læst som {what}',
'auth.alreadySignedIn': 'Du er logget ind som {name}. Log ud først, og åbn så linket igen.',
'detail.reopen': 'Genåbn',
'detail.uploading': 'Lægger op …',
'detail.attachFile': 'Vedhæft en fil',
// --- settings: integrations --------------------------------------------------------
'int.gmail': 'Gmail',
'int.gmailHint':
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
'int.connectGmail': 'Forbind Gmail',
'int.fromEnv':
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
'int.oauthClient': 'OAuth-klient hos Google',
'int.registerHint':
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, slå Gmail API til, og indsæt de to værdier her. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
'int.redirectURI': 'Godkendt redirect-URI',
'int.clientID': 'Klient-id',
'int.clientSecret': 'Klienthemmelighed',
'int.secretStored': 'Gemt — udfyld kun for at skifte den',
'int.save': 'Gem',
'int.saved': 'Gemt.',
'int.noClient':
'Der er ikke registreret en OAuth-klient hos Google endnu. Det skal en administrator gøre, før knappen her kan bruges.',
'int.connectedAs': 'Forbundet som',
'int.unknownAccount': 'ukendt konto',
'int.whatMakesATask': 'Hvad laver en opgave',
'int.starred': 'Stjernemarkerede',
'int.aLabel': 'En bestemt etiket',
'int.both': 'Begge dele',
'int.gmailLabelName': 'Etikettens navn i Gmail',
'int.gmailLabelExample': 'fx Opgaver',
'int.fetchNow': 'Hent nu',
'int.disconnect': 'Afbryd',
'int.feed': 'Kalenderfeed',
'int.feedHint':
'Abonnér i Apple Kalender, Google eller Thunderbird. En kalenderklient kan ikke logge ind, så nøglen i adressen er hele adgangskoden — del den ikke.',
'int.address': 'Adresse',
'int.copy': 'Kopiér',
'int.newLink': 'Nyt link',
'int.newLinkHint':
'Et nyt link bryder alle eksisterende abonnementer med det samme. Det er pointen: det er det, man gør, når det gamle er havnet et forkert sted.',
'int.mailToTask': 'Mail til opgave',
'int.mailHint':
'Send eller videresend en mail hertil, og emnelinjen bliver tolket som en hurtig tilføjelse — “Fakturer Anders p1 #Firma” virker også her.',
'int.noMailServer':
'Der er ingen mailserver sat op på denne server, så adressen herunder kan ikke modtage noget endnu. Sæt VERDANDE_SMTP_HOST og de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til todo+* videre til /inbound/mail.',
'int.yourAddress': 'Din adresse',
'int.newAddress': 'Ny adresse',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
'int.server': 'Server',
'int.caldavAuth':
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
'int.gmailConnected': 'Gmail er forbundet.',
'int.gmailState':
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
'int.gmailExpired': 'Forsøget udløb undervejs. Prøv igen.',
'int.gmailInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.',
'int.gmailFailed': 'Google afviste ombytningen af koden.',
'int.gmailNoRefresh':
'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.',
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
'int.gmailOther': 'Gmail svarede: {what}',
// --- a project ----------------------------------------------------------------------
'project.name': 'Projektets navn',
'project.clickToRename': 'Klik for at omdøbe',
'project.color': 'Farve på projektet',
'project.pickColor': 'Vælg farve',
'project.delete': 'Slet projektet',
'project.share': 'Del',
'project.history': 'Historik',
'project.noHistory': 'Ingenting er sket her endnu.',
'project.canEdit': 'Kan redigere',
'project.canView': 'Kan kun se',
'project.invite': 'Inviter',
'project.inviteByEmail': 'Inviter via e-mail',
'project.emailAddress': 'e-mailadresse',
'project.role': 'Rolle',
'project.sendLinkYourself': 'Ingen mailserver sat op — send selv linket:',
'project.noSection': 'Uden sektion',
'project.rename': 'Omdøb',
'project.deleteSection': 'Slet',
'project.readOnly': 'Du kan se dette projekt, men ikke ændre det.',
'project.notFound': 'Projektet findes ikke, eller du har ikke adgang til det.',
'project.loadFailed': 'Kunne ikke hente projektet. Serveren svarede ikke.',
'project.retry': 'Prøv igen',
'project.nothingHere': 'Ingenting her endnu.',
'project.sharedWith': 'Delt · {n}',
// --- the palette ---------------------------------------------------------------
'color.graphite': 'Grafit',
'color.tomato': 'Tomat',
'color.amber': 'Rav',
'color.lime': 'Lime',
'color.green': 'Grøn',
'color.teal': 'Petrol',
'color.blue': 'Blå',
'color.indigo': 'Indigo',
'color.violet': 'Violet',
'color.magenta': 'Magenta',
// --- what a failed request says -----------------------------------------------
'error.unauthorized': 'Forkert e-mail eller adgangskode.',
'error.totpRequired': 'Indtast koden fra din authenticator.',
'error.forbidden': 'Det har du ikke adgang til.',
'error.notFound': 'Findes ikke.',
'error.conflict': 'Det er der allerede.',
'error.rateLimited': 'For mange forsøg. Vent et øjeblik.',
'error.validation': 'Tjek felterne herunder.',
'error.tooLarge': 'Det er for stort.',
'error.internal': 'Noget gik galt. Prøv igen.',
'error.badRequest': 'Anmodningen kunne ikke læses.',
'error.offline': 'Ingen forbindelse til serveren.',
'error.gmailNotConfigured':
'Gmail er ikke sat op på denne server. En administrator skal registrere en OAuth-klient hos Google under Indstillinger → Integrationer.',
'error.aiNotConfigured': 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
'error.totpNotEnabled': 'To-faktor er ikke slået til.',
'error.inboxProtected': 'Indbakken kan ikke slettes.',
'error.lastAdmin':
'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.',
// --- the themes -------------------------------------------------------------------
'theme.dark': 'Nordlys',
'theme.darkNote': 'Mørk med grøn accent.',
'theme.dusk': 'Skumring',
'theme.duskNote': 'Mørk og varm, med rav.',
'theme.light': 'Dagslys',
'theme.lightNote': 'Lys og køligt neutral.',
'theme.paper': 'Papir',
'theme.paperNote': 'Lys og varm, som papir.',
'theme.contrast': 'Kontrast',
'theme.contrastNote': 'Sort på hvidt, til skarpt lys.',
// --- push, in the browser's own words ----------------------------------------------
'push.deniedByBrowser':
'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.',
'push.notEnabled': 'Notifikationer blev ikke slået til.',
'push.noVapidKey': 'Serveren har ingen VAPID-nøgle.',
'view.done': 'Færdige',
'view.showDone': 'Vis færdige',
'view.hideDone': 'Skjul færdige'
};
+586
View File
@@ -0,0 +1,586 @@
/**
* English, translated from `da.js`.
*
* Danish is the source: it is the language the app was written in, and the one
* whose phrasing was argued over. A key missing here falls through to Danish
* rather than to the key itself — a Danish sentence in an English screen can be
* read, and `settings.notifications.title` cannot.
*
* Dates are not here. They go through `tag()`, which returns `en-GB` for this
* locale: the app is day-month everywhere, weeks start on Monday, and an American
* date format inside a European layout reads as a bug.
*/
export const en = {
// --- the shell ---------------------------------------------------------------
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
'nav.inbox': 'Inbox',
'nav.settings': 'Settings',
'nav.signOut': 'Sign out',
'nav.main': 'Main menu',
'nav.search': 'Search',
'nav.searchLong': 'Search tasks and projects',
'nav.noResults': 'Nothing found.',
'nav.showSidebar': 'Show the sidebar',
'nav.hideSidebar': 'Hide the sidebar',
'nav.showMenu': 'Show menu',
'nav.sidebarWidth': 'Sidebar width',
'nav.toggleTheme': 'Switch between light and dark',
'nav.offline': 'Offline',
'nav.offlineHint': 'Changes from other people are not showing right now',
'nav.doItToday': 'Do it today',
// --- the sidebar's own sections ------------------------------------------------
'sidebar.projects': 'Projects',
'sidebar.shared': 'Shared with me',
'sidebar.filters': 'Filters',
'sidebar.labels': 'Labels',
'sidebar.newProject': 'New project',
'sidebar.newGroup': 'New group',
'sidebar.projectName': 'Project name',
'sidebar.groupName': "The group's name",
'sidebar.noProjects': 'No projects yet.',
'sidebar.emptyGroup': 'Empty — drag a project up here.',
'sidebar.rename': 'Rename',
'sidebar.delete': 'Delete',
'sidebar.fold': 'Collapse {name}',
'sidebar.unfold': 'Expand {name}',
'sidebar.deleteGroup': 'Delete the group "{name}"?',
'sidebar.deleteGroupOne': 'The project in it stays, and ends up without a group.',
'sidebar.deleteGroupMany': 'The {n} projects stay, and end up without a group.',
// --- a task in a list ----------------------------------------------------------
'task.add': 'Add',
'task.new': 'New task',
'task.placeholder': 'Add a task — try “pay VAT tomorrow at 10 p1 #Company”',
'task.complete': 'Mark as done',
'task.reopen': 'Reopen task',
'task.today': 'Today',
'task.tomorrow': 'Tomorrow',
'task.yesterday': 'Yesterday',
'task.overdue': '{n} days overdue',
'task.repeats': 'Repeats {rule}',
'task.assignee': 'Assigned to {name}',
'task.subtasks': '{done} of {total} sub-tasks are done',
'task.attachments': '{n} attached',
'task.priority': 'Priority {n}',
'task.noPriority': 'No priority',
// --- a project group -----------------------------------------------------------
'group.colorOf': 'Colour of {name}',
// --- settings: the frame -------------------------------------------------------
'settings.title': 'Settings',
'settings.tab.account': 'Account',
'settings.tab.notifications': 'Notifications',
'settings.tab.integrations': 'Integrations',
'settings.tab.ai': 'AI',
'settings.tab.tokens': 'API tokens',
'settings.tab.users': 'People',
'settings.tab.history': 'History',
'settings.tab.errors': 'Errors',
'settings.tab.data': 'Data and templates',
// --- settings: the account -----------------------------------------------------
'account.profile': 'Profile',
'account.profileHint':
'The timezone is not decoration: every date in the app is resolved in it, so “tomorrow at 9” means something else once you change it.',
'account.name': 'Name',
'account.email': 'Email',
'account.emailFixed': 'Cannot be changed here.',
'account.timezone': 'Timezone',
'account.parseLanguage': 'Language',
'account.danish': 'Dansk',
'account.english': 'English',
'account.parseHint':
'Both the interface’s language and the grammar quick add reads a line with — “hver mandag” or “every monday”.',
'account.save': 'Save',
'account.saved': 'Saved.',
'account.appearance': 'Appearance',
'account.appearanceHint':
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
'account.password': 'Password',
'account.passwordHint':
'Changing it signs every other session out — which is usually the reason to do it.',
'account.currentPassword': 'Current password',
'account.newPassword': 'New password',
'account.changePassword': 'Change password',
'account.passwordChanged': 'Changed.',
'account.totp': 'Two-factor',
'account.totpHint': 'A one-time code from an authenticator app, on top of the password.',
'account.writeThemDown': 'Write them down now.',
'account.recoveryHint': 'They are shown this once — the server only has their hash.',
'account.gotThem': 'I have them',
'account.passwordRequired':
'Required for both actions below. Turning two-factor off is exactly what a stranger holding your session would do first.',
'account.newRecoveryCodes': 'New recovery codes',
'account.totpOff': 'Turn two-factor off',
'account.scanHint': 'Scan it in your authenticator app, or type the key in by hand.',
'account.codeFromApp': 'The code from the app',
'account.totpProofHint':
'Two-factor is only on once a code has proved the app really has the key.',
'account.confirm': 'Confirm',
'account.cancel': 'Cancel',
'account.totpOn': 'Turn two-factor on',
'account.devices': 'Devices',
'account.devicesHint':
'Where you are signed in. If you do not recognise one, sign it out and change your password.',
'account.thisDevice': 'this device',
'account.signOutDevice': 'Sign out',
'account.totpEnabled': 'On.',
'account.recoveryLeftOne': '{n} recovery code left.',
'account.recoveryLeftMany': '{n} recovery codes left.',
// --- settings: notifications ---------------------------------------------------
'push.title': 'Notifications on this device',
'push.hint':
'Reminders and messages from other people, even with the tab closed. The message is encrypted to your browser on the way — the push service cannot read it.',
'push.unsupported':
'This browser does not support it. Safari needs the app added to the home screen; over plain HTTP it only works on localhost.',
'push.blocked':
'The browser has blocked notifications for this site. That can only be changed in the browser’s own settings — a page cannot ask again once the answer has been no.',
'push.on': 'On for this device.',
'push.turnOff': 'Turn off',
'push.turnOn': 'Turn on for this device',
'push.perBrowser': 'Applies only to the browser you are in right now.',
'push.recent': 'Recent',
'push.none': 'Nothing yet.',
'push.open': 'Open',
'push.markAllRead': 'Mark all as read',
'push.version': 'Version',
'push.running': 'Running',
'push.seeRelease': 'See the release',
// --- settings: API tokens ------------------------------------------------------
'tokens.title': 'API tokens',
'tokens.hint':
'What a script, a CalDAV client or an MCP connector signs in with. A token carries exactly your permissions — it can do nothing you cannot.',
'tokens.copyNow': 'Copy it now.',
'tokens.onceOnly': 'It is shown this once — the server only has its hash.',
'tokens.copy': 'Copy',
'tokens.copied': 'Copied.',
'tokens.done': 'Done',
'tokens.mcpBefore': 'Using it as an',
'tokens.mcpLink': 'MCP connector',
'tokens.mcpAfter': 'in Claude? Then this is the address the dialog wants:',
'tokens.copyAddress': 'Copy the address',
'tokens.name': 'Name',
'tokens.namePlaceholder': 'e.g. my laptop, or the backup script',
'tokens.expires': 'Expires',
'tokens.never': 'Never',
'tokens.in30': 'In 30 days',
'tokens.in90': 'In 90 days',
'tokens.inYear': 'In a year',
'tokens.expiryHint':
'A calendar client that has been subscribed for years needs “never”. A script you run once does not.',
'tokens.create': 'Create a token',
'tokens.none': 'No tokens yet.',
'tokens.revoke': 'Revoke',
// --- settings: AI --------------------------------------------------------------
'ai.title': 'AI',
'ai.hint':
'Used in two places: splitting a task into sub-tasks, and writing a short overview of what is outstanding. The key stays on your server and is only sent to the provider you pick here.',
'ai.provider': 'Provider',
'ai.off': 'Off',
'ai.model': 'Model',
'ai.baseURL': 'Address',
'ai.apiKey': 'API key',
'ai.save': 'Save',
'ai.saved': 'Saved.',
'ai.weekly': 'Weekly overview',
'ai.weeklyHint': 'A short note on what looks most important right now.',
'push.unreadOne': '{n} unread.',
'push.unreadMany': '{n} unread.',
'ai.keyStored': 'A key is stored — leave this blank',
'ai.keyBlankHint':
'The field is blank on purpose. The key is never sent back to the browser — a settings page that refills a password field is one that will one day leak a key into a screenshot.',
'ai.keyNewHint': 'Stored on the server and never shown again.',
'tokens.lastUsed': 'last used {when}',
'tokens.neverUsed': 'never used',
'tokens.expired': 'expired {when}',
'tokens.expiresOn': 'expires {when}',
'tokens.revokeNamed': 'Revoke {name}',
// --- settings: people ----------------------------------------------------------
'users.invite': 'Invite somebody',
'users.inviteHint':
'There is no open registration. You send a link and the person picks their own name and password — so there is never a password two people know.',
'users.emailAddress': 'Email address',
'users.emailPlaceholder': 'name@example.com',
'users.mustBeEmail': 'Must be an email address.',
'users.sendInvite': 'Send invitation',
'users.inviteMade': 'The invitation is ready. Send the link to them:',
'users.pending': 'Awaiting a reply',
'users.pendingHint':
'Invitations sent and not used yet. Only the link’s hash is stored, so it cannot be shown again — if it went to the wrong person, revoke it.',
'users.revoke': 'Revoke',
'users.accounts': 'Accounts',
'users.accountsHint': 'Everybody on this instance.',
'users.admin': 'administrator',
'users.you': 'you',
'users.makeAdmin': 'Make administrator',
'users.removeAdmin': 'Remove administrator',
'users.delete': 'Delete',
'users.deleteQuestion': 'Delete {name} <{email}> for good?',
'users.deleteTakes': 'That deletes {projects} and the {tasks} in them.',
'users.deleteLeaves': '{tasks} in other people’s projects stay, without an author.',
'users.deleteFinal': 'It cannot be undone. There is no trash for accounts.',
'users.revokeQuestion': 'Revoke the invitation to {email}? The link stops working.',
'users.projectOne': 'project',
'users.projectMany': 'projects',
'users.taskOne': 'task',
'users.taskMany': 'tasks',
'users.never': 'never seen',
// --- settings: history and errors -----------------------------------------------
'history.title': 'History',
'history.hint':
'Everything that has been done on this server, across every project — including the ones you are not in. A project’s own history panel shows the same rows one project at a time; this is the question those cannot answer. Rows stay when an account is deleted: what was done stops having a name, not having happened.',
'history.person': 'Person',
'history.all': 'All',
'history.project': 'Project',
'history.event': 'Event',
'history.clear': 'Clear',
'history.more': 'Load more',
'history.loading': 'Loading…',
'history.noMatch': 'Nothing matches the filters.',
'history.nothing': 'Nothing has been recorded yet.',
'history.deletedAccount': 'A deleted account',
'errors.title': 'Server errors',
'errors.hint':
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
'errors.none': 'No errors recorded. That is the right state.',
// --- the views -------------------------------------------------------------------
'view.delegated': 'Waiting on others',
'view.delegatedEmpty': 'You are not waiting on anybody.',
'view.labelEmpty': 'No open tasks with this label.',
'view.filterEmpty': 'Nothing matches right now.',
'view.filterFailed': 'The filter could not be run.',
'view.upcoming': 'Upcoming',
'view.mode': 'View',
'view.list': 'List',
'view.week': 'Week',
'view.month': 'Month',
'view.board': 'Board',
'view.calendar': 'Calendar',
'view.emptySection': 'Empty',
'view.addSection': '+ Add a section',
'view.sectionName': 'The section’s name',
'view.newSection': 'New section',
'view.prevMonth': 'Previous month',
'view.nextMonth': 'Next month',
'view.prevWeek': 'Previous week',
'view.nextWeek': 'Next week',
'view.weekNumber': 'week {n}',
'view.more': '+{n} more',
// --- how long ago ----------------------------------------------------------------
'when.justNow': 'just now',
'when.minutes': '{n} min. ago',
'when.hours': '{n} hours ago',
'when.days': '{n} days ago',
'when.never': 'never',
'when.neverSignedIn': 'never signed in',
// --- settings: data ---------------------------------------------------------------
'data.import': 'Import from Todoist',
'data.csvFile': 'CSV file',
'data.importing': 'Importing …',
'data.openProject': 'Open the project',
'data.trash': 'Trash',
'data.trashEmpty': 'The trash is empty.',
'data.restore': 'Restore',
'data.backups': 'Backups',
'data.backupNow': 'Take one now',
'data.backingUp': 'Copying…',
'data.backupMade': 'Backup written — {size}',
'data.backupFailed': 'failed',
'data.download': 'Download',
'data.noBackups': 'No backup has been taken yet.',
'data.swept': 'swept away',
'data.empty': 'empty',
'data.export': 'Export',
'data.exportAccount': 'The whole account as JSON',
'data.exportOne': 'One project',
'data.pickProject': 'Pick a project',
'data.csv': 'CSV',
'data.ics': 'Calendar file',
'data.templates': 'Templates',
'data.saveAsTemplate': 'Save a project as a template',
'data.templateName': 'Name for the template',
'data.saveTemplate': 'Save as a template',
'data.noTemplates': 'No templates yet.',
'data.use': 'Use',
'data.delete': 'Delete',
'data.newProjectName': 'Name for the new project',
'data.startDate': 'Start date',
'data.startDateHint': 'Day zero for the template’s dates. Blank means today.',
'data.createProject': 'Create the project',
// --- a group's own page ------------------------------------------------------------
'group.notFound': 'That group does not exist, or it is not yours.',
'group.failed': 'The group could not be loaded.',
'group.retry': 'Try again',
'group.about': 'About this group',
'group.aboutPlaceholder': 'What kind of work is this?',
'group.writeAbout': 'Write what kind of work this is.',
'group.saving': 'Saving…',
'group.projects': 'Projects',
'group.projectOne': '1 project',
'group.projectMany': '{n} projects',
'group.noProjects':
'No projects in the group yet. Drag one up here in the sidebar, or pick the group when you create one.',
'group.documents': 'Documents',
'group.documentsHint':
'Files that belong to the whole group rather than to one task in it — the contract, the drawing, the agreement everything else rests on.',
'group.remove': 'Remove',
'group.removeNamed': 'Remove {name}',
'group.removeQuestion': 'Remove "{name}"?',
'group.addFile': '+ Upload a file',
'group.uploading': 'Uploading…',
// --- keyboard shortcuts --------------------------------------------------------------
'keys.title': 'Shortcuts',
'data.importHint':
'Pick the CSV export of one project. Priorities are converted, not copied: Todoist writes 4 for what its interface calls P1.',
'data.trashHint':
'Deleted projects, with their tasks. They come back exactly as they were — except for tasks you had deleted individually first; there was a reason for those.',
'data.backupsHint':
'The database is copied every night and the most recent fourteen are kept. Counted, not dated: a container that has been off for a month must not come back and delete all of them for being too old.',
'data.backupsAttachments':
'Attachments are not included. They are content-addressed and only ever added to, so fourteen identical copies would fill the volume they were meant to protect — take the whole of /data if you want everything.',
'data.restoreHint':
'Putting a copy back is deliberately not a button here: a database cannot be swapped under a server that is writing to it, and a half-finished swap destroys the very thing it was meant to save. Stop the container, put the file in place of verdande.db in the data directory, delete the -wal and -shm beside it, and start it again.',
'data.exportHint':
'Everything, in its raw form. That is the guarantee that you can leave — an export that has been tidied up is an export that has lost something.',
'data.templatesHint':
'A project saved as a shape you can use again. The dates in a template are relative, so they are worked out from the start date you pick.',
'keys.navigation': 'Navigation',
'keys.jumpToQuickAdd': 'Jump to the “add a task” field',
'keys.thisPage': 'This page',
'keys.inQuickAdd': 'In quick add',
'keys.intoProject': 'Put the task in a project',
'keys.setLabel': 'Set a label',
'keys.priority': 'Priority',
'keys.date': 'A date — Danish and English',
'keys.time': 'A time',
'keys.clear': 'Clear the field',
// --- signing in ------------------------------------------------------------------
'auth.firstAccount': 'Create the first account. It becomes the administrator.',
'auth.name': 'Name',
'auth.nameRequired': 'Required',
'auth.email': 'Email',
'auth.emailInvalid': 'Must be an email address',
'auth.password': 'Password',
'auth.passwordHint': 'At least 10 characters. A sentence is both easier and stronger.',
'auth.createAccount': 'Create account',
'auth.totpPrompt': 'Enter the code from your authenticator.',
'auth.code': 'Code',
'auth.totpRecovery': 'If you have lost your phone, one of your recovery codes works here.',
'auth.continue': 'Continue',
'auth.resetPrompt': 'We will send a link to choose a new password.',
'auth.sendLink': 'Send the link',
'auth.back': 'Back',
'auth.linkBroken': 'That link is incomplete. Open it from the email again, or ask for a new one.',
'auth.toSignIn': 'To sign in',
'auth.signOut': 'Sign out',
'auth.invited': 'You have been invited. Choose a name and a password.',
'auth.pickNew': 'Choose a new password.',
'auth.newPassword': 'New password',
'auth.newPasswordHint': 'At least 10 characters. Every other device is signed out.',
'auth.savePassword': 'Save the password',
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
'auth.resetSent':
'If that address has an account, a link is on its way. Check the spam filter too.',
'auth.signInPrompt': 'Sign in to continue.',
'auth.signIn': 'Sign in',
'auth.forgot': 'Forgotten your password?',
// --- a task, opened ---------------------------------------------------------------
'detail.task': 'Task',
'detail.close': 'Close',
'detail.text': 'The task’s text',
'detail.description': 'Description',
'detail.descriptionPlaceholder': 'Details, links, whatever has to be remembered',
'detail.priority': 'Priority',
'detail.p1': 'P1 — urgent',
'detail.none': 'None',
'detail.due': 'Due',
'detail.labels': 'Labels',
'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project',
'detail.repeats': 'Repeats',
'detail.monthly': 'Every month',
'detail.yearly': 'Every year',
'detail.assignee': 'Assigned to',
'detail.subtasks': 'Sub-tasks',
'detail.addSubtask': 'Add a sub-task',
'detail.newSubtask': 'New sub-task',
'detail.files': 'Files',
'detail.delete': 'Delete',
'detail.reminders': 'Reminders',
'detail.newReminder': 'New reminder',
'detail.sent': 'sent',
'detail.add': 'Add',
'detail.comments': 'Comments',
'detail.writeComment': 'Write a comment',
'detail.newComment': 'New comment',
'detail.post': 'Post',
'detail.readAs': 'Read as {what}',
'auth.alreadySignedIn': 'You are signed in as {name}. Sign out first, then open the link again.',
'detail.reopen': 'Reopen',
'detail.uploading': 'Uploading …',
'detail.attachFile': 'Attach a file',
// --- settings: integrations --------------------------------------------------------
'int.gmail': 'Gmail',
'int.gmailHint':
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
'int.connectGmail': 'Connect Gmail',
'int.fromEnv':
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
'int.oauthClient': 'OAuth client at Google',
'int.registerHint':
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, enable the Gmail API, and paste the two values here. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
'int.redirectURI': 'Authorised redirect URI',
'int.clientID': 'Client ID',
'int.clientSecret': 'Client secret',
'int.secretStored': 'Stored — fill this in only to change it',
'int.save': 'Save',
'int.saved': 'Saved.',
'int.noClient':
'No OAuth client has been registered with Google yet. An administrator has to do that before this button can be used.',
'int.connectedAs': 'Connected as',
'int.unknownAccount': 'an unknown account',
'int.whatMakesATask': 'What makes a task',
'int.starred': 'Starred',
'int.aLabel': 'A particular label',
'int.both': 'Both',
'int.gmailLabelName': 'The label’s name in Gmail',
'int.gmailLabelExample': 'e.g. Tasks',
'int.fetchNow': 'Fetch now',
'int.disconnect': 'Disconnect',
'int.feed': 'Calendar feed',
'int.feedHint':
'Subscribe in Apple Calendar, Google or Thunderbird. A calendar client cannot sign in, so the key in the address is the whole password — do not share it.',
'int.address': 'Address',
'int.copy': 'Copy',
'int.newLink': 'New link',
'int.newLinkHint':
'A new link breaks every existing subscription immediately. That is the point: it is what you do when the old one has ended up somewhere it should not be.',
'int.mailToTask': 'Mail to task',
'int.mailHint':
'Send or forward an email here and the subject line is read as a quick add — “Invoice Anders p1 #Company” works here too.',
'int.noMailServer':
'No mail server is configured on this server, so the address below cannot receive anything yet. Set VERDANDE_SMTP_HOST and the other SMTP settings, and have the mail server deliver post for todo+* to /inbound/mail.',
'int.yourAddress': 'Your address',
'int.newAddress': 'New address',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
'int.server': 'Server',
'int.caldavAuth':
'The username is your email. The password is an API token — not your real password.',
'int.gmailConnected': 'Gmail is connected.',
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
'int.gmailExpired': 'The attempt expired on the way. Try again.',
'int.gmailInvalid': 'Google’s reply could not be read. Try again.',
'int.gmailFailed': 'Google refused to exchange the code.',
'int.gmailNoRefresh':
'Google sent no refresh token. Remove verdande from your Google account and connect again.',
'int.gmailDenied': 'Access was refused at Google.',
'int.gmailOther': 'Gmail replied: {what}',
// --- a project ----------------------------------------------------------------------
'project.name': 'The project’s name',
'project.clickToRename': 'Click to rename',
'project.color': 'The project’s colour',
'project.pickColor': 'Pick a colour',
'project.delete': 'Delete the project',
'project.share': 'Share',
'project.history': 'History',
'project.noHistory': 'Nothing has happened here yet.',
'project.canEdit': 'Can edit',
'project.canView': 'Can only view',
'project.invite': 'Invite',
'project.inviteByEmail': 'Invite by email',
'project.emailAddress': 'email address',
'project.role': 'Role',
'project.sendLinkYourself': 'No mail server configured — send the link yourself:',
'project.noSection': 'No section',
'project.rename': 'Rename',
'project.deleteSection': 'Delete',
'project.readOnly': 'You can see this project, but not change it.',
'project.notFound': 'That project does not exist, or you do not have access to it.',
'project.loadFailed': 'The project could not be loaded. The server did not answer.',
'project.retry': 'Try again',
'project.nothingHere': 'Nothing here yet.',
'project.sharedWith': 'Shared · {n}',
// --- the palette ---------------------------------------------------------------
'color.graphite': 'Graphite',
'color.tomato': 'Tomato',
'color.amber': 'Amber',
'color.lime': 'Lime',
'color.green': 'Green',
'color.teal': 'Teal',
'color.blue': 'Blue',
'color.indigo': 'Indigo',
'color.violet': 'Violet',
'color.magenta': 'Magenta',
// --- what a failed request says -----------------------------------------------
'error.unauthorized': 'Wrong email or password.',
'error.totpRequired': 'Enter the code from your authenticator.',
'error.forbidden': 'You do not have access to that.',
'error.notFound': 'Not found.',
'error.conflict': 'That already exists.',
'error.rateLimited': 'Too many attempts. Wait a moment.',
'error.validation': 'Check the fields below.',
'error.tooLarge': 'That is too large.',
'error.internal': 'Something went wrong. Try again.',
'error.badRequest': 'The request could not be read.',
'error.offline': 'No connection to the server.',
'error.gmailNotConfigured':
'Gmail is not set up on this server. An administrator has to register an OAuth client with Google under Settings → Integrations.',
'error.aiNotConfigured': 'No AI provider has been chosen. Set one up under Settings → AI.',
'error.totpNotEnabled': 'Two-factor is not on.',
'error.inboxProtected': 'The Inbox cannot be deleted.',
'error.lastAdmin':
'That is the last administrator. Make somebody else an administrator first — otherwise nobody can get back into this page.',
// --- the themes -------------------------------------------------------------------
'theme.dark': 'Northern lights',
'theme.darkNote': 'Dark, with a green accent.',
'theme.dusk': 'Dusk',
'theme.duskNote': 'Dark and warm, with amber.',
'theme.light': 'Daylight',
'theme.lightNote': 'Light and coolly neutral.',
'theme.paper': 'Paper',
'theme.paperNote': 'Light and warm, like paper.',
'theme.contrast': 'Contrast',
'theme.contrastNote': 'Black on white, for bright light.',
// --- push, in the browser's own words ----------------------------------------------
'push.deniedByBrowser':
'The browser has blocked notifications for this site. It has to be turned on in the browser’s own settings.',
'push.notEnabled': 'Notifications were not turned on.',
'push.noVapidKey': 'The server has no VAPID key.',
'view.done': 'Done',
'view.showDone': 'Show done',
'view.hideDone': 'Hide done'
};
+4 -3
View File
@@ -8,6 +8,7 @@
*/
import { api } from './api.js';
import { t } from './i18n.svelte.js';
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
* can any browser on a page served over plain HTTP from a non-localhost host. */
@@ -56,14 +57,14 @@ export async function subscribe() {
if (granted !== 'granted') {
throw new Error(
granted === 'denied'
? 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.'
: 'Notifikationer blev ikke slået til.'
? t('push.deniedByBrowser')
: t('push.notEnabled')
);
}
const reg = await registration();
const { public_key } = await api.pushKey();
if (!public_key) throw new Error('Serveren har ingen VAPID-nøgle.');
if (!public_key) throw new Error(t('push.noVapidKey'));
const subscription = await reg.pushManager.subscribe({
// Not optional in Chrome: a subscription that is not userVisibleOnly is

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