Compare commits

...
8 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
48 changed files with 1563 additions and 196 deletions
+20 -3
View File
@@ -5,7 +5,17 @@
# manager and no libc — the attack surface of the running container is the binary. # manager and no libc — the attack surface of the running container is the binary.
# --- 1. the web interface ---------------------------------------------------- # --- 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 WORKDIR /src
COPY web/ ./ COPY web/ ./
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
fi fi
# --- 2. the binary ----------------------------------------------------------- # --- 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 WORKDIR /src
@@ -37,11 +52,13 @@ COPY . .
COPY --from=web /src/build ./cmd/verdande/webbuild COPY --from=web /src/build ./cmd/verdande/webbuild
ARG VERSION=dev 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 # 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 # 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. # 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 \ -tags embedweb \
-trimpath \ -trimpath \
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \ -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 | | REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested | | Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | 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 | | Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done | | Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | 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 | | Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested | | Device list: where you are signed in, and signing one out | Done, tested |
| User administration: invite to the instance, promote, remove | 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 | | 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 to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested | | Drag a task to another project or another day | Done, smoke-tested |
+121
View File
@@ -856,6 +856,60 @@ paths:
"204": { description: Deleted. } "204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" } "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: /api/v1/users:
get: get:
tags: [auth] tags: [auth]
@@ -1211,9 +1265,46 @@ paths:
parameters: parameters:
- { name: projectID, in: path, required: true, schema: { type: string } } - { name: projectID, in: path, required: true, schema: { type: string } }
- { name: userID, 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: delete:
tags: [projects] tags: [projects]
summary: Remove somebody from a project. Its owner only. 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: responses:
"204": { description: Removed. } "204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" } "404": { $ref: "#/components/responses/NotFound" }
@@ -1315,6 +1406,36 @@ paths:
type: array type: array
items: { $ref: "#/components/schemas/Task" } 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: /api/v1/delegated:
get: get:
tags: [views] tags: [views]
+39 -17
View File
@@ -8,6 +8,8 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth" "github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store" "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) token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
if err != nil { if err != nil {
s.internal(w, "create session", err) s.internal(w, r, "create session", err)
return return
} }
s.setSessionCookie(w, token, ttl) 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 { 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 return
} }
// The cookie is reissued so its lifetime matches the session's new one. // 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 { 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 return
} }
updated, err := s.db.UserByID(r.Context(), user.ID) updated, err := s.db.UserByID(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "reload user", err) s.internal(w, r, "reload user", err)
return return
} }
writeJSON(w, http.StatusOK, toUserJSON(updated)) 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) hash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return return
} }
@@ -277,14 +279,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
"an account already exists for that email address") "an account already exists for that email address")
return return
} }
s.internal(w, "create user", err) s.internal(w, r, "create user", err)
return return
} }
// Accepting the invite is what adds the membership and burns the token, in one // 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. // 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 { 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 return
} }
@@ -328,7 +330,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password) hash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return 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") writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
return return
} }
s.internal(w, "create first admin", err) s.internal(w, r, "create first admin", err)
return 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) { func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
n, err := s.db.UserCount(r.Context()) n, err := s.db.UserCount(r.Context())
if err != nil { if err != nil {
s.internal(w, "count users", err) s.internal(w, r, "count users", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0}) 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) { 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) token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
if err != nil { if err != nil {
s.internal(w, "create session", err) s.internal(w, r, "create session", err)
return return
} }
s.setSessionCookie(w, token, s.cfg.SessionTTL) s.setSessionCookie(w, token, s.cfg.SessionTTL)
@@ -393,7 +395,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if err == nil { if err == nil {
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL) token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
if err != nil { if err != nil {
s.internal(w, "create password reset", err) s.internal(w, r, "create password reset", err)
return return
} }
link := s.cfg.BaseURL + "/reset?token=" + token 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) hash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return return
} }
// Every session ends: a password reset is what somebody does when they think // Every session ends: a password reset is what somebody does when they think
// another person has been in their account. // another person has been in their account.
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil { 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 return
} }
@@ -469,7 +471,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.NewPassword) hash, err := auth.HashPassword(req.NewPassword)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return return
} }
keep := "" keep := ""
@@ -477,7 +479,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
keep = session.ID keep = session.ID
} }
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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 // 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 // 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. // 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) 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") 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) projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil { if err != nil {
s.internal(w, "caldav home", err) s.internal(w, r, "caldav home", err)
return return
} }
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Depth") != "0" { if r.Header.Get("Depth") != "0" {
tasks, err := s.caldavTasks(r, user, projectID) tasks, err := s.caldavTasks(r, user, projectID)
if err != nil { if err != nil {
s.internal(w, "caldav tasks", err) s.internal(w, r, "caldav tasks", err)
return return
} }
for _, t := range tasks { 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) tasks, err := s.caldavTasks(r, user, projectID)
if err != nil { if err != nil {
s.internal(w, "caldav report", err) s.internal(w, r, "caldav report", err)
return return
} }
byID := map[string]store.Task{} byID := map[string]store.Task{}
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
update.DueDatetime = parsed.DueDatetime update.DueDatetime = parsed.DueDatetime
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil { 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 return
} }
if parsed.Completed && !existing.Completed() { if parsed.Completed && !existing.Completed() {
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
task.Content = "(uden titel)" task.Content = "(uden titel)"
} }
if err := s.db.CreateTask(r.Context(), task, nil); err != nil { 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 return
} }
s.publish(projectID, "task.created", toTaskJSON(*task)) s.publish(projectID, "task.created", toTaskJSON(*task))
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.DeleteTask(r.Context(), taskID); err != nil { if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.internal(w, "caldav delete", err) s.internal(w, r, "caldav delete", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) comments, err := s.db.ListComments(r.Context(), taskID)
if err != nil { if err != nil {
s.internal(w, "list comments", err) s.internal(w, r, "list comments", err)
return return
} }
out := make([]commentJSON, 0, len(comments)) 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) files, err := s.db.ListTaskAttachments(r.Context(), taskID)
if err != nil { if err != nil {
s.internal(w, "list attachments", err) s.internal(w, r, "list attachments", err)
return return
} }
direct := make([]attachmentJSON, 0, len(files)) 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) comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
if err != nil { if err != nil {
s.internal(w, "create comment", err) s.internal(w, r, "create comment", err)
return return
} }
comment.UserName, comment.UserColor = user.Name, user.AvatarColor 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) err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
if err != nil { if err != nil {
s.storeError(w, "update comment", err) s.storeError(w, r, "update comment", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) taskID, err := s.db.CommentTask(r.Context(), commentID)
if err != nil { if err != nil {
s.storeError(w, "comment task", err) s.storeError(w, r, "comment task", err)
return return
} }
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) 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 { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
stored, size, err := s.storeUpload(file) stored, size, err := s.storeUpload(file)
if err != nil { if err != nil {
s.internal(w, "store upload", err) s.internal(w, r, "store upload", err)
return 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 { if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored)) os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, "record attachment", err) s.internal(w, r, "record attachment", err)
return return
} }
@@ -306,7 +306,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID) taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "attachment task", err) s.storeError(w, r, "attachment task", err)
return return
} }
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil { 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) a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "get attachment", err) s.storeError(w, r, "get attachment", err)
return return
} }
@@ -358,7 +358,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID) taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "attachment task", err) s.storeError(w, r, "attachment task", err)
return return
} }
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) 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) path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "delete attachment", err) s.storeError(w, r, "delete attachment", err)
return 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, Limit: 2000,
}) })
if err != nil { if err != nil {
s.internal(w, "ics feed", err) s.internal(w, r, "ics feed", err)
return return
} }
projects, err := s.db.ListProjects(r.Context(), user.ID, true) projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil { if err != nil {
s.internal(w, "ics feed projects", err) s.internal(w, r, "ics feed projects", err)
return return
} }
names := map[string]string{} 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) token, err := s.db.EnsureICSToken(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "ics token", err) s.internal(w, r, "ics token", err)
return return
} }
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"}) 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() token, err := auth.NewToken()
if err != nil { if err != nil {
s.internal(w, "generate feed token", err) s.internal(w, r, "generate feed token", err)
return return
} }
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"}) 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) reminders, err := s.db.ListReminders(r.Context(), taskID)
if err != nil { if err != nil {
s.internal(w, "list reminders", err) s.internal(w, r, "list reminders", err)
return return
} }
out := make([]reminderJSON, 0, len(reminders)) 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) rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
if err != nil { if err != nil {
s.internal(w, "create reminder", err) s.internal(w, r, "create reminder", err)
return 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) { func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID) err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.storeError(w, "delete reminder", err) s.storeError(w, r, "delete reminder", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID) templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list templates", err) s.internal(w, r, "list templates", err)
return return
} }
out := make([]templateJSON, 0, len(templates)) 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, tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description)) strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
if err != nil { if err != nil {
s.storeError(w, "save template", err) s.storeError(w, r, "save template", err)
return return
} }
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl)) 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")) tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
if err != nil { if err != nil {
s.storeError(w, "get template", err) s.storeError(w, r, "get template", err)
return 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) project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
if err != nil { if err != nil {
s.internal(w, "create from template", err) s.internal(w, r, "create from template", err)
return return
} }
s.activity(r, project.ID, "", "project.created", map[string]any{ 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) { func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID")) err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
if err != nil { if err != nil {
s.storeError(w, "delete template", err) s.storeError(w, r, "delete template", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) { func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID) filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list filters", err) s.internal(w, r, "list filters", err)
return return
} }
out := make([]filterJSON, 0, len(filters)) out := make([]filterJSON, 0, len(filters))
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
f.SortOrder = *req.SortOrder f.SortOrder = *req.SortOrder
} }
if err := s.db.CreateFilter(r.Context(), f); err != nil { if err := s.db.CreateFilter(r.Context(), f); err != nil {
s.internal(w, "create filter", err) s.internal(w, r, "create filter", err)
return return
} }
writeJSON(w, http.StatusCreated, toFilterJSON(*f)) 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, Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
}) })
if err != nil { if err != nil {
s.storeError(w, "update filter", err) s.storeError(w, r, "update filter", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) { func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID")) err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
if err != nil { if err != nil {
s.storeError(w, "delete filter", err) s.storeError(w, r, "delete filter", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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")) f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
if err != nil { if err != nil {
s.storeError(w, "get filter", err) s.storeError(w, r, "get filter", err)
return return
} }
s.runQuery(w, r, f.Query) 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), Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
}) })
if err != nil { if err != nil {
s.internal(w, "run filter", err) s.internal(w, r, "run filter", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)}) 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) { func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID) labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list labels", err) s.internal(w, r, "list labels", err)
return return
} }
out := make([]labelJSON, 0, len(labels)) 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") writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return return
} }
s.internal(w, "create label", err) s.internal(w, r, "create label", err)
return return
} }
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0}) 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") writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return return
} }
s.storeError(w, "update label", err) s.storeError(w, r, "update label", err)
return return
} }
s.publishToOwner(r, "label.changed", nil) 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) { func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID")) err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
if err != nil { if err != nil {
s.storeError(w, "delete label", err) s.storeError(w, r, "delete label", err)
return return
} }
s.publishToOwner(r, "label.changed", nil) 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() pkce, err := gmail.NewPKCE()
if err != nil { if err != nil {
s.internal(w, "gmail pkce", err) s.internal(w, r, "gmail pkce", err)
return return
} }
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail") settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
settings["pkce_verifier"] = pkce.Verifier 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() settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)}) 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") settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
verifier, _ := settings["pkce_verifier"].(string) 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 { 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 return
} }
s.log.Info("gmail connected", "user", user.ID, "mailbox", email) 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) project := todoist.FromRows(rows, name)
result, err := s.importProject(r, user, project) result, err := s.importProject(r, user, project)
if err != nil { if err != nil {
s.internal(w, "import project", err) s.internal(w, r, "import project", err)
return return
} }
writeJSON(w, http.StatusCreated, result) 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) result, err := s.importProject(r, user, project)
if err != nil { if err != nil {
s.internal(w, "import csv", err) s.internal(w, r, "import csv", err)
return return
} }
writeJSON(w, http.StatusCreated, result) 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) project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
exported, err := s.buildExport(r, user, project) exported, err := s.buildExport(r, user, project)
if err != nil { if err != nil {
s.internal(w, "export project", err) s.internal(w, r, "export project", err)
return return
} }
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(ctx, user.ID, true) projects, err := s.db.ListProjects(ctx, user.ID, true)
if err != nil { if err != nil {
s.internal(w, "export projects", err) s.internal(w, r, "export projects", err)
return return
} }
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000, ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
}) })
if err != nil { if err != nil {
s.internal(w, "export tasks", err) s.internal(w, r, "export tasks", err)
return return
} }
for _, t := range tasks { 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) project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{ tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000, ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
}) })
if err != nil { if err != nil {
s.internal(w, "export ics", err) s.internal(w, r, "export ics", err)
return 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) token, err := s.db.EnsureMailToken(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "mail token", err) s.internal(w, r, "mail token", err)
return return
} }
writeJSON(w, http.StatusOK, mailAddressResponse{ 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) { func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken() token, err := auth.NewToken()
if err != nil { if err != nil {
s.internal(w, "generate mail token", err) s.internal(w, r, "generate mail token", err)
return return
} }
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, mailAddressResponse{ writeJSON(w, http.StatusOK, mailAddressResponse{
@@ -134,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
} }
if projectID == "" { if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err) s.internal(w, r, "inbox", err)
return return
} }
} }
@@ -145,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID, RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
} }
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil { 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 return
} }
@@ -187,7 +187,7 @@ type aiSettings struct {
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID) cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
// The key is never sent back. A settings page that repopulates a password // 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) existing, err := s.aiConfig(r, user.ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
// An empty key means "leave it alone", not "delete it" — otherwise saving a // 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, "model": req.Model, "api_key": key,
} }
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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) cfg, err := s.aiConfig(r, user.ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
if !cfg.Configured() { 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) task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
@@ -299,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
Priority: task.Priority, CreatedBy: user.ID, Priority: task.Priority, CreatedBy: user.ID,
} }
if err := s.db.CreateTask(r.Context(), child, nil); err != nil { 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 return
} }
created = append(created, toTaskJSON(*child)) 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) cfg, err := s.aiConfig(r, user.ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
if !cfg.Configured() { 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}) tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
if err != nil { if err != nil {
s.internal(w, "list tasks", err) s.internal(w, r, "list tasks", err)
return return
} }
if len(tasks) == 0 { if len(tasks) == 0 {
@@ -372,7 +372,7 @@ type gmailSettings struct {
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail") values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
str := func(key string) string { 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") values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
values["trigger"] = req.Trigger values["trigger"] = req.Trigger
values["label"] = req.Label values["label"] = req.Label
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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. // handleDisconnectGmail forgets the connection, tokens and all.
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) { 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 { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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) user, err := s.db.UserByAPIToken(r.Context(), key)
if err != nil { if err != nil {
if !errors.Is(err, store.ErrNotFound) { if !errors.Is(err, store.ErrNotFound) {
s.internal(w, "mcp key", err) s.internal(w, r, "mcp key", err)
return return
} }
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid") 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 // 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. // and shows the sign-in screen — and nothing in the logs says why.
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) { if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
s.internal(w, "authenticate", err) s.internal(w, r, "authenticate", err)
return return
} }
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in") 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) list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
if err != nil { if err != nil {
s.internal(w, "list notifications", err) s.internal(w, r, "list notifications", err)
return return
} }
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID) unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "count notifications", err) s.internal(w, r, "count notifications", err)
return 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) { func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID")) err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
if err != nil { if err != nil {
s.internal(w, "mark notifications read", err) s.internal(w, r, "mark notifications read", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -148,7 +148,7 @@ type pushSubscriptionRequest struct {
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
public, _, err := s.vapidKeys(r.Context()) public, _, err := s.vapidKeys(r.Context())
if err != nil { if err != nil {
s.internal(w, "vapid keys", err) s.internal(w, r, "vapid keys", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"public_key": public}) 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(), UserAgent: r.UserAgent(),
}) })
if err != nil { if err != nil {
s.internal(w, "save push subscription", err) s.internal(w, r, "save push subscription", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+6 -6
View File
@@ -34,7 +34,7 @@ func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID) groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list project groups", err) s.internal(w, r, "list project groups", err)
return return
} }
out := make([]projectGroupJSON, 0, len(groups)) out := make([]projectGroupJSON, 0, len(groups))
@@ -77,7 +77,7 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
g.Collapsed = *req.Collapsed g.Collapsed = *req.Collapsed
} }
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil { 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 return
} }
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g)) s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
@@ -108,12 +108,12 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{ if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
Name: req.Name, Color: req.Color, Collapsed: req.Collapsed, Name: req.Name, Color: req.Color, Collapsed: req.Collapsed,
}); err != nil { }); err != nil {
s.storeError(w, "update project group", err) s.storeError(w, r, "update project group", err)
return return
} }
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID) g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
if err != nil { if err != nil {
s.storeError(w, "get project group", err) s.storeError(w, r, "get project group", err)
return return
} }
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g)) s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
@@ -126,7 +126,7 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID") groupID := chi.URLParam(r, "groupID")
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil { 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 return
} }
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID}) s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
@@ -145,7 +145,7 @@ func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Reque
return return
} }
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+71 -24
View File
@@ -45,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived) projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
if err != nil { if err != nil {
s.internal(w, "list projects", err) s.internal(w, r, "list projects", err)
return return
} }
out := make([]projectJSON, 0, len(projects)) out := make([]projectJSON, 0, len(projects))
@@ -59,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context()) user := userFrom(r.Context())
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID) p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
writeJSON(w, http.StatusOK, toProjectJSON(*p)) 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 { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -138,7 +138,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
p.ViewMode = *req.ViewMode p.ViewMode = *req.ViewMode
} }
if err := s.db.CreateProject(r.Context(), p); err != nil { if err := s.db.CreateProject(r.Context(), p); err != nil {
s.internal(w, "create project", err) s.internal(w, r, "create project", err)
return return
} }
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name}) s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
@@ -174,7 +174,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
ViewMode: req.ViewMode, Archived: req.Archived, ViewMode: req.ViewMode, Archived: req.Archived,
}) })
if err != nil { if err != nil {
s.storeError(w, "update project", err) s.storeError(w, r, "update project", err)
return return
} }
// Grouping is written separately because it is scoped differently: the fields // Grouping is written separately because it is scoped differently: the fields
@@ -184,7 +184,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
if req.GroupID != nil { if req.GroupID != nil {
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID, if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
userFrom(r.Context()).ID); err != nil { userFrom(r.Context()).ID); err != nil {
s.storeError(w, "set project group", err) s.storeError(w, r, "set project group", err)
return return
} }
} }
@@ -192,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) p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
s.publishToOwner(r, "project.updated", toProjectJSON(*p)) s.publishToOwner(r, "project.updated", toProjectJSON(*p))
@@ -206,7 +206,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted") writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
return return
} }
s.storeError(w, "delete project", err) s.storeError(w, r, "delete project", err)
return return
} }
s.activity(r, projectID, "", "project.deleted", nil) s.activity(r, projectID, "", "project.deleted", nil)
@@ -230,7 +230,7 @@ type trashedProjectJSON struct {
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID) trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list trashed projects", err) s.internal(w, r, "list trashed projects", err)
return return
} }
@@ -252,7 +252,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
// deliberately invisible to it. Ownership is what governs restoring. // deliberately invisible to it. Ownership is what governs restoring.
owner, err := s.db.ProjectOwner(r.Context(), projectID) owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil { if err != nil {
s.storeError(w, "project owner", err) s.storeError(w, r, "project owner", err)
return return
} }
if owner != userFrom(r.Context()).ID { if owner != userFrom(r.Context()).ID {
@@ -260,7 +260,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.RestoreProject(r.Context(), projectID); err != nil { if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
s.storeError(w, "restore project", err) s.storeError(w, r, "restore project", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -306,7 +306,7 @@ type sectionJSON struct {
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID")) sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
if err != nil { if err != nil {
s.internal(w, "list sections", err) s.internal(w, r, "list sections", err)
return return
} }
out := make([]sectionJSON, 0, len(sections)) out := make([]sectionJSON, 0, len(sections))
@@ -336,7 +336,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
sec.SortOrder = *req.SortOrder sec.SortOrder = *req.SortOrder
} }
if err := s.db.CreateSection(r.Context(), sec); err != nil { if err := s.db.CreateSection(r.Context(), sec); err != nil {
s.internal(w, "create section", err) s.internal(w, r, "create section", err)
return return
} }
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name}) s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
@@ -350,7 +350,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
sectionID := chi.URLParam(r, "sectionID") sectionID := chi.URLParam(r, "sectionID")
projectID, err := s.db.SectionProject(r.Context(), sectionID) projectID, err := s.db.SectionProject(r.Context(), sectionID)
if err != nil { if err != nil {
s.storeError(w, "section project", err) s.storeError(w, r, "section project", err)
return "", "", false return "", "", false
} }
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil { if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
@@ -381,7 +381,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{ if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
Name: req.Name, SortOrder: req.SortOrder, Name: req.Name, SortOrder: req.SortOrder,
}); err != nil { }); err != nil {
s.storeError(w, "update section", err) s.storeError(w, r, "update section", err)
return return
} }
s.activity(r, projectID, "", "section.updated", nil) s.activity(r, projectID, "", "section.updated", nil)
@@ -394,7 +394,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil { if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
s.storeError(w, "delete section", err) s.storeError(w, r, "delete section", err)
return return
} }
s.activity(r, projectID, "", "section.deleted", nil) s.activity(r, projectID, "", "section.deleted", nil)
@@ -414,7 +414,7 @@ type memberJSON struct {
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID")) members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
if err != nil { if err != nil {
s.internal(w, "list members", err) s.internal(w, r, "list members", err)
return return
} }
out := make([]memberJSON, 0, len(members)) out := make([]memberJSON, 0, len(members))
@@ -463,7 +463,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID) project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
@@ -475,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil { 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 return
} }
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)}) s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
@@ -485,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) token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
if err != nil { if err != nil {
s.internal(w, "create invite", err) s.internal(w, r, "create invite", err)
return return
} }
link := s.cfg.BaseURL + "/invite?token=" + token link := s.cfg.BaseURL + "/invite?token=" + token
@@ -502,13 +502,60 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed}) 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) { func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID") projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID") userID := chi.URLParam(r, "userID")
owner, err := s.db.ProjectOwner(r.Context(), projectID) owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil { if err != nil {
s.storeError(w, "project owner", err) s.storeError(w, r, "project owner", err)
return return
} }
if userID == owner { if userID == owner {
@@ -517,7 +564,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil { 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 return
} }
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID}) s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
@@ -529,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 // 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 // 403 confirms the thing exists, which is exactly what somebody probing ids wants
// to learn. // 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 { switch {
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess): case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
writeError(w, http.StatusNotFound, CodeNotFound, "not found") writeError(w, http.StatusNotFound, CodeNotFound, "not found")
case errors.Is(err, store.ErrEmailInUse): case errors.Is(err, store.ErrEmailInUse):
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account") writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
default: 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) 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("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated) r.Get("/delegated", s.handleDelegated)
r.Get("/people", s.handlePeople)
r.Get("/search", s.handleSearch) r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) { 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.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject) r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite) r.Post("/invites", s.handleInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember) 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. // delete somebody else's.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin) r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
r.Get("/users", s.handleListUsers) r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleCreateUser) r.Post("/users", s.handleCreateUser)
r.Patch("/users/{userID}", s.handleUpdateUser) 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) sessions, err := s.db.ListSessions(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "list sessions", err) s.internal(w, r, "list sessions", err)
return return
} }
@@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "sessionID") sessionID := chi.URLParam(r, "sessionID")
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil { 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 return
} }
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID { 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) tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
if err != nil { if err != nil {
s.internal(w, "list tasks", err) s.internal(w, r, "list tasks", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)}) 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) { 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) t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
writeJSON(w, http.StatusOK, toTaskJSON(*t)) writeJSON(w, http.StatusOK, toTaskJSON(*t))
@@ -162,7 +162,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
if projectID == "" { if projectID == "" {
var err error var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err) s.internal(w, r, "inbox", err)
return 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 { 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 return
} }
// The store wrote the labels; the struct it was handed does not know that, and // 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 { 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 return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
s.activity(r, t.ProjectID, t.ID, "task.updated", nil) 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) result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "complete task", err) s.storeError(w, r, "complete task", err)
return return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
@@ -369,13 +369,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.ReopenTask(r.Context(), taskID); err != nil { if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
s.storeError(w, "reopen task", err) s.storeError(w, r, "reopen task", err)
return return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil) 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) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
if err := s.db.DeleteTask(r.Context(), taskID); err != nil { if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.storeError(w, "delete task", err) s.storeError(w, r, "delete task", err)
return return
} }
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content}) 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) current, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) 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 { 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 return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t)) s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
@@ -502,7 +502,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
if projectID == "" { if projectID == "" {
var err error var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err) s.internal(w, r, "inbox", err)
return 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 { 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 return
} }
t.Labels = parsed.Labels 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) { func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID) tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list api tokens", err) s.internal(w, r, "list api tokens", err)
return 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) plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
if err != nil { if err != nil {
s.internal(w, "create api token", err) s.internal(w, r, "create api token", err)
return 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) { func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID")) err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
if err != nil { if err != nil {
s.storeError(w, "delete api token", err) s.storeError(w, r, "delete api token", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
if err != nil { if err != nil {
s.internal(w, "generate totp secret", err) s.internal(w, r, "generate totp secret", err)
return return
} }
// Stored but not enabled: confirm decides that. // Stored but not enabled: confirm decides that.
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri}) 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() codes, hashes, err := auth.NewRecoveryCodes()
if err != nil { if err != nil {
s.internal(w, "generate recovery codes", err) s.internal(w, r, "generate recovery codes", err)
return return
} }
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { 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 return
} }
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil { 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 return
} }
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil { 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 return
} }
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil { 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() codes, hashes, err := auth.NewRecoveryCodes()
if err != nil { if err != nil {
s.internal(w, "generate recovery codes", err) s.internal(w, r, "generate recovery codes", err)
return return
} }
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes}) 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) { func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID) n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "count recovery codes", err) s.internal(w, r, "count recovery codes", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]int{"remaining": n}) 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()) users, err := s.db.ListUsers(r.Context())
if err != nil { if err != nil {
s.internal(w, "list users", err) s.internal(w, r, "list users", err)
return return
} }
invites, err := s.db.ListPendingInvites(r.Context()) invites, err := s.db.ListPendingInvites(r.Context())
if err != nil { if err != nil {
s.internal(w, "list pending invites", err) s.internal(w, r, "list pending invites", err)
return 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, token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
inviter.ID, s.cfg.InviteTTL) inviter.ID, s.cfg.InviteTTL)
if err != nil { if err != nil {
s.internal(w, "create instance invite", err) s.internal(w, r, "create instance invite", err)
return return
} }
link := s.cfg.BaseURL + "/invite?token=" + token 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 { 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 return
} }
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin) 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 { if err := s.db.DeleteUser(r.Context(), userID); err != nil {
s.storeError(w, "delete user", err) s.storeError(w, r, "delete user", err)
return return
} }
s.log.Info("user deleted", "by", me.ID, "user", userID) 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) { func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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 { func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
target, err := s.db.UserByID(r.Context(), userID) target, err := s.db.UserByID(r.Context(), userID)
if err != nil { if err != nil {
s.storeError(w, "get user", err) s.storeError(w, r, "get user", err)
return err return err
} }
if !target.IsAdmin { 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()) admins, err := s.db.CountAdmins(r.Context())
if err != nil { if err != nil {
s.internal(w, "count admins", err) s.internal(w, r, "count admins", err)
return err return err
} }
if admins <= 1 { if admins <= 1 {
+67
View File
@@ -277,3 +277,70 @@ func indexAfter(s, needle string) int {
} }
return 0 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, Limit: 500,
}) })
if err != nil { if err != nil {
s.internal(w, "today", err) s.internal(w, r, "today", err)
return return
} }
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
Limit: 1000, Limit: 1000,
}) })
if err != nil { if err != nil {
s.internal(w, "upcoming", err) s.internal(w, r, "upcoming", err)
return return
} }
@@ -92,6 +92,31 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": out}) 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 { type delegatedPerson struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Name string `json:"name"` 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), Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
}) })
if err != nil { if err != nil {
s.internal(w, "delegated", err) s.internal(w, r, "delegated", err)
return return
} }
@@ -132,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
people, err := s.db.PeopleByIDs(r.Context(), ids) people, err := s.db.PeopleByIDs(r.Context(), ids)
if err != nil { if err != nil {
s.internal(w, "delegated people", err) s.internal(w, r, "delegated people", err)
return return
} }
@@ -184,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100), Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
}) })
if err != nil { if err != nil {
s.internal(w, "search tasks", err) s.internal(w, r, "search tasks", err)
return 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. // second FTS index for that would be more machinery than the problem needs.
projects, err := s.db.ListProjects(r.Context(), user.ID, false) projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil { if err != nil {
s.internal(w, "search projects", err) s.internal(w, r, "search projects", err)
return return
} }
matched := []projectJSON{} 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"), entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
parseLimit(r.URL.Query().Get("limit"), 50, 200)) parseLimit(r.URL.Query().Get("limit"), 50, 200))
if err != nil { if err != nil {
s.internal(w, "list activity", err) s.internal(w, r, "list activity", err)
return 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) 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, "backup", time.Hour, r.nightlyBackup)
r.every(ctx, "trash", time.Hour, r.emptyTrash) r.every(ctx, "trash", time.Hour, r.emptyTrash)
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions) 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 // 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 // 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 // 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 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 { func (r *Runner) purgeSessions(ctx context.Context) error {
n, err := r.db.PurgeExpiredSessions(ctx) n, err := r.db.PurgeExpiredSessions(ctx)
if err != nil { if err != nil {
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"context"
"time"
)
// A ServerError is one 500 the API answered, kept so it can be read after the
// container that produced it has gone.
type ServerError struct {
ID string
At time.Time
Method string
Path string
Status int
What string
Message string
UserID string
UserName string
RequestID string
}
// RecordError stores one failure.
//
// Errors here are dropped rather than returned: this is called from the path that
// is already reporting a failure to somebody, and a diagnostic that can itself
// fail the request would turn one broken screen into two.
func (db *DB) RecordError(ctx context.Context, e ServerError) {
var user any
if e.UserID != "" {
user = e.UserID
}
_, _ = db.ExecContext(ctx,
`INSERT INTO error_log (id, at, method, path, status, what, message, user_id, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
NewID(), time.Now().Unix(), e.Method, truncate(e.Path, 500), e.Status,
truncate(e.What, 200), truncate(e.Message, 2000), user, truncate(e.RequestID, 100))
}
// ListErrors returns the most recent failures, newest first.
func (db *DB) ListErrors(ctx context.Context, limit int) ([]ServerError, error) {
rows, err := db.QueryContext(ctx, `
SELECT e.id, e.at, e.method, e.path, e.status, e.what, e.message,
COALESCE(e.user_id, ''), COALESCE(u.name, ''), e.request_id
FROM error_log e
LEFT JOIN users u ON u.id = e.user_id
ORDER BY e.at DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ServerError{}
for rows.Next() {
var e ServerError
var at int64
if err := rows.Scan(&e.ID, &at, &e.Method, &e.Path, &e.Status, &e.What,
&e.Message, &e.UserID, &e.UserName, &e.RequestID); err != nil {
return nil, err
}
e.At = time.Unix(at, 0).UTC()
out = append(out, e)
}
return out, rows.Err()
}
// PurgeOldErrors is run by the nightly sweep. A diagnostic that grows without
// limit is a disk that fills for a reason nobody expected.
func (db *DB) PurgeOldErrors(ctx context.Context, keep time.Duration) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM error_log WHERE at < ?`, time.Now().Add(-keep).Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
@@ -0,0 +1,27 @@
-- Server errors, kept where they can outlive the container that produced them.
--
-- verdande already logs the real cause of every 500 to stdout. On a Rune that is
-- the container's log, and every restart starts a new one — so the panel can
-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone.
-- An operator is then told that something broke and given no way to find out what.
--
-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that
-- grows without limit is a disk that fills for a reason nobody expected.
CREATE TABLE error_log (
id TEXT PRIMARY KEY,
at INTEGER NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL,
-- what: the operation the handler was attempting, in the words it uses in the
-- log — "list projects", "move task". The detail a stack trace would give and
-- a status code cannot.
what TEXT NOT NULL,
message TEXT NOT NULL,
-- Whose request it was, so a fault that only one account hits is visible as
-- one. NULL for anything that failed before authentication.
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
request_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_error_log_at ON error_log (at DESC);
+27
View File
@@ -389,6 +389,33 @@ func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role
return err 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 { func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error { return db.Tx(ctx, func(tx *sql.Tx) error {
// Anything assigned to them is unassigned rather than left pointing at // 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 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. // UserCount is what decides whether the instance still needs its first admin.
func (db *DB) UserCount(ctx context.Context) (int, error) { func (db *DB) UserCount(ctx context.Context) (int, error) {
var n int var n int
+151 -2
View File
@@ -153,9 +153,10 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
'Integrationer', 'Integrationer',
'AI', 'AI',
'API-tokens', '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. // is the first account, which is one.
'Brugere', 'Brugere',
'Fejl',
'Data og skabeloner' 'Data og skabeloner'
]) { ]) {
await tabs.getByRole('link', { name: label, exact: true }).click(); 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(); 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 // The service worker is what Web Push needs; without it a subscription cannot
// be created at all. // be created at all.
const worker = await request.get('/sw.js'); const worker = await request.get('/sw.js');
@@ -438,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 morgen').getByText('hent pakken')).toBeVisible();
await expect(day('I dag').getByText('hent pakken')).toBeHidden(); 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 // 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. // find a place among that project's tasks, and leave its old section behind.
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' })); await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
@@ -570,7 +585,10 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await sidebar.getByRole('link', { name: 'Venter på andre' }).click(); 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: 'Venter på andre' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nabo' })).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([]); expect(trouble).toEqual([]);
}); });
@@ -705,6 +723,137 @@ test('den sidste administrator kan hverken fjernes eller slette sig selv', async
await expect(mine.getByRole('button')).toHaveCount(0); 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. * 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();
+4
View File
@@ -7,6 +7,10 @@
<meta name="description" content="verdande — opgaver og projekter, delt." /> <meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" /> <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> <title>verdande</title>
<script> <script>
// Applied before first paint, so a dark-theme user never sees a white // 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 }), setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
deleteUser: (id) => del(`/users/${id}`), deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`), revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
recoveryCodes: () => get('/auth/recovery-codes'), recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }), regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
@@ -205,6 +206,8 @@ export const api = {
listMembers: (projectId) => get(`/projects/${projectId}/members`), listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }), 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}`), removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
activity: (projectId) => get(`/projects/${projectId}/activity`), activity: (projectId) => get(`/projects/${projectId}/activity`),
@@ -305,5 +308,6 @@ export const api = {
today: () => get('/today'), today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`), upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
delegated: () => get('/delegated'), delegated: () => get('/delegated'),
people: () => get('/people'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`) search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
}; };
+49 -2
View File
@@ -18,7 +18,13 @@
newName = ''; newName = '';
adding = false; adding = false;
const project = await app.createProject(name); 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() { async function signOut() {
@@ -224,6 +230,34 @@
await app.reorderProjects(ordered.map((p) => p.id)); 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 ------------------------------------------------ // --- a task dropped on a project ------------------------------------------------
/** The project row lit up because a task is hovering over it. */ /** The project row lit up because a task is hovering over it. */
@@ -306,7 +340,20 @@
</div> </div>
<div class="views"> <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> <span class="dot today" aria-hidden="true"></span>
I dag I dag
</a> </a>
+16 -2
View File
@@ -575,7 +575,12 @@
.drawer { .drawer {
position: fixed; position: fixed;
inset: 0 0 0 auto; 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; z-index: 80;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -625,7 +630,12 @@
.scroll { .scroll {
flex: 1; flex: 1;
min-width: 0;
overflow-y: auto; 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; overscroll-behavior: contain;
padding: var(--s4) var(--s4) var(--s8); padding: var(--s4) var(--s4) var(--s8);
display: flex; display: flex;
@@ -883,8 +893,12 @@
} }
@media (max-width: 560px) { @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 { .drawer {
width: 100vw; inset: 0;
width: auto;
border-left: 0; border-left: 0;
} }
} }
+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 due = $derived(dueLabel(task.due_date));
let time = $derived( let time = $derived(
task.due_datetime task.due_datetime
@@ -81,8 +83,19 @@
<span class="description">{task.description}</span> <span class="description">{task.description}</span>
{/if} {/if}
{#if due || task.labels?.length || task.recurrence_text} {#if assignee || due || task.labels?.length || task.recurrence_text}
<span class="meta"> <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} {#if due}
<span class="due" data-tone={due.tone}> <span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if} {due.text}{#if time}&nbsp;{time}{/if}
@@ -259,6 +272,25 @@
stroke-linejoin: round; 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 { .label {
color: var(--ink-muted); color: var(--ink-muted);
background: var(--surface-raised); background: var(--surface-raised);
+29 -2
View File
@@ -18,6 +18,14 @@ class AppState {
projects = $state([]); projects = $state([]);
/** The foldable headings over the projects in the sidebar. */ /** The foldable headings over the projects in the sidebar. */
groups = $state([]); 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([]); tasks = $state([]);
loading = $state(true); loading = $state(true);
/** Transient messages: a failed save, a rolled-back change. */ /** Transient messages: a failed save, a rolled-back change. */
@@ -52,6 +60,23 @@ class AppState {
return this.projects.find((p) => p.id === id); 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() { get detailTask() {
return this.tasks.find((t) => t.id === this.detailId) ?? null; return this.tasks.find((t) => t.id === this.detailId) ?? null;
} }
@@ -68,12 +93,14 @@ class AppState {
this.loading = true; this.loading = true;
try { try {
this.user = await api.me(); this.user = await api.me();
const [{ projects }, { groups }] = await Promise.all([ const [{ projects }, { groups }, { people }] = await Promise.all([
api.listProjects(), api.listProjects(),
api.listProjectGroups() api.listProjectGroups(),
api.people()
]); ]);
this.projects = projects; this.projects = projects;
this.groups = groups; this.groups = groups;
this.people = people;
this.connect(); this.connect();
} catch (e) { } catch (e) {
this.user = null; this.user = null;
+6 -1
View File
@@ -25,7 +25,12 @@
{ href: '/indstillinger/integrationer', label: 'Integrationer' }, { href: '/indstillinger/integrationer', label: 'Integrationer' },
{ href: '/indstillinger/ai', label: 'AI' }, { href: '/indstillinger/ai', label: 'AI' },
{ href: '/indstillinger/tokens', label: 'API-tokens' }, { 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' } { 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>
+212 -7
View File
@@ -26,6 +26,8 @@
let sections = $state([]); let sections = $state([]);
let members = $state([]); let members = $state([]);
let showShare = $state(false); let showShare = $state(false);
let showLog = $state(false);
let activity = $state([]);
let inviteEmail = $state(''); let inviteEmail = $state('');
let inviteRole = $state('editor'); let inviteRole = $state('editor');
let inviteLink = $state(''); let inviteLink = $state('');
@@ -65,6 +67,63 @@
} }
}); });
// 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 // The chosen view is kept locally as well as on the project, so switching is
// instant and only persists once the request lands. // instant and only persists once the request lands.
let view = $state(null); let view = $state(null);
@@ -245,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) { async function invite(event) {
event.preventDefault(); event.preventDefault();
inviteError = ''; inviteError = '';
@@ -326,6 +412,12 @@
</button> </button>
{/if} {/if}
{#if !project.is_inbox}
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
Historik
</button>
{/if}
{#if isOwner && !project.is_inbox} {#if isOwner && !project.is_inbox}
<button class="remove" onclick={remove} aria-label="Slet projektet"> <button class="remove" onclick={remove} aria-label="Slet projektet">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -335,6 +427,26 @@
{/if} {/if}
</header> </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} {#if choosingColor}
<div class="panel swatches" role="group" aria-label="Vælg farve"> <div class="panel swatches" role="group" aria-label="Vælg farve">
{#each COLORS as color (color.id)} {#each COLORS as color (color.id)}
@@ -360,7 +472,22 @@
{member.name[0]?.toUpperCase()} {member.name[0]?.toUpperCase()}
</span> </span>
<span class="member-name">{member.name}</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'} {#if isOwner && member.role !== 'owner'}
<button <button
class="remove" class="remove"
@@ -421,7 +548,10 @@
ondrop={(e) => onSectionDrop(e, '')} ondrop={(e) => onSectionDrop(e, '')}
> >
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} /> <TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
{#if !unsectioned.length} <!-- 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> <p class="empty">Uden sektion</p>
{/if} {/if}
</section> </section>
@@ -537,17 +667,28 @@
max-width: 1400px; 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 { header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--s4); gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s5); margin-bottom: var(--s5);
} }
h1 { h1 {
font-size: var(--text-2xl); font-size: var(--text-2xl);
flex: 1; /* 8ch rather than 0: the basis is what stops flex shrinking it past the
min-width: 0; point where the wrapping below is the better answer. */
flex: 1 1 8ch;
min-width: 8ch;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -567,8 +708,8 @@
/* Matches the heading it stands in for, so the swap does not move the page. */ /* Matches the heading it stands in for, so the swap does not move the page. */
.title { .title {
flex: 1; flex: 1 1 8ch;
min-width: 0; min-width: 8ch;
font-size: var(--text-2xl); font-size: var(--text-2xl);
font-weight: 560; font-weight: 560;
letter-spacing: -0.015em; letter-spacing: -0.015em;
@@ -764,6 +905,24 @@
font-size: var(--text-xs); 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 { .remove {
color: var(--ink-faint); color: var(--ink-faint);
width: 20px; width: 20px;
@@ -811,6 +970,52 @@
font-weight: 550; 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 { .link-out {
margin: 0; margin: 0;
font-size: var(--text-xs); font-size: var(--text-xs);
+7 -3
View File
@@ -135,17 +135,21 @@
max-width: 1400px; 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 { header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--s4); gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s5); margin-bottom: var(--s5);
} }
h1 { h1 {
font-size: var(--text-2xl); font-size: var(--text-2xl);
flex: 1; flex: 1 1 8ch;
min-width: 0; min-width: 8ch;
} }
.views { .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", "src": "/icon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "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"
} }
] ]
} }