Compare commits

...
11 Commits
Author SHA1 Message Date
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
Kristian WindandClaude Opus 5 fc32fea607 fix(projects): a section can be dropped into, and a failed load says so
Release / Build and publish image (push) Failing after 9m53s
Two things from the same report — an external member who could see a
project but could not get work into it.

**A section with no tasks could not be filled by dragging.** Only the task
rows accepted a drop, so the target was always another row; a section with
none had nothing to aim at, and a section you have just made is empty by
definition, which is exactly when you want to put something in it. The
board has taken a drop on the column since it was written. The list now
does the same: the section is the target, and a drop on it puts the task at
the end. Dropping on a row still means "here exactly" and stops there
rather than bubbling to the section behind it.

The unsectioned block at the top is a target too, which is how a task comes
back *out* of a section — previously only possible if some other loose task
happened to be there to aim at.

**And the page said "Projektet findes ikke, eller du har ikke adgang til
det" for three different things.** `project` started null with no loading
state, so that sentence was what you saw before the first request returned,
what you saw when it failed, and what you saw when you genuinely had no
access — and the effect never asked again, so the wrong one stayed until
somebody reloaded. That is very likely what was reported: the container was
restarted twice while people had the app open, and a request that lands
mid-restart leaves the page insisting the project does not exist.

Four states now. Loading renders nothing, as the shell does. 404 keeps the
old sentence, since the server deliberately answers 404 for both "no such
project" and "not yours" and the interface must not guess between them.
Anything else says the request failed and offers to try again.

What is *not* a bug: a viewer cannot create or move tasks. Checked at the
API — an editor invited by link gets 201 on create, 201 on quick add and
200 on a move into a section; a viewer gets 404 on all of them, which is
the rule working. If the person reporting this cannot add tasks, their
membership is viewer rather than editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:04:55 +02:00
Kristian WindandClaude Opus 5 8b1f374855 test(e2e): drive a member opening a project, not just seeing it in the sidebar
The invite test proved the shared project reached the invitee's sidebar and
stopped there. A member's project page is a different path from an owner's
— different role, different guards — and it was never opened by anything.

It also matters that the page shows "Projektet findes ikke, eller du har
ikke adgang til det" while it is still loading, because `project` starts
null and there is no loading state. A failed load and a slow one look
identical, which makes this exactly the kind of thing a person reports and
a test suite cannot see.

Signs in as the invited account in its own context and clicks through to
the project, asserting the heading renders and that message is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:54:26 +02:00
Kristian WindandClaude Opus 5 550f420c59 feat(sidebar): colours you can choose, and projects indented under their group
`projects.color` has been in the schema since the first migration, has
defaulted to 'graphite' on every row ever written, and nothing has ever
read it. There was no palette, no name-to-colour mapping, and the dot in
the sidebar was hardcoded to a border grey. So this is not "add a picker";
it is implementing colour for the first time.

Names, not hex values. A hex in ten thousand rows is a decision nobody can
revisit; a name is resolved when the interface paints and can be retuned
for a new theme or a new display. It is also the difference between a
database that stores a fact and one that stores a rendering. The set is
closed and refused at the edge — an unknown name would be stored happily
and painted as the default, which looks like a colour that did not save.

One palette across all five themes, and this is the one place in app.css
that is not redefined per theme. Everything else there is text, a ground or
a border and has to be measured against what sits behind it. These are
small solid marks beside a label that already carries the meaning: what
they owe is to be told apart, and to be visible on both a near-black and a
white ground, which is why all ten sit in the mid-tone band. It also keeps
them out of the trap the themes are in — a colour added to four blocks and
forgotten in the fifth looks finished and falls back silently. There is no
fifth block to forget.

The list is written down three times: Go for validation, colors.js for the
picker, app.css for the tokens that paint. So a test reads the other two —
the same small-scanner approach openapi_test.go takes to the spec — and
fails if the server accepts a colour nothing can draw, or the picker offers
one the server refuses.

Groups get a colour of their own (migration 0006) and projects inside a
group are indented. The indent is to the group's *name*, not to its dot:
the eye follows the text, and lining the projects up under the dot leaves
them looking a step short of where the heading starts. The drop line is
indented with them, so a drop inside a group reads as landing in the group.

The group's dot is the control as well as the preview — you press the thing
you are about to change, as on the project page. That was not the first
design: a third text action, "Farve", sat beside Omdøb and Slet, and those
carry `opacity: 0` rather than `display: none`, so they take their width
whether or not you can see them. A group called "Arbejde" rendered as
"ARBEJD". Moving the control onto the dot gave the name its room back.

The smoke test measures the indent at the content edge rather than the
element's, because the indent is padding and the box does not move — which
is how the first version of that assertion passed 12 against 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:50:20 +02:00
56 changed files with 2364 additions and 233 deletions
+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}" \
+5
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 |
+142 -2
View File
@@ -856,6 +856,60 @@ paths:
"204": { description: Deleted. }
"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]
@@ -1211,9 +1265,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 +1406,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]
@@ -2549,7 +2670,7 @@ components:
properties:
id: { type: string }
name: { type: string }
color: { type: string }
color: { $ref: "#/components/schemas/Color" }
icon: { type: string }
view_mode: { type: string, enum: [list, board, calendar] }
owner_id: { type: string }
@@ -2570,7 +2691,7 @@ components:
type: object
properties:
name: { type: string }
color: { type: string }
color: { $ref: "#/components/schemas/Color" }
icon: { type: string }
view_mode: { type: string, enum: [list, board, calendar] }
archived: { type: boolean }
@@ -2588,6 +2709,7 @@ components:
properties:
id: { type: string }
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
collapsed: { type: boolean }
sort_order: { type: number }
@@ -2595,8 +2717,26 @@ components:
type: object
properties:
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
collapsed: { type: boolean }
Color:
type: string
default: graphite
enum: [graphite, tomato, amber, lime, green, teal, blue, indigo, violet, magenta]
description: |
A name, not a hex value. The interface resolves it when it paints, so the
palette can be retuned for a new theme or a new display without rewriting
every row — and a database that stores a fact rather than a rendering.
The set is closed: an unknown name is refused rather than stored and
painted as the default, which would look like a colour that did not save.
One palette for all five themes. These are small solid marks beside a
label that carries the meaning, not text or grounds, so what they owe is
to be told apart and to be visible on both a near-black and a white
ground — which is why they all sit in the mid-tone band.
Section:
type: object
properties:
+39 -17
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"
)
@@ -88,7 +90,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 +132,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 +212,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 +265,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 +279,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 +330,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 +343,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 +357,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 +366,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 +395,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 +433,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 +471,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 +479,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 +522,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")
}
+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)
+12 -12
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
}
@@ -306,7 +306,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
s.storeError(w, r, "attachment task", err)
return
}
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
@@ -316,7 +316,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
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
}
@@ -358,7 +358,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
s.storeError(w, r, "attachment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
@@ -369,7 +369,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
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)
+5 -5
View File
@@ -37,13 +37,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 +53,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 +83,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)
@@ -138,7 +138,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)
+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)
+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)
+25 -8
View File
@@ -19,18 +19,22 @@ import (
type projectGroupJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Collapsed bool `json:"collapsed"`
SortOrder float64 `json:"sort_order"`
}
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
return projectGroupJSON{ID: g.ID, Name: g.Name, Collapsed: g.Collapsed, SortOrder: g.SortOrder}
return projectGroupJSON{
ID: g.ID, Name: g.Name, Color: g.Color,
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
}
}
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))
@@ -42,6 +46,7 @@ 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"`
}
@@ -59,12 +64,20 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
return
}
if req.Color != nil && !store.ValidColor(*req.Color) {
writeFieldErrors(w, map[string]string{"color": colorFieldError})
return
}
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
if req.Color != nil {
g.Color = *req.Color
}
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))
@@ -87,16 +100,20 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
}
req.Name = &name
}
if req.Color != nil && !store.ValidColor(*req.Color) {
writeFieldErrors(w, map[string]string{"color": colorFieldError})
return
}
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
Name: req.Name, Collapsed: req.Collapsed,
Name: req.Name, Color: req.Color, 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))
@@ -109,7 +126,7 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
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})
@@ -128,7 +145,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)
+50
View File
@@ -219,3 +219,53 @@ func groupOf(t *testing.T, ts *testServer, projectID string) string {
t.Fatalf("project %s is not in the listing", projectID)
return ""
}
// Colour is a name from a closed set, on both a project and a group.
//
// Refused rather than stored: an unknown name would be written happily and then
// painted as the default, which looks like a colour that did not save.
func TestAColourMustBeOneFromThePalette(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)
if group["color"] != "graphite" {
t.Errorf("a new group's colour = %v, want graphite", group["color"])
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
for _, c := range []struct {
what, method, path string
}{
{"group", "PATCH", "/api/v1/project-groups/" + groupID},
{"project", "PATCH", "/api/v1/projects/" + projectID},
} {
resp, body := ts.do(t, c.method, c.path, map[string]any{"color": "teal"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s, a real colour: %d %v", c.what, resp.StatusCode, body)
}
if body["color"] != "teal" {
t.Errorf("%s colour = %v, want teal", c.what, body["color"])
}
resp, body = ts.do(t, c.method, c.path, map[string]any{"color": "#ff0000"})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%s, a hex value: status %d, want 422 — body %v", c.what, resp.StatusCode, body)
}
}
// A rename does not disturb the colour, and a recolour does not disturb the
// name: both are PATCH fields on the same row, and the whole point of a PATCH
// is that the one you did not send is the one you did not mean.
_, renamed := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
map[string]any{"name": "Kontoret"})
if renamed["color"] != "teal" {
t.Errorf("colour = %v after a rename, want teal", renamed["color"])
}
if renamed["name"] != "Kontoret" {
t.Errorf("name = %v", renamed["name"])
}
}
+90 -27
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)
@@ -122,7 +122,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if fields := validateProject(name, req.ViewMode); len(fields) > 0 {
if fields := validateProject(name, req.ViewMode, req.Color); len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
@@ -138,7 +138,7 @@ 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
}
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
@@ -158,7 +158,13 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
name = strings.TrimSpace(*req.Name)
req.Name = &name
}
if fields := validateProject(name, req.ViewMode); req.Name != nil && len(fields) > 0 {
// The name is only checked when it was sent — but the colour is checked
// whenever it was, which is why this is two conditions rather than one.
fields := validateProject(name, req.ViewMode, req.Color)
if req.Name == nil {
delete(fields, "name")
}
if len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
@@ -168,7 +174,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
@@ -178,7 +184,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
}
}
@@ -186,7 +192,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))
@@ -200,7 +206,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)
@@ -224,7 +230,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
}
@@ -246,7 +252,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 {
@@ -254,13 +260,18 @@ 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)
}
func validateProject(name string, viewMode *string) map[string]string {
// colorFieldError names the closed set rather than listing it. The list is ten
// long and lives in three places already; a caller that got it wrong needs to be
// pointed at the documentation, not handed a copy that will one day be stale.
const colorFieldError = "must be one of the colours in the palette — see the Color schema"
func validateProject(name string, viewMode, color *string) map[string]string {
fields := map[string]string{}
switch {
case name == "":
@@ -275,6 +286,11 @@ func validateProject(name string, viewMode *string) map[string]string {
fields["view_mode"] = "must be list, board or calendar"
}
}
// Refused rather than stored and quietly painted as the default, which is what
// an unknown name would do — and which looks like a colour that did not save.
if color != nil && !store.ValidColor(*color) {
fields["color"] = colorFieldError
}
return fields
}
@@ -290,7 +306,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))
@@ -320,7 +336,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})
@@ -334,7 +350,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 {
@@ -365,7 +381,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)
@@ -378,7 +394,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)
@@ -398,7 +414,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))
@@ -447,7 +463,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
}
@@ -459,7 +475,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)})
@@ -469,7 +485,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
@@ -486,13 +502,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 {
@@ -501,7 +564,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})
@@ -513,13 +576,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)
}
}
+89
View File
@@ -194,3 +194,92 @@ 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)
}
}
+3
View File
@@ -264,6 +264,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 +293,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 +305,7 @@ 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)
r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleCreateUser)
r.Patch("/users/{userID}", s.handleUpdateUser)
+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 {
+17 -17
View File
@@ -99,7 +99,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 +116,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 +162,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 +226,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 +302,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 +327,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 +369,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 +394,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 +427,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 +449,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 +502,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 +523,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})
+8 -8
View File
@@ -54,12 +54,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
}
@@ -128,7 +128,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 +170,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 +197,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 +206,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 +218,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 +226,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 {
+67
View File
@@ -277,3 +277,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)
}
}
+15
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
@@ -265,6 +266,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 {
+39
View File
@@ -0,0 +1,39 @@
package store
// The colours a project, group, label or filter may be marked with.
//
// Names rather than hex values, and the reason is what happens later: a hex in
// ten thousand rows is a decision nobody can revisit, while a name is resolved by
// the interface at the moment it paints and can be retuned for a new theme, a new
// palette, or a display nobody has yet. It is also the difference between a
// database that stores a fact and one that stores a rendering.
//
// The list is closed. An unknown name would be stored happily and then paint as
// the default, which looks like a colour that did not save — so it is refused at
// the edge instead. web/src/lib/colors.js carries the same ids for the picker, and
// a test reads that file to check the two have not drifted.
var Colors = []string{
"graphite",
"tomato",
"amber",
"lime",
"green",
"teal",
"blue",
"indigo",
"violet",
"magenta",
}
// DefaultColor is what an unmarked thing is, and what the schema defaults to.
const DefaultColor = "graphite"
// ValidColor reports whether a name is one this app can paint.
func ValidColor(name string) bool {
for _, c := range Colors {
if c == name {
return true
}
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The palette is written down three times: here, in web/src/lib/colors.js for the
// picker, and in web/src/app.css as the tokens that actually paint. Three copies
// is two chances to drift, and the failure is quiet — a colour the server accepts
// and the interface cannot draw renders as the default, which looks like a colour
// that did not save.
//
// So this reads the other two. A small scanner rather than a parser, the same
// approach openapi_test.go takes to the spec: it needs the ids out of a list this
// repository also owns, and that is worth more than a dependency in a project
// whose whole shape is "one static binary".
func TestThePaletteIsTheSameInGoAndInTheFrontend(t *testing.T) {
js := readFrontendColors(t, filepath.Join("..", "..", "web", "src", "lib", "colors.js"))
css := readColorTokens(t, filepath.Join("..", "..", "web", "src", "app.css"))
for _, name := range Colors {
if !js[name] {
t.Errorf("%q is accepted by the server and missing from colors.js — the picker cannot offer it", name)
}
if !css[name] {
t.Errorf("--color-%s is missing from app.css — the server accepts a colour nothing can paint", name)
}
}
for name := range js {
if !ValidColor(name) {
t.Errorf("colors.js offers %q, which the server refuses — picking it would fail to save", name)
}
}
}
// readFrontendColors pulls the ids out of the COLORS array: lines shaped
// `{ id: 'tomato', name: 'Tomat' },`.
func readFrontendColors(t *testing.T, path string) map[string]bool {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read colors.js: %v", err)
}
found := map[string]bool{}
for _, line := range strings.Split(string(body), "\n") {
id, ok := between(line, "{ id: '", "'")
if ok {
found[id] = true
}
}
if len(found) == 0 {
t.Fatal("no colours were read out of colors.js — the scanner and the file disagree")
}
return found
}
// readColorTokens pulls the names out of `--color-tomato: #d9614f;`.
func readColorTokens(t *testing.T, path string) map[string]bool {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read app.css: %v", err)
}
found := map[string]bool{}
for _, line := range strings.Split(string(body), "\n") {
name, ok := between(line, "--color-", ":")
if ok {
found[name] = true
}
}
if len(found) == 0 {
t.Fatal("no --color- tokens were read out of app.css")
}
return found
}
func between(line, open, close string) (string, bool) {
start := strings.Index(line, open)
if start < 0 {
return "", false
}
rest := line[start+len(open):]
end := strings.Index(rest, close)
if end <= 0 {
return "", false
}
return rest[:end], true
}
+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,11 @@
-- A colour on a sidebar group, so a group can be marked the way a project is.
--
-- Projects, labels and filters have carried a `color` since 0001; groups arrived
-- in 0005 without one because nothing rendered any of them yet. Now that the
-- sidebar paints them, a group without one would be the only thing in the list
-- that could not be told apart at a glance.
--
-- Same default and same spelling as the others: a colour name, not a hex. The
-- value has to survive a theme change and a redesign, and '#8a8f98' in ten
-- thousand rows is a decision nobody can revisit.
ALTER TABLE project_groups ADD COLUMN color TEXT NOT NULL DEFAULT 'graphite';
@@ -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);
+13 -7
View File
@@ -18,6 +18,7 @@ type ProjectGroup struct {
ID string
OwnerID string
Name string
Color string
Collapsed bool
SortOrder float64
CreatedAt time.Time
@@ -26,7 +27,7 @@ type ProjectGroup struct {
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE owner_id = ?
ORDER BY sort_order, created_at`, userID)
if err != nil {
@@ -39,7 +40,7 @@ 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, &collapsed, &g.SortOrder,
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder,
&created, &updated); err != nil {
return nil, err
}
@@ -59,9 +60,9 @@ 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, collapsed, sort_order, created_at, updated_at
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder, &created, &updated)
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -80,6 +81,9 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
}
now := time.Now().UTC()
g.CreatedAt, g.UpdatedAt = now, now
if g.Color == "" {
g.Color = DefaultColor
}
if g.SortOrder == 0 {
var max sql.NullFloat64
if err := db.QueryRowContext(ctx,
@@ -90,14 +94,15 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
g.SortOrder = max.Float64 + 1024
}
_, err := db.ExecContext(ctx,
`INSERT INTO project_groups (id, owner_id, name, collapsed, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.OwnerID, g.Name, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
`INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.OwnerID, g.Name, g.Color, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
return err
}
type ProjectGroupUpdate struct {
Name *string
Color *string
Collapsed *bool
SortOrder *float64
}
@@ -105,6 +110,7 @@ type ProjectGroupUpdate struct {
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,
})
+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
+44
View File
@@ -274,6 +274,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
+259 -7
View File
@@ -153,9 +153,10 @@ 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',
'Fejl',
'Data og skabeloner'
]) {
await tabs.getByRole('link', { name: label, exact: true }).click();
@@ -183,6 +184,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,7 +263,10 @@ 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');
const heading = sidebar.getByRole('button', { name: 'Arbejde' });
// `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 });
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
@@ -295,11 +305,38 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await heading.click();
await expect(heading).toHaveAttribute('aria-expanded', 'false');
await page.reload();
await expect(sidebar.getByRole('button', { name: 'Arbejde' })).toHaveAttribute(
await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute(
'aria-expanded',
'false'
);
await sidebar.getByRole('button', { name: 'Arbejde' }).click();
await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).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();
await sidebar.getByRole('button', { name: 'Petrol' }).click();
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)');
// 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:
// the indent is padding, so the element's own left edge does not move.
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
const contentLeft = (el) =>
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
const [inside, outside] = await Promise.all([
sidebar.locator('.folder > a').first().evaluate(contentLeft),
sidebar.locator('.views a').first().evaluate(contentLeft)
]);
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
await sidebar
.getByRole('link', { name: 'Regnskab' })
.dragTo(sidebar.getByRole('heading', { name: 'Projekter' }));
// Renaming writes one row rather than one per project, which is the reason a
// group is a table and not a string repeated on every project.
@@ -307,19 +344,72 @@ 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' })).toBeVisible();
await expect(sidebar.getByRole('button', { 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' })).toBeHidden();
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* A task can be dropped into a section, including one with nothing in it.
*
* Only the task *rows* took a drop, so a section with no rows had nothing to aim
* at — and a section you have just made is always empty, which is exactly when
* you want to fill it. The board has always taken a drop on the column; the list
* had no equivalent.
*
* The second half is the other thing that made this hard to report: the page used
* to say "Projektet findes ikke, eller du har ikke adgang til det" whenever the
* load failed *or* had not finished, so a dropped connection and a project you
* cannot see were one sentence, and it never asked again.
*/
test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning siger det', 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('Risteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await page.getByLabel('Ny opgave').fill('brænde kaffe');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('brænde kaffe')).toBeVisible();
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
await page.getByLabel('Ny sektion').fill('Uge 34');
await page.getByLabel('Ny sektion').press('Enter');
const section = page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Uge 34' }) });
await expect(section.getByText('Tom')).toBeVisible();
await page.locator('.sortable').filter({ hasText: 'brænde kaffe' }).dragTo(section);
await expect(section.getByText('brænde kaffe')).toBeVisible();
await expect(page.locator('.toast')).toHaveCount(0);
// A load that fails says so, and offers to try again.
await page.route('**/api/v1/projects/*', (r) => r.abort());
await page.reload();
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
await page.unroute('**/api/v1/projects/*');
await page.getByRole('button', { name: 'Prøv igen' }).click();
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
});
/**
* A task can be dragged onto another day, and onto another project.
*
@@ -355,6 +445,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' }));
@@ -443,6 +541,26 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
await context.close();
// The invitee opens the project. Nothing has ever driven this: the assertion
// above only proved the project reaches their *sidebar*, and a member's view
// of a project page is a different code path from an owner's — different role,
// different guards, and the page shows the same "findes ikke" message while it
// is still loading as it does when the load failed.
const inviteePage = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const member = await inviteePage.newPage();
const memberTrouble = watchForTrouble(member);
await member.goto('/');
await member.getByLabel('E-mail').fill('nabo@example.dk');
await member.getByLabel('Adgangskode').fill('et langt kodeord til test');
await member.getByRole('button', { name: 'Log ind' }).click();
const theirs = member.getByRole('navigation', { name: 'Hovedmenu' });
await theirs.getByRole('link', { name: /Fælleshuset/ }).click();
await expect(member.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
await expect(member.getByText('findes ikke')).toHaveCount(0);
expect(memberTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
await inviteePage.close();
// Now that there is somebody to delegate to, the inviter can — and the view
// for it appears. It is hidden on an instance with one person, where it could
// never have anything in it.
@@ -467,7 +585,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([]);
});
@@ -602,6 +723,137 @@ 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');
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([]);
});
/**
* 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([]);
});
/**
* The MCP connector address must not fall through to the app shell.
*
+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();
+28
View File
@@ -45,6 +45,34 @@
--danger: #cf6b6b;
--danger-sunken: #33201f;
/* Marks for projects, groups, labels and filters.
*
* The one set of colours in this file that is *not* redefined per theme, and
* on purpose. Everything else here is text, a ground or a border, and has to
* be measured against whatever sits behind it. These are none of those: they
* are small solid dots beside a label that carries the meaning, so what they
* owe is to be told apart from one another and to be visible on both a
* near-black and a white ground. All of them sit in the mid-tone band where
* that is true, which is why none is very dark or very pale.
*
* It also keeps them out of the trap the themes are in: a colour added to four
* blocks and forgotten in the fifth looks finished and falls back silently.
* There is no fifth block to forget.
*
* The ids are what the database stores. web/src/lib/colors.js lists them for
* the picker, and a Go test reads that file to check the server's list of
* accepted names has not drifted from it. */
--color-graphite: #8a8f98;
--color-tomato: #d9614f;
--color-amber: #d9a04f;
--color-lime: #a3b74d;
--color-green: #4fae7a;
--color-teal: #45b0ab;
--color-blue: #5b9bd9;
--color-indigo: #7f83d6;
--color-violet: #a86fd0;
--color-magenta: #d46aa8;
/* Type. System stack: it renders instantly, matches the platform, and has
proper Danish glyphs on every device — a webfont would cost a round trip
and a flash of unstyled text on the one screen people open fifty times a day. */
+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
+4
View File
@@ -178,6 +178,7 @@ 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'),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
@@ -205,6 +206,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 +308,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)}`)
};
+45
View File
@@ -0,0 +1,45 @@
/**
* The colours a project, a group, a label or a filter can be marked with.
*
* The `color` column has been on projects since the first migration, defaulting
* to 'graphite', and nothing has ever read it. This is the list that makes it
* mean something.
*
* **One set for all five themes, deliberately.** Every other colour in this app is
* redefined per theme, because every other colour is text, a ground, or a border,
* and those have to be measured against the surface behind them. These are none of
* those: they are small solid marks sitting next to a label that carries the
* meaning. What they need is to be told apart from each other, and to be visible
* on both a near-black and a white ground — which mid-tone, medium-saturation
* colours are, and which is why they are all in that band.
*
* Defining them once is also what keeps them out of the trap the themes are in:
* a colour added to four theme blocks and forgotten in the fifth looks finished
* and falls back silently. There is no fifth block to forget.
*
* The ids are what the database stores, so they are not to be renamed. `app.css`
* 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.
*/
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' }
];
/** The CSS variable a colour id paints with, falling back to the default. */
export function colorVar(id) {
return COLORS.some((c) => c.id === id) ? `var(--color-${id})` : 'var(--color-graphite)';
}
/** The Danish name, for the swatch's label. */
export function colorName(id) {
return COLORS.find((c) => c.id === id)?.name ?? COLORS[0].name;
}
+152 -3
View File
@@ -2,6 +2,7 @@
import { app, sidebar } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -17,7 +18,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() {
@@ -78,6 +85,8 @@
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();
@@ -221,6 +230,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. */
@@ -303,7 +340,20 @@
</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
</a>
@@ -353,7 +403,7 @@
}}
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
>
<span class="dot" aria-hidden="true"></span>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{project.name}
{#if project.shared}
<span class="count">{project.member_count}</span>
@@ -480,6 +530,20 @@
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. -->
<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>
<h2 class="fold">
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
<svg
@@ -505,6 +569,25 @@
{/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)}
@@ -852,6 +935,72 @@
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. */
.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;
}
/* Projects inside a group are indented to the group's name, not to its dot:
the eye follows the text, and lining up under the dot leaves the names
looking a step short of where the heading starts. */
.folder > a {
padding-left: calc(var(--s2) + var(--s4));
}
/* The line is indented with them, so a drop between two projects in a group
reads as landing in the group rather than across the whole sidebar. */
.folder > a.sortable::before {
left: calc(var(--s2) + var(--s4));
}
.folder > .empty {
padding-left: calc(var(--s2) + var(--s4));
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
padding: var(--s2) var(--s2) var(--s3);
}
.swatch {
width: 16px;
height: 16px;
border-radius: var(--radius-full);
flex: none;
/* A ring in the ground colour, so the chosen one reads as selected without
the swatch changing size and shuffling the row as you move along it. */
box-shadow: 0 0 0 2px var(--surface-sunken);
transition: box-shadow var(--fast) var(--ease);
}
.swatch:hover,
.swatch:focus-visible {
box-shadow:
0 0 0 2px var(--surface-sunken),
0 0 0 3px var(--line-strong);
outline: none;
}
.swatch.chosen {
box-shadow:
0 0 0 2px var(--surface-sunken),
0 0 0 3px var(--ink);
}
.folder-head form {
flex: 1;
min-width: 0;
+16 -2
View File
@@ -575,7 +575,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 +630,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 +893,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;
}
}
+4
View File
@@ -50,6 +50,10 @@
async function onDrop(event, target) {
event.preventDefault();
// The section around this list also takes drops, for the coarse "into here"
// gesture and for sections with no rows to aim at. A drop that landed on a
// row has already said exactly where it goes, so it stops here.
event.stopPropagation();
const id = draggingId;
const below = overBelow;
draggingId = null;
+33 -1
View File
@@ -48,6 +48,8 @@
};
}
// 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
@@ -81,8 +83,19 @@
<span class="description">{task.description}</span>
{/if}
{#if due || task.labels?.length || task.recurrence_text}
{#if assignee || due || task.labels?.length || task.recurrence_text}
<span class="meta">
{#if assignee}
<!-- First in the row, because "who" changes what the rest of the line
means: a date on somebody else's task is their deadline, not
yours. -->
<span class="assignee" title="Ansvarlig: {assignee.name}">
<span class="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}
@@ -259,6 +272,25 @@
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;
}
.label {
color: var(--ink-muted);
background: var(--surface-raised);
+47 -2
View File
@@ -18,6 +18,14 @@ class AppState {
projects = $state([]);
/** The foldable headings over the projects in the sidebar. */
groups = $state([]);
/**
* Everybody you share a project with, and yourself.
*
* Loaded once so a task row can put a name on an `assignee_id` without a
* request per row — and without every list having to know which project each
* task belongs to in order to ask for its members.
*/
people = $state([]);
tasks = $state([]);
loading = $state(true);
/** Transient messages: a failed save, a rolled-back change. */
@@ -52,6 +60,23 @@ class AppState {
return this.projects.find((p) => p.id === id);
}
personById(id) {
return this.people.find((p) => p.id === id) ?? null;
}
/**
* The person a task is waiting on, or null when it is nobody or yourself.
*
* Yourself is nobody, on purpose: every row in your own list would otherwise
* carry your own face, which is a column of the same initial telling you
* nothing. The point of showing an assignee is that it is *not* you.
*/
assigneeOf(task) {
const id = task?.assignee_id;
if (!id || id === this.user?.id) return null;
return this.personById(id) ?? { id, name: 'Ukendt', avatar_color: '#8a8f98' };
}
get detailTask() {
return this.tasks.find((t) => t.id === this.detailId) ?? null;
}
@@ -68,12 +93,14 @@ class AppState {
this.loading = true;
try {
this.user = await api.me();
const [{ projects }, { groups }] = await Promise.all([
const [{ projects }, { groups }, { people }] = await Promise.all([
api.listProjects(),
api.listProjectGroups()
api.listProjectGroups(),
api.people()
]);
this.projects = projects;
this.groups = groups;
this.people = people;
this.connect();
} catch (e) {
this.user = null;
@@ -410,6 +437,10 @@ class AppState {
await this.#patchGroup(id, { name });
}
async setGroupColor(id, color) {
await this.#patchGroup(id, { color });
}
async #patchGroup(id, patch) {
const previous = this.groups.find((g) => g.id === id);
if (!previous) return;
@@ -459,6 +490,20 @@ class AppState {
}
}
/** Marks a project with one of the palette's colours. */
async setProjectColor(projectId, color) {
const previous = this.projects.find((p) => p.id === projectId);
if (!previous || previous.color === color) return;
this.upsertProject({ ...previous, color });
try {
this.upsertProject(await api.updateProject(projectId, { color }));
} catch (e) {
this.upsertProject(previous);
this.toast(humanMessage(e));
}
}
/** Files a project under a group, or takes it out of one when groupId is ''. */
async setProjectGroup(projectId, groupId) {
const previous = this.projects.find((p) => p.id === projectId);
+6 -1
View File
@@ -25,7 +25,12 @@
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
{ href: '/indstillinger/ai', label: 'AI' },
{ href: '/indstillinger/tokens', label: 'API-tokens' },
...(app.user?.is_admin ? [{ href: '/indstillinger/brugere', label: 'Brugere' }] : []),
...(app.user?.is_admin
? [
{ href: '/indstillinger/brugere', label: 'Brugere' },
{ href: '/indstillinger/fejl', label: 'Fejl' }
]
: []),
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
]);
@@ -0,0 +1,138 @@
<script>
/**
* Fejl: what has gone wrong lately, kept where a restart cannot take it.
*
* The panel's watcher already says "HTTP 5xx, twice, at 11:49". That is enough
* to know something broke and nothing at all to act on — the line explaining it
* is in the container's log, and a Rune replaces the container on every
* restart, so it is usually gone before anybody looks. These are the same
* failures written somewhere that survives.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let errors = $state([]);
let loaded = $state(false);
$effect(() => {
api
.listErrors()
.then((r) => {
errors = r.errors;
loaded = true;
})
.catch((e) => app.toast(humanMessage(e)));
});
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
return then.toLocaleString('da-DK', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<section class="panel">
<header>
<h2>Serverfejl</h2>
<p class="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.
</p>
</header>
{#if errors.length}
<ul class="rows">
{#each errors as error (error.id)}
<li>
<div class="what">
<span class="primary-line">
<span class="status">{error.status}</span>
{error.what}
</span>
<span class="secondary">{error.message}</span>
<span class="secondary mono">
{error.method}
{error.path}{#if error.user_name}{' · '}{error.user_name}{/if}{#if error.request_id}{' · ' +
error.request_id}{/if}
</span>
</div>
<span class="when">{when(error.at)}</span>
</li>
{/each}
</ul>
{:else if loaded}
<p class="hint">Ingen fejl registreret. Det er den rigtige tilstand.</p>
{/if}
</section>
<style>
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.rows li {
display: flex;
align-items: flex-start;
gap: var(--s3);
padding: var(--s3) 0;
border-bottom: 1px solid var(--line);
}
.rows li:last-child {
border-bottom: 0;
}
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.primary-line {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
}
.status {
font-size: var(--text-xs);
font-weight: 560;
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0 var(--s1);
}
.secondary {
font-size: var(--text-xs);
color: var(--ink-faint);
overflow-wrap: anywhere;
}
.mono {
font-family: var(--font-mono);
}
.when {
font-size: var(--text-xs);
color: var(--ink-faint);
flex: none;
}
</style>
+442 -24
View File
@@ -4,15 +4,30 @@
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
import TaskList from '$lib/components/TaskList.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
let project = $state(null);
/**
* loading | ready | denied | failed.
*
* Four states rather than "is project null", which is what this was: the page
* showed "Projektet findes ikke, eller du har ikke adgang til det" before the
* first request had even returned, and again if it failed for any reason at
* all. A slow load, a dropped connection, a server being restarted underneath
* you and a project you genuinely cannot see were one message — and it never
* asked again, so the wrong one stayed until somebody reloaded.
*/
let status = $state('loading');
let sections = $state([]);
let members = $state([]);
let showShare = $state(false);
let showLog = $state(false);
let activity = $state([]);
let inviteEmail = $state('');
let inviteRole = $state('editor');
let inviteLink = $state('');
@@ -21,29 +36,94 @@
let id = $derived($page.params.id);
$effect(() => {
const projectId = id;
if (!projectId) return;
Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.loadTasks({ project_id: projectId })
])
.then(([p, s]) => {
project = p;
sections = s.sections;
})
.catch(() => {
project = null;
});
load(id);
});
async function load(projectId) {
if (!projectId) return;
status = 'loading';
try {
const [p, s] = await Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.loadTasks({ project_id: projectId })
]);
project = p;
sections = s.sections;
status = 'ready';
} catch (e) {
project = null;
// 404 is the app's answer for both "no such project" and "not yours" —
// deliberately, so probing ids teaches nothing. Anything else is the
// request itself having failed, which is worth saying differently and
// worth offering to try again.
status = e?.status === 404 ? 'denied' : 'failed';
}
}
$effect(() => {
if (showShare && project) {
api.listMembers(project.id).then((r) => (members = r.members));
}
});
// Loaded when it is opened rather than with the page: it is a record you go
// looking for, not something every visit should pay for.
$effect(() => {
if (showLog && project) {
api.activity(project.id).then((r) => (activity = r.activity)).catch(() => {});
}
});
/**
* What each recorded event is called.
*
* The log has been written since the beginning — nineteen kinds of entry — and
* nothing has ever read it. The keys are the server's and they are stable; this
* is the only place they become Danish.
*
* 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.
*/
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 bit of context an entry carries, when it has one worth reading. */
function detail(entry) {
const p = entry.payload ?? {};
return p.name ?? p.email ?? p.role ?? '';
}
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
}
// The chosen view is kept locally as well as on the project, so switching is
// instant and only persists once the request lands.
let view = $state(null);
@@ -73,6 +153,18 @@
let unsectioned = $derived(open.filter((t) => !t.section_id));
let editing = $state(false);
let choosingColor = $state(false);
/**
* The colour is written to the store as well as here: the sidebar reads its own
* copy of the project list, and a dot that only changed on this page would be
* the same colour in the two places you look at it.
*/
async function setColor(color) {
choosingColor = false;
project = { ...project, color };
await app.setProjectColor(project.id, color);
}
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
async function rename(input) {
@@ -107,6 +199,56 @@
}
}
// --- dropping a task into a section ---------------------------------------------
/**
* The section under the pointer, '' for the unsectioned block at the top.
*
* Only the task *rows* used to accept a drop, so a section with nothing in it
* had nothing to aim at and could not be filled by dragging at all — and a
* section you have just made is always empty. The board has always taken a drop
* on the column; this is the same idea in the list.
*/
let overSection = $state(null);
function onSectionDragOver(event, sectionId) {
if (!canEdit || !carries(event, TASK)) return;
accept(event);
overSection = sectionId;
}
async function onSectionDrop(event, sectionId) {
event.preventDefault();
const id = dragged(event, TASK);
overSection = null;
if (!id || !canEdit) return;
const task = app.get(id);
if (!task || (task.section_id ?? '') === sectionId) return;
// At the end of the section: dropping on the area rather than between two
// rows is the coarse gesture, and landing at the top would push whatever is
// there down.
const existing = open.filter((t) => (t.section_id ?? '') === sectionId && t.id !== id);
const after = [...existing].sort((a, b) => a.sort_order - b.sort_order).at(-1);
const previous = { ...task };
app.replace(id, { ...task, section_id: sectionId });
try {
app.replace(
id,
await api.moveTask(id, {
project_id: project.id,
section_id: sectionId,
after_id: after?.id ?? ''
})
);
} catch (e) {
app.replace(id, previous);
app.toast(humanMessage(e));
}
}
// --- sections -------------------------------------------------------------------
let addingSection = $state(false);
@@ -162,6 +304,33 @@
}
}
/**
* What each standing is called in the interface.
*
* The API's words are `owner`, `editor` and `viewer`, and they were being
* printed straight into a Danish page. These are the same three words the
* invite form already uses, so the list and the form agree.
*/
const ROLES = { owner: 'Ejer', editor: 'Kan redigere', viewer: 'Kan kun se' };
/**
* Changes what somebody may do, without removing them.
*
* Removing and re-inviting was the only way to correct a role, and it
* unassigns every task they were responsible for on the way past — so fixing a
* dropdown cost somebody their work.
*/
async function setMemberRole(member, role) {
const previous = members;
members = members.map((m) => (m.user_id === member.user_id ? { ...m, role } : m));
try {
await api.setMemberRole(project.id, member.user_id, role);
} catch (e) {
members = previous;
app.toast(humanMessage(e));
}
}
async function invite(event) {
event.preventDefault();
inviteError = '';
@@ -216,6 +385,17 @@
{/if}
</h1>
{/if}
{#if isOwner && !project.is_inbox}
<button
class="color"
style="background: {colorVar(project.color)}"
onclick={() => (choosingColor = !choosingColor)}
aria-expanded={choosingColor}
aria-label="Farve på projektet"
title="Farve på projektet"
></button>
{/if}
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
<button
@@ -232,6 +412,12 @@
</button>
{/if}
{#if !project.is_inbox}
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
Historik
</button>
{/if}
{#if isOwner && !project.is_inbox}
<button class="remove" onclick={remove} aria-label="Slet projektet">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -241,6 +427,42 @@
{/if}
</header>
{#if showLog}
<div class="panel">
<ul class="log">
{#each activity as entry (entry.id)}
<li>
<span class="who">{entry.user_name}</span>
<span class="what">
{EVENTS[entry.event] ?? entry.event}{#if detail(entry)}
<span class="detail">{detail(entry)}</span>{/if}
</span>
<span class="when">{when(entry.created_at)}</span>
</li>
{/each}
</ul>
{#if !activity.length}
<p class="empty">Ingenting er sket her endnu.</p>
{/if}
</div>
{/if}
{#if choosingColor}
<div class="panel swatches" role="group" aria-label="Vælg farve">
{#each COLORS as color (color.id)}
<button
class="swatch"
class:chosen={(project.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={color.name}
aria-label={color.name}
aria-pressed={(project.color ?? 'graphite') === color.id}
onclick={() => setColor(color.id)}
></button>
{/each}
</div>
{/if}
{#if showShare}
<div class="panel">
<ul class="members">
@@ -250,7 +472,22 @@
{member.name[0]?.toUpperCase()}
</span>
<span class="member-name">{member.name}</span>
<span class="role">{member.role}</span>
<!-- The owner's standing is not a choice: ownership is transferred,
not granted, and the server refuses it. A disabled dropdown
would be a control that exists to say no. -->
{#if isOwner && member.role !== 'owner'}
<select
class="role-picker"
value={member.role}
aria-label="Rolle for {member.name}"
onchange={(e) => setMemberRole(member, e.currentTarget.value)}
>
<option value="editor">{ROLES.editor}</option>
<option value="viewer">{ROLES.viewer}</option>
</select>
{:else}
<span class="role">{ROLES[member.role] ?? member.role}</span>
{/if}
{#if isOwner && member.role !== 'owner'}
<button
class="remove"
@@ -303,13 +540,31 @@
{:else if mode === 'calendar'}
<CalendarView projectId={project.id} />
{:else}
<section>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class:over={overSection === ''}
ondragover={(e) => onSectionDragOver(e, '')}
ondragleave={() => (overSection = null)}
ondrop={(e) => onSectionDrop(e, '')}
>
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
<!-- Only once there are sections to be "without": on a project with none,
the label is a heading for the only list there is, and the empty
state below already says the project is empty. -->
{#if sections.length && !unsectioned.length}
<p class="empty">Uden sektion</p>
{/if}
</section>
{#each sections as section (section.id)}
{@const tasks = open.filter((t) => t.section_id === section.id)}
<section>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class:over={overSection === section.id}
ondragover={(e) => onSectionDragOver(e, section.id)}
ondragleave={() => (overSection = null)}
ondrop={(e) => onSectionDrop(e, section.id)}
>
<div class="section-head">
{#if renamingSection === section.id}
<form onsubmit={(e) => renameSection(e, section)}>
@@ -384,8 +639,18 @@
{#if project.role === 'viewer'}
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
{/if}
{:else}
{:else if status === 'loading'}
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
<div class="booting"></div>
{:else if status === 'denied'}
<p class="clear">Projektet findes ikke, eller du har ikke adgang til det.</p>
{:else}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Kunne ikke hente projektet. Serveren svarede ikke.
<button class="retry" onclick={() => load(id)}>Prøv igen</button>
</p>
{/if}
</div>
@@ -402,17 +667,28 @@
max-width: 1400px;
}
/* Wraps, and the title keeps a floor.
*
* Without both, a phone squeezes the heading to its min-content — which with
* `overflow-wrap: anywhere` is *one character*, so "Skovvænget" came out as a
* vertical column of letters while the buttons ran off the right edge. Four
* controls beside a title is more than 390px has, so they belong on the next
* line rather than in a race for the same one.
*/
header {
display: flex;
align-items: center;
gap: var(--s4);
gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
flex: 1;
min-width: 0;
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
point where the wrapping below is the better answer. */
flex: 1 1 8ch;
min-width: 8ch;
overflow-wrap: anywhere;
}
@@ -432,8 +708,8 @@
/* Matches the heading it stands in for, so the swap does not move the page. */
.title {
flex: 1;
min-width: 0;
flex: 1 1 8ch;
min-width: 8ch;
font-size: var(--text-2xl);
font-weight: 560;
letter-spacing: -0.015em;
@@ -482,6 +758,56 @@
stroke-linejoin: round;
}
/* A circle in the project's own colour, which is both the control and its own
preview: the thing you are about to change is what you press. */
.color {
flex: none;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
box-shadow: 0 0 0 2px var(--ground);
transition: box-shadow var(--fast) var(--ease);
}
.color:hover,
.color:focus-visible {
box-shadow:
0 0 0 2px var(--ground),
0 0 0 3px var(--line-strong);
outline: none;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: var(--s3);
}
.swatch {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
flex: none;
/* A ring in the panel's own colour, so the selected swatch reads as chosen
without changing size and shuffling the row as you move along it. */
box-shadow: 0 0 0 2px var(--surface);
transition: box-shadow var(--fast) var(--ease);
}
.swatch:hover,
.swatch:focus-visible {
box-shadow:
0 0 0 2px var(--surface),
0 0 0 3px var(--line-strong);
outline: none;
}
.swatch.chosen {
box-shadow:
0 0 0 2px var(--surface),
0 0 0 3px var(--ink);
}
.views {
display: flex;
gap: 1px;
@@ -579,6 +905,24 @@
font-size: var(--text-xs);
}
.role-picker {
flex: none;
padding: var(--s1) var(--s2);
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-muted);
font-size: var(--text-xs);
outline: none;
transition: border-color var(--fast) var(--ease);
}
.role-picker:hover,
.role-picker:focus {
border-color: var(--accent);
color: var(--ink);
}
.remove {
color: var(--ink-faint);
width: 20px;
@@ -626,6 +970,52 @@
font-weight: 550;
}
.log {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
/* Capped and scrolled: a busy project's log is thousands of lines, and a
panel that pushes the tasks off the screen is a panel you close again. */
max-height: 320px;
overflow-y: auto;
}
.log li {
display: flex;
align-items: baseline;
gap: var(--s2);
padding: var(--s2) 0;
border-bottom: 1px solid var(--line);
font-size: var(--text-sm);
}
.log li:last-child {
border-bottom: 0;
}
.who {
font-weight: 500;
flex: none;
}
.what {
color: var(--ink-muted);
flex: 1;
min-width: 0;
}
.detail {
color: var(--ink);
}
.when {
color: var(--ink-faint);
font-size: var(--text-xs);
flex: none;
}
.link-out {
margin: 0;
font-size: var(--text-xs);
@@ -651,6 +1041,14 @@
section {
margin-top: var(--s5);
border-radius: var(--radius);
}
/* The whole section lights up, because the whole section is the target. A line
between two rows means "here exactly"; this means "in this one". */
section.over {
box-shadow: 0 0 0 1px var(--accent);
background: var(--surface-sunken);
}
h2 {
@@ -765,4 +1163,24 @@
color: var(--accent);
opacity: 0.5;
}
.booting {
min-height: 50vh;
}
.retry {
padding: var(--s2) var(--s4);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink-muted);
transition:
border-color var(--fast) var(--ease),
color var(--fast) var(--ease);
}
.retry:hover {
border-color: var(--accent);
color: var(--ink);
}
</style>
+7 -3
View File
@@ -135,17 +135,21 @@
max-width: 1400px;
}
/* Wraps, for the same reason the project header does: a title and a switcher
are more than a phone's width, and the switcher belongs underneath rather
than squeezing the heading. */
header {
display: flex;
align-items: center;
gap: var(--s4);
gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
flex: 1;
min-width: 0;
flex: 1 1 8ch;
min-width: 8ch;
}
.views {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+19 -1
View File
@@ -12,7 +12,25 @@
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}