Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:50:20 +02:00
Kristian WindandClaude Opus 5 e8ebd0fa78 feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the
beginning and there was no such thing. There are exactly two ways an
account has ever come into existence: the first admin at setup, and
somebody accepting an invite to a project. So the only way to add a person
was to share a project with them first.

The store has been ready the whole time. CreateInvite already documents
that "projectID may be empty, which invites somebody to the instance
rather than to a particular project", and AcceptInvite already has the
branch that grants no membership when there is none. requireAdmin has
existed in the middleware and was used nowhere. This wires those three
together and puts a page on them.

An invite rather than an account with a password the administrator picks.
A password typed here would be known to two people before it was ever
used, and would then have to travel to the second one by some channel
nobody has thought about. No name is asked for either — the signup form
asks the person, and a name an administrator guesses is a name somebody
has to correct.

Two refusals keep the instance administrable: the last administrator
cannot be demoted or deleted, and nobody can delete the account they are
signed in as. Both are enforced on the server; the interface simply does
not draw the buttons on your own row, because a button whose only purpose
is to be refused wastes a click to teach a rule. The last-admin refusal
gets its own error code, because "that already exists" is not what
happened and this is the one refusal a person needs explained.

Deleting an account is the only hard delete in this database, and building
the confirmation is what turned up how wide it goes. `projects.owner_id`
cascades, which is expected. But `tasks.created_by` cascades too — so
removing a colleague deletes every task they ever wrote, including in
projects somebody else owns. The count in the list says so, and the
confirmation says it in words. Changing that to ON DELETE SET NULL, so the
work stays and loses its author, means rebuilding the tasks table and its
FTS triggers: worth doing, not worth doing quietly while adding a screen.
It is written down in the store instead.

Pending invites are listed and can be withdrawn. Only the token's hash is
stored, so a link sent to the wrong address cannot be looked up and
resent — withdrawing it is the only way to stop it before it expires.

Six API tests for the guards and the counts, and two smoke tests: the
whole loop from invite to a second browser creating the account and
appearing in the list as somebody who is not an administrator, and the
absence of buttons on your own row.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:10:46 +02:00
30 changed files with 2792 additions and 49 deletions
+8 -2
View File
@@ -37,8 +37,10 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
views, and foldable groups over the projects in the sidebar.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Internal users are created by an admin; anyone else joins through an
invite link sent to their email. Owner, editor and viewer roles.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
everybody arrives through an invite link, either to a project or — from
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
by somebody else.
- **Live sync.** A change made by somebody else appears without a refresh.
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
@@ -63,12 +65,16 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | Done, tested |
| Assignee shown on every task that is somebody else's | Done, smoke-tested |
| Activity log, visible per project | Done, smoke-tested |
| Change a member's role without removing them | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
| Task detail: description, sub-tasks, comments, files, reminders | Done |
| Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested |
| User administration: invite to the instance, promote, remove | Done, tested |
| Invite and password-reset links that open the right page | Done, smoke-tested |
| Drag to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested |
+264 -2
View File
@@ -856,6 +856,140 @@ paths:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/users:
get:
tags: [auth]
summary: Everybody on the instance, and the invites not yet accepted.
description: |
Administrators only. Each user carries what deleting them would destroy,
so the confirmation can name real numbers without a second request in the
middle of a decision.
Session only: an API token cannot enumerate the instance's membership.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
users:
type: array
items: { $ref: "#/components/schemas/AdminUser" }
invites:
type: array
items: { $ref: "#/components/schemas/PendingInvite" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [auth]
summary: Invite somebody to the instance.
description: |
There is no open registration and no account created with a password
somebody else chose: this issues an invite with no project attached, and
the link ends with a password only its owner has seen. The same link and
the same `/auth/signup` as a project invite — an invite whose project is
empty simply grants no membership when it is accepted.
No name is asked for. The signup form asks the person themselves, and a
name an administrator guesses is a name somebody has to correct.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
link:
type: string
description: |
Returned so the interface can offer to copy it. On an
instance with no mail server that is the only way the invite
reaches anybody.
emailed: { type: boolean }
"403": { $ref: "#/components/responses/Forbidden" }
"409":
description: That address already has an account.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/users/{userID}:
parameters:
- { name: userID, in: path, required: true, schema: { type: string } }
patch:
tags: [auth]
summary: Make somebody an administrator, or stop them being one.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
is_admin: { type: boolean }
responses:
"204": { description: Changed. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: |
The last administrator cannot be demoted — `code` is `last_admin`.
An instance with no administrator has no way back: there is no
console, and setup refuses to run once an account exists.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
delete:
tags: [auth]
summary: Remove an account and everything the foreign keys take with it.
description: |
The only hard delete in this API. Everything else is soft-deleted with a
trash behind it; this is not recoverable.
What goes is wider than it looks: their projects, every task in those
projects, **and every task they wrote anywhere** — `tasks.created_by`
cascades, so retiring a colleague's account removes their contributions
from shared projects too. Tasks merely assigned to them are unassigned
and survive.
Refused for your own account, and for the last administrator.
responses:
"204": { description: Deleted. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: Your own account, or the last administrator.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v1/invites/{inviteID}:
parameters:
- { name: inviteID, in: path, required: true, schema: { type: string } }
delete:
tags: [auth]
summary: Withdraw an invite that has not been accepted.
description: |
Only the token's hash is stored, so a link sent to the wrong address
cannot be looked up and resent — withdrawing it is the only way to stop
it working before it expires.
responses:
"204": { description: Withdrawn. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/project-groups:
get:
tags: [projects]
@@ -1077,9 +1211,46 @@ paths:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
- { name: userID, in: path, required: true, schema: { type: string } }
patch:
tags: [projects]
summary: Change what an existing member may do. The project's owner only.
description: |
Editor or viewer. An UPDATE against a membership that already exists —
somebody who is not a member is not silently made one, because that is an
invite and belongs to the invite flow.
Not the owner: ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom. The
owner also has no membership row at all — ownership lives on the project —
so this answers 409 with the real reason rather than "not a member".
Correcting a role used to mean removing the person and inviting them
again, which also unassigns every task they were responsible for.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role]
properties:
role: { type: string, enum: [editor, viewer] }
responses:
"204": { description: Changed. }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: The owner's standing cannot be changed.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
description: |
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
responses:
"204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" }
@@ -1181,6 +1352,36 @@ paths:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/people:
get:
tags: [views]
summary: Everybody you share a project with, and yourself.
description: |
What an interface needs to put a name and a face on an `assignee_id` — in
a task row, in a filter, anywhere a person appears beside work. One
request per session rather than a lookup per row, and per project members
would mean knowing which project every task belongs to first.
Deliberately not the instance's user list: that is
`/users`, administrators only. A task list has no business enumerating
everybody with an account here.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
people:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
avatar_color: { type: string }
/api/v1/delegated:
get:
tags: [views]
@@ -2270,6 +2471,48 @@ components:
type: array
items: { type: string }
AdminUser:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
name: { type: string }
avatar_color: { type: string }
is_admin: { type: boolean }
self:
type: boolean
description: The caller, so the interface can grey out what nobody should do to their own account here.
created_at: { type: string, format: date-time }
last_seen_at:
type: string
format: date-time
description: |
Absent when they have never signed in — which is what an invite that
was accepted and then forgotten looks like. Absent rather than zero,
because 1970 in an interface reads as a bug.
project_count:
type: integer
description: Projects they own, excluding the Inbox every account has.
task_count:
type: integer
description: |
Everything a delete would destroy: tasks in projects they own, plus
every task they wrote anywhere. Not the same as "their tasks", and
the difference is the whole point of showing it.
PendingInvite:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
project_name:
type: string
description: Absent for an invite to the instance itself rather than to a project.
role: { type: string, enum: [owner, editor, viewer] }
invited_by: { type: string }
created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
Session:
type: object
properties:
@@ -2373,7 +2616,7 @@ components:
properties:
id: { type: string }
name: { type: string }
color: { type: string }
color: { $ref: "#/components/schemas/Color" }
icon: { type: string }
view_mode: { type: string, enum: [list, board, calendar] }
owner_id: { type: string }
@@ -2394,7 +2637,7 @@ components:
type: object
properties:
name: { type: string }
color: { type: string }
color: { $ref: "#/components/schemas/Color" }
icon: { type: string }
view_mode: { type: string, enum: [list, board, calendar] }
archived: { type: boolean }
@@ -2412,6 +2655,7 @@ components:
properties:
id: { type: string }
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
collapsed: { type: boolean }
sort_order: { type: number }
@@ -2419,8 +2663,26 @@ components:
type: object
properties:
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
collapsed: { type: boolean }
Color:
type: string
default: graphite
enum: [graphite, tomato, amber, lime, green, teal, blue, indigo, violet, magenta]
description: |
A name, not a hex value. The interface resolves it when it paints, so the
palette can be retuned for a new theme or a new display without rewriting
every row — and a database that stores a fact rather than a rendering.
The set is closed: an unknown name is refused rather than stored and
painted as the default, which would look like a colour that did not save.
One palette for all five themes. These are small solid marks beside a
label that carries the meaning, not text or grounds, so what they owe is
to be told apart and to be visible on both a near-black and a white
ground — which is why they all sit in the mid-tone band.
Section:
type: object
properties:
+8
View File
@@ -47,8 +47,16 @@ const (
CodeAINotConfigured = "ai_not_configured"
CodeTOTPNotEnabled = "totp_not_enabled"
CodeInboxProtected = "inbox_protected"
// And a fifth, for the same reason: refusing to remove the last administrator
// is not "that already exists" either, and it is the one refusal on the user
// page that a person needs explained rather than merely reported.
CodeLastAdmin = "last_admin"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
// stops rather than carrying on. It is never rendered anywhere.
var errLastAdmin = errors.New("httpapi: refused to remove the last administrator")
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, APIError{Code: code, Message: message})
}
+19 -2
View File
@@ -19,12 +19,16 @@ import (
type projectGroupJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Collapsed bool `json:"collapsed"`
SortOrder float64 `json:"sort_order"`
}
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
return projectGroupJSON{ID: g.ID, Name: g.Name, Collapsed: g.Collapsed, SortOrder: g.SortOrder}
return projectGroupJSON{
ID: g.ID, Name: g.Name, Color: g.Color,
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
}
}
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
@@ -42,6 +46,7 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request)
type projectGroupRequest struct {
Name *string `json:"name"`
Color *string `json:"color"`
Collapsed *bool `json:"collapsed"`
}
@@ -59,7 +64,15 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
return
}
if req.Color != nil && !store.ValidColor(*req.Color) {
writeFieldErrors(w, map[string]string{"color": colorFieldError})
return
}
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
if req.Color != nil {
g.Color = *req.Color
}
if req.Collapsed != nil {
g.Collapsed = *req.Collapsed
}
@@ -87,9 +100,13 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
}
req.Name = &name
}
if req.Color != nil && !store.ValidColor(*req.Color) {
writeFieldErrors(w, map[string]string{"color": colorFieldError})
return
}
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
Name: req.Name, Collapsed: req.Collapsed,
Name: req.Name, Color: req.Color, Collapsed: req.Collapsed,
}); err != nil {
s.storeError(w, "update project group", err)
return
+50
View File
@@ -219,3 +219,53 @@ func groupOf(t *testing.T, ts *testServer, projectID string) string {
t.Fatalf("project %s is not in the listing", projectID)
return ""
}
// Colour is a name from a closed set, on both a project and a group.
//
// Refused rather than stored: an unknown name would be written happily and then
// painted as the default, which looks like a colour that did not save.
func TestAColourMustBeOneFromThePalette(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
if group["color"] != "graphite" {
t.Errorf("a new group's colour = %v, want graphite", group["color"])
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
for _, c := range []struct {
what, method, path string
}{
{"group", "PATCH", "/api/v1/project-groups/" + groupID},
{"project", "PATCH", "/api/v1/projects/" + projectID},
} {
resp, body := ts.do(t, c.method, c.path, map[string]any{"color": "teal"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s, a real colour: %d %v", c.what, resp.StatusCode, body)
}
if body["color"] != "teal" {
t.Errorf("%s colour = %v, want teal", c.what, body["color"])
}
resp, body = ts.do(t, c.method, c.path, map[string]any{"color": "#ff0000"})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%s, a hex value: status %d, want 422 — body %v", c.what, resp.StatusCode, body)
}
}
// A rename does not disturb the colour, and a recolour does not disturb the
// name: both are PATCH fields on the same row, and the whole point of a PATCH
// is that the one you did not send is the one you did not mean.
_, renamed := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
map[string]any{"name": "Kontoret"})
if renamed["color"] != "teal" {
t.Errorf("colour = %v after a rename, want teal", renamed["color"])
}
if renamed["name"] != "Kontoret" {
t.Errorf("name = %v", renamed["name"])
}
}
+66 -3
View File
@@ -122,7 +122,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if fields := validateProject(name, req.ViewMode); len(fields) > 0 {
if fields := validateProject(name, req.ViewMode, req.Color); len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
@@ -158,7 +158,13 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
name = strings.TrimSpace(*req.Name)
req.Name = &name
}
if fields := validateProject(name, req.ViewMode); req.Name != nil && len(fields) > 0 {
// The name is only checked when it was sent — but the colour is checked
// whenever it was, which is why this is two conditions rather than one.
fields := validateProject(name, req.ViewMode, req.Color)
if req.Name == nil {
delete(fields, "name")
}
if len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
@@ -260,7 +266,12 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func validateProject(name string, viewMode *string) map[string]string {
// colorFieldError names the closed set rather than listing it. The list is ten
// long and lives in three places already; a caller that got it wrong needs to be
// pointed at the documentation, not handed a copy that will one day be stale.
const colorFieldError = "must be one of the colours in the palette — see the Color schema"
func validateProject(name string, viewMode, color *string) map[string]string {
fields := map[string]string{}
switch {
case name == "":
@@ -275,6 +286,11 @@ func validateProject(name string, viewMode *string) map[string]string {
fields["view_mode"] = "must be list, board or calendar"
}
}
// Refused rather than stored and quietly painted as the default, which is what
// an unknown name would do — and which looks like a colour that did not save.
if color != nil && !store.ValidColor(*color) {
fields["color"] = colorFieldError
}
return fields
}
@@ -486,6 +502,53 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
}
type memberRoleRequest struct {
Role string `json:"role"`
}
// handleSetMemberRole changes an existing member between editor and viewer.
//
// Until now the only way to correct an invite sent with the wrong role was to
// remove the person and invite them again — which also unassigns everything they
// were responsible for, because removing a member clears their tasks' assignee.
// Fixing a dropdown should not cost somebody their work.
func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
var req memberRoleRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
role := store.Role(req.Role)
if !role.Valid() || role == store.RoleOwner {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
// The owner has no project_members row at all — ownership lives on the project
// — so this would report "not a member", which is true and completely
// unhelpful. Say the real reason instead.
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, "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, "set member role", err)
return
}
s.activity(r, projectID, "", "member.role_changed",
map[string]any{"user_id": userID, "role": string(role)})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
+89
View File
@@ -194,3 +194,92 @@ func TestListMembersWorksForTheInbox(t *testing.T) {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
}
// A role sent by mistake used to be uncorrectable: the only way back was to remove
// the person and invite them again, which unassigns every task they were
// responsible for. Fixing a dropdown should not cost somebody their work.
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "[email protected]", "Anden")
memberUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
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": "[email protected]", "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, "[email protected]", "Anden")
owner, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
memberUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
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": "[email protected]", "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, "[email protected]", "Tredje")
_ = stranger
strangerUser, _ := ts.db.UserByEmail(t.Context(), "[email protected]")
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)
}
}
+15
View File
@@ -264,6 +264,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
r.Get("/people", s.handlePeople)
r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) {
@@ -292,11 +293,25 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
// The instance's membership. Administrators only, and behind
// requireSession as well as requireAdmin: an API token belongs to a
// person, and a leaked one must not be able to mint an account or
// delete somebody else's.
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleCreateUser)
r.Patch("/users/{userID}", s.handleUpdateUser)
r.Delete("/users/{userID}", s.handleDeleteUser)
r.Delete("/invites/{inviteID}", s.handleDeleteInvite)
})
// Groups are a person's own headings over their projects, so there is
// no project permission to check — every query here is scoped to the
// caller.
+237
View File
@@ -0,0 +1,237 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// Who is on the instance, and how somebody else gets on it.
//
// There is no open registration and there never was: an account comes into
// existence in exactly two ways — the first admin at setup, and somebody accepting
// an invite. This surface is the second one made reachable without first sharing a
// project, which is what "create a user" turns out to mean here.
//
// The whole file is administrators only.
type adminUserJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
IsAdmin bool `json:"is_admin"`
// Self marks the caller, so the interface can grey out the two things nobody
// should do to their own account from here.
Self bool `json:"self"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at,omitempty"`
// ProjectCount and TaskCount are what a delete would destroy. Sent with the
// list rather than fetched when the button is pressed, so the confirmation can
// name real numbers without a round trip in the middle of a decision.
ProjectCount int `json:"project_count"`
TaskCount int `json:"task_count"`
}
type pendingInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
// ProjectName is empty for an invite to the instance itself, which is what
// this page issues. The two read very differently to whoever is looking.
ProjectName string `json:"project_name,omitempty"`
Role string `json:"role"`
InvitedBy string `json:"invited_by,omitempty"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
me := userFrom(r.Context())
users, err := s.db.ListUsers(r.Context())
if err != nil {
s.internal(w, "list users", err)
return
}
invites, err := s.db.ListPendingInvites(r.Context())
if err != nil {
s.internal(w, "list pending invites", err)
return
}
out := make([]adminUserJSON, 0, len(users))
for _, u := range users {
j := adminUserJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
}
// Zero means never signed in, which is not a time and must not be sent as
// one — 1970 in an interface reads as a bug rather than as "never".
if !u.LastSeenAt.IsZero() {
j.LastSeenAt = u.LastSeenAt.Format(time.RFC3339)
}
out = append(out, j)
}
pending := make([]pendingInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, pendingInviteJSON{
ID: i.ID, Email: i.Email, ProjectName: i.ProjectName, Role: string(i.Role),
InvitedBy: i.InvitedBy,
CreatedAt: i.CreatedAt.Format(time.RFC3339),
ExpiresAt: i.ExpiresAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"users": out, "invites": pending})
}
type createUserRequest struct {
Email string `json:"email"`
}
// handleCreateUser invites somebody to the instance.
//
// An invite rather than an account with a password the administrator chooses. A
// password typed here would be known to two people before it was ever used, and
// would have to travel to the second one by some channel nobody has thought about.
// The link does the same job and ends with a password only its owner has seen.
//
// No name is asked for, because the signup form asks the person themselves — a
// name an administrator guesses is a name somebody has to correct.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
inviter := userFrom(r.Context())
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
if _, err := s.db.UserByEmail(r.Context(), email); err == nil {
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
return
}
// RoleEditor is carried on the row because the column requires a role, and it
// is never read: AcceptInvite returns early when there is no project to grant
// anything on.
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
inviter.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, "create instance invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
emailed := false
if s.mail.Configured() {
if err := s.mail.SendInvite(r.Context(), email, inviter.Name, "", link, s.cfg.InviteTTL); err != nil {
s.log.Error("send instance invite", "err", err, "to", email)
} else {
emailed = true
}
}
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
}
type updateUserRequest struct {
IsAdmin *bool `json:"is_admin"`
}
// handleUpdateUser promotes or demotes an administrator.
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
var req updateUserRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
if req.IsAdmin == nil {
w.WriteHeader(http.StatusNoContent)
return
}
// Standing down as the last administrator locks everybody out of this page,
// and there is no console and no second setup run to undo it with.
if !*req.IsAdmin {
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be demoted"); err != nil {
return
}
}
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
s.storeError(w, "set user admin", err)
return
}
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
w.WriteHeader(http.StatusNoContent)
}
// handleDeleteUser removes an account and everything the foreign keys take with
// it. See store.DeleteUser for what that is; it is more than it looks.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
// Not yourself. Deleting the account you are signed in as would take your own
// projects while you were looking at them, and end the session mid-request.
// Somebody who really means it can be demoted first and removed by another
// administrator.
if userID == me.ID {
writeError(w, http.StatusConflict, CodeConflict,
"you cannot delete the account you are signed in as")
return
}
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be deleted"); err != nil {
return
}
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
s.storeError(w, "delete user", err)
return
}
s.log.Info("user deleted", "by", me.ID, "user", userID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, "delete invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// refuseIfLastAdmin writes a 409 and returns an error when the named user is the
// only administrator left. The two callers both need it, and getting it wrong in
// one of them is an instance nobody can administer again.
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
target, err := s.db.UserByID(r.Context(), userID)
if err != nil {
s.storeError(w, "get user", err)
return err
}
if !target.IsAdmin {
return nil
}
admins, err := s.db.CountAdmins(r.Context())
if err != nil {
s.internal(w, "count admins", err)
return err
}
if admins <= 1 {
writeError(w, http.StatusConflict, CodeLastAdmin, message)
return errLastAdmin
}
return nil
}
+279
View File
@@ -0,0 +1,279 @@
package httpapi
import (
"net/http"
"testing"
)
// The instance's membership is an administrator's list, and creating an account
// means issuing an invite — there is no other door, and no password chosen by
// somebody other than its owner.
func TestAnAdminCanInviteSomebodyToTheInstance(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "[email protected]"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invite: %d %v", resp.StatusCode, body)
}
link, _ := body["link"].(string)
if link == "" {
t.Fatal("no link came back — with no mail server it is the only way the invite reaches anybody")
}
if body["emailed"] != false {
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
}
// It shows as pending, with no project attached: this is an invite to the
// instance, not to somewhere in it.
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
invites, _ := listed["invites"].([]any)
if len(invites) != 1 {
t.Fatalf("want one pending invite, got %v", invites)
}
invite := invites[0].(map[string]any)
if invite["email"] != "[email protected]" {
t.Errorf("email = %v", invite["email"])
}
if name, ok := invite["project_name"]; ok && name != "" {
t.Errorf("project_name = %v, want none for an instance invite", name)
}
// And the token in the link creates the account, through the same signup the
// project invites use.
token := link[len(link)-len(link)+indexAfter(link, "token="):]
fresh := newSignedOut(t, ts)
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
t.Fatalf("signup with the instance invite: status %d", resp.StatusCode)
}
// They are on the instance, and they are not an administrator.
_, after := ts.do(t, "GET", "/api/v1/users", nil)
users, _ := after["users"].([]any)
if len(users) != 2 {
t.Fatalf("want two accounts, got %v", users)
}
for _, raw := range users {
u := raw.(map[string]any)
if u["email"] == "[email protected]" && u["is_admin"] != false {
t.Errorf("a newly invited account is an administrator: %v", u)
}
}
// The invite is used up rather than left live.
if pending, _ := after["invites"].([]any); len(pending) != 0 {
t.Errorf("the invite is still pending after being accepted: %v", pending)
}
}
// The two refusals that make this page safe to have at all.
func TestTheLastAdministratorCannotBeRemoved(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// Demoting yourself as the only administrator locks everybody out: there is no
// console, and setup refuses to run once an account exists.
resp, body := ts.do(t, "PATCH", "/api/v1/users/"+me.ID, map[string]any{"is_admin": false})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("demote the last admin: status %d, want 409", resp.StatusCode)
}
if body["code"] != "last_admin" {
t.Errorf("code = %v, want last_admin — the interface has to explain this one", body["code"])
}
// And deleting the account you are signed in as is refused before that check
// is even reached.
resp, _ = ts.do(t, "DELETE", "/api/v1/users/"+me.ID, nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("delete your own account: status %d, want 409", resp.StatusCode)
}
// Still an administrator, and still there.
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
first := listed["users"].([]any)[0].(map[string]any)
if first["is_admin"] != true || first["self"] != true {
t.Errorf("the only administrator is no longer one: %v", first)
}
}
// With a second administrator the refusal lifts — it is about the last one, not
// about administrators.
func TestASecondAdministratorCanBeDemoted(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "[email protected]", "Anden")
other, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
map[string]any{"is_admin": true}); resp.StatusCode != http.StatusNoContent {
t.Fatalf("promote: status %d", resp.StatusCode)
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
map[string]any{"is_admin": false}); resp.StatusCode != http.StatusNoContent {
t.Errorf("demote a second administrator: status %d, want 204", resp.StatusCode)
}
}
// Deleting an account takes more than its own projects, and the list says so
// before anybody presses the button.
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// One project of their own, with a task in it.
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "deres egen", "project_id": theirs["id"].(string),
})
// And a task they wrote in a project somebody else owns. tasks.created_by
// cascades, so this one goes too — and a count that missed it would understate
// the damage in exactly the case that matters.
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
sharedID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "skrevet i et delt projekt", "project_id": sharedID,
})
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
var found map[string]any
for _, raw := range listed["users"].([]any) {
if u := raw.(map[string]any); u["id"] == otherUser.ID {
found = u
}
}
if found == nil {
t.Fatal("the second account is not in the list")
}
if found["project_count"] != float64(1) {
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
}
if found["task_count"] != float64(2) {
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
found["task_count"])
}
// And the delete really does take it.
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
}
}
// Everything here is administrators only, and not reachable with an API token
// even by an administrator: a leaked token must not be able to mint an account.
func TestTheUserPageIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, c := range []struct {
what, method, path string
body any
}{
{"list", "GET", "/api/v1/users", nil},
{"invite", "POST", "/api/v1/users", map[string]any{"email": "[email protected]"}},
{"promote", "PATCH", "/api/v1/users/whoever", map[string]any{"is_admin": true}},
{"delete", "DELETE", "/api/v1/users/whoever", nil},
{"revoke", "DELETE", "/api/v1/invites/whichever", nil},
} {
if resp, _ := other.do(t, c.method, c.path, c.body); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", c.what, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/users", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("an administrator's API token reached the user list: status %d", resp.StatusCode)
}
}
// A pending invite can be withdrawn, which is the only way to stop a link that
// went to the wrong address: the token is stored as a hash and cannot be looked up.
func TestAPendingInviteCanBeWithdrawn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "[email protected]"})
link := created["link"].(string)
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
inviteID := listed["invites"].([]any)[0].(map[string]any)["id"].(string)
if resp, _ := ts.do(t, "DELETE", "/api/v1/invites/"+inviteID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("withdraw: status %d", resp.StatusCode)
}
// The link no longer works.
token := link[indexAfter(link, "token="):]
fresh := newSignedOut(t, ts)
resp, _ := fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Forkert", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a withdrawn invite still signs somebody up: status %d", resp.StatusCode)
}
}
// An address that already has an account is a conflict rather than a second
// invite: the signup would fail at the end, after somebody had chosen a password.
func TestInvitingAnAddressThatAlreadyHasAnAccount(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/users",
map[string]any{"email": "[email protected]"})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status %d, want 409 — body %v", resp.StatusCode, body)
}
}
// newSignedOut is a client against the same server with an empty cookie jar.
func newSignedOut(t *testing.T, ts *testServer) *testServer {
t.Helper()
return &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
}
// indexAfter returns the offset just past `needle` in `s`.
func indexAfter(s, needle string) int {
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
return i + len(needle)
}
}
return 0
}
+25
View File
@@ -92,6 +92,31 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": out})
}
// handlePeople lists everybody the caller shares a project with, so an interface
// can put a name and a face on an assignee id.
//
// One request per session rather than a lookup per row, and not the instance's
// user list — that is the administrator's page. A task list has no business
// enumerating everybody with an account here.
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list people", err)
return
}
out := make([]personJSON, 0, len(people))
for _, p := range people {
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
type personJSON struct {
ID string `json:"id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
}
type delegatedPerson struct {
UserID string `json:"user_id"`
Name string `json:"name"`
+47
View File
@@ -153,3 +153,50 @@ func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
t.Errorf("a task in a project they cannot see: %v", people)
}
}
// /people is what puts a name on an assignee id. It must be the people you share
// work with — not the instance's user list, which is the administrator's page.
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
colleague := ts.newUser(t, "[email protected]", "Anders")
ts.newUser(t, "[email protected]", "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": "[email protected]", "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)
}
}
+39
View File
@@ -0,0 +1,39 @@
package store
// The colours a project, group, label or filter may be marked with.
//
// Names rather than hex values, and the reason is what happens later: a hex in
// ten thousand rows is a decision nobody can revisit, while a name is resolved by
// the interface at the moment it paints and can be retuned for a new theme, a new
// palette, or a display nobody has yet. It is also the difference between a
// database that stores a fact and one that stores a rendering.
//
// The list is closed. An unknown name would be stored happily and then paint as
// the default, which looks like a colour that did not save — so it is refused at
// the edge instead. web/src/lib/colors.js carries the same ids for the picker, and
// a test reads that file to check the two have not drifted.
var Colors = []string{
"graphite",
"tomato",
"amber",
"lime",
"green",
"teal",
"blue",
"indigo",
"violet",
"magenta",
}
// DefaultColor is what an unmarked thing is, and what the schema defaults to.
const DefaultColor = "graphite"
// ValidColor reports whether a name is one this app can paint.
func ValidColor(name string) bool {
for _, c := range Colors {
if c == name {
return true
}
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The palette is written down three times: here, in web/src/lib/colors.js for the
// picker, and in web/src/app.css as the tokens that actually paint. Three copies
// is two chances to drift, and the failure is quiet — a colour the server accepts
// and the interface cannot draw renders as the default, which looks like a colour
// that did not save.
//
// So this reads the other two. A small scanner rather than a parser, the same
// approach openapi_test.go takes to the spec: it needs the ids out of a list this
// repository also owns, and that is worth more than a dependency in a project
// whose whole shape is "one static binary".
func TestThePaletteIsTheSameInGoAndInTheFrontend(t *testing.T) {
js := readFrontendColors(t, filepath.Join("..", "..", "web", "src", "lib", "colors.js"))
css := readColorTokens(t, filepath.Join("..", "..", "web", "src", "app.css"))
for _, name := range Colors {
if !js[name] {
t.Errorf("%q is accepted by the server and missing from colors.js — the picker cannot offer it", name)
}
if !css[name] {
t.Errorf("--color-%s is missing from app.css — the server accepts a colour nothing can paint", name)
}
}
for name := range js {
if !ValidColor(name) {
t.Errorf("colors.js offers %q, which the server refuses — picking it would fail to save", name)
}
}
}
// readFrontendColors pulls the ids out of the COLORS array: lines shaped
// `{ id: 'tomato', name: 'Tomat' },`.
func readFrontendColors(t *testing.T, path string) map[string]bool {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read colors.js: %v", err)
}
found := map[string]bool{}
for _, line := range strings.Split(string(body), "\n") {
id, ok := between(line, "{ id: '", "'")
if ok {
found[id] = true
}
}
if len(found) == 0 {
t.Fatal("no colours were read out of colors.js — the scanner and the file disagree")
}
return found
}
// readColorTokens pulls the names out of `--color-tomato: #d9614f;`.
func readColorTokens(t *testing.T, path string) map[string]bool {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read app.css: %v", err)
}
found := map[string]bool{}
for _, line := range strings.Split(string(body), "\n") {
name, ok := between(line, "--color-", ":")
if ok {
found[name] = true
}
}
if len(found) == 0 {
t.Fatal("no --color- tokens were read out of app.css")
}
return found
}
func between(line, open, close string) (string, bool) {
start := strings.Index(line, open)
if start < 0 {
return "", false
}
rest := line[start+len(open):]
end := strings.Index(rest, close)
if end <= 0 {
return "", false
}
return rest[:end], true
}
+64
View File
@@ -136,6 +136,70 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
return &inv, nil
}
// PendingInvite is an invite that has been sent and not yet used, as the
// administrator's list shows it. The token is not here and cannot be: only its
// hash is stored, so a link that has gone astray is revoked rather than resent.
type PendingInvite struct {
ID string
Email string
ProjectID string
ProjectName string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// ListPendingInvites returns invites that are neither accepted nor expired.
//
// Expired ones are left out rather than shown greyed: an invite past its date is
// not a thing anybody can act on, and a list of them would grow forever. The
// project name is joined in because an invite with no project means something
// different — an invitation to the instance itself — and the interface has to be
// able to say which it is.
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
COALESCE(u.name, ''), i.created_at, i.expires_at
FROM invites i
LEFT JOIN projects p ON p.id = i.project_id
LEFT JOIN users u ON u.id = i.created_by
WHERE i.accepted_at IS NULL AND i.expires_at > ?
ORDER BY i.created_at DESC`, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []PendingInvite{}
for rows.Next() {
var p PendingInvite
var created, expires int64
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
&p.InvitedBy, &created, &expires); err != nil {
return nil, err
}
p.CreatedAt = time.Unix(created, 0).UTC()
p.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, p)
}
return out, rows.Err()
}
// DeleteInvite revokes an invite, which is what makes a link sent to the wrong
// address recoverable: the token cannot be looked up, so withdrawing it is the
// only way to stop it working before it expires.
func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
res, err := db.ExecContext(ctx, `DELETE FROM invites WHERE id = ?`, inviteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
@@ -0,0 +1,11 @@
-- A colour on a sidebar group, so a group can be marked the way a project is.
--
-- Projects, labels and filters have carried a `color` since 0001; groups arrived
-- in 0005 without one because nothing rendered any of them yet. Now that the
-- sidebar paints them, a group without one would be the only thing in the list
-- that could not be told apart at a glance.
--
-- Same default and same spelling as the others: a colour name, not a hex. The
-- value has to survive a theme change and a redesign, and '#8a8f98' in ten
-- thousand rows is a decision nobody can revisit.
ALTER TABLE project_groups ADD COLUMN color TEXT NOT NULL DEFAULT 'graphite';
+13 -7
View File
@@ -18,6 +18,7 @@ type ProjectGroup struct {
ID string
OwnerID string
Name string
Color string
Collapsed bool
SortOrder float64
CreatedAt time.Time
@@ -26,7 +27,7 @@ type ProjectGroup struct {
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE owner_id = ?
ORDER BY sort_order, created_at`, userID)
if err != nil {
@@ -39,7 +40,7 @@ func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGr
var g ProjectGroup
var collapsed int
var created, updated int64
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder,
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder,
&created, &updated); err != nil {
return nil, err
}
@@ -59,9 +60,9 @@ func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*Pro
var collapsed int
var created, updated int64
err := db.QueryRowContext(ctx,
`SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder, &created, &updated)
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -80,6 +81,9 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
}
now := time.Now().UTC()
g.CreatedAt, g.UpdatedAt = now, now
if g.Color == "" {
g.Color = DefaultColor
}
if g.SortOrder == 0 {
var max sql.NullFloat64
if err := db.QueryRowContext(ctx,
@@ -90,14 +94,15 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
g.SortOrder = max.Float64 + 1024
}
_, err := db.ExecContext(ctx,
`INSERT INTO project_groups (id, owner_id, name, collapsed, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.OwnerID, g.Name, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
`INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.OwnerID, g.Name, g.Color, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
return err
}
type ProjectGroupUpdate struct {
Name *string
Color *string
Collapsed *bool
SortOrder *float64
}
@@ -105,6 +110,7 @@ type ProjectGroupUpdate struct {
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
set, args := buildUpdate(map[string]any{
"name": u.Name,
"color": u.Color,
"collapsed": u.Collapsed,
"sort_order": u.SortOrder,
})
+27
View File
@@ -389,6 +389,33 @@ func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role
return err
}
// SetMemberRole changes what an existing member may do.
//
// An UPDATE rather than the upsert AddMember does, and the difference is the
// point: this is a PATCH against a membership that exists. Somebody who is not a
// member is not silently made one — that is an invite, it belongs to the invite
// flow, and doing it here would let a typo in a user id add a stranger to a
// project without anybody being asked.
//
// Owner is refused for the same reason AddMember refuses it: ownership is
// transferred, not granted, and two owners is a state with no way to settle a
// disagreement about who may remove whom.
func (db *DB) SetMemberRole(ctx context.Context, projectID, userID string, role Role) error {
if !role.Valid() || role == RoleOwner {
return errors.New("store: members may be editor or viewer")
}
res, err := db.ExecContext(ctx,
`UPDATE project_members SET role = ? WHERE project_id = ? AND user_id = ?`,
string(role), projectID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
// Anything assigned to them is unassigned rather than left pointing at
+155
View File
@@ -163,6 +163,161 @@ func (db *DB) PeopleByIDs(ctx context.Context, ids []string) (map[string]Person,
return people, rows.Err()
}
// UserSummary is a user as the administrator's list shows them: the account, plus
// the two numbers that say what deleting it would take with it and the one that
// says whether it is in use at all.
type UserSummary struct {
User
// LastSeenAt is the most recent request from any of their sessions, zero if
// they have never signed in — which is what an invite that was accepted and
// then forgotten looks like.
LastSeenAt time.Time
// ProjectCount excludes the Inbox: every account has one, so counting it would
// make "1 project" mean "nothing".
ProjectCount int
// TaskCount is everything a delete would destroy, which is not the same as
// "their tasks": it is the tasks in projects they own *plus* every task they
// wrote anywhere, because tasks.created_by cascades. A number that counted
// only the first would understate the damage in exactly the case that matters
// — a colleague leaving a shared project.
TaskCount int
}
// ListUsers returns every account on the instance. Administrators only — this is
// the whole membership of the server, which is not a list anybody else is owed.
func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id, u.email, u.name, u.avatar_color, u.is_admin, u.created_at,
COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.user_id = u.id), 0),
(SELECT count(*) FROM projects p
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE t.deleted_at IS NULL
AND (p.owner_id = u.id OR t.created_by = u.id))
FROM users u
ORDER BY u.is_admin DESC, u.name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []UserSummary{}
for rows.Next() {
var s UserSummary
var isAdmin int
var created, lastSeen int64
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
&lastSeen, &s.ProjectCount, &s.TaskCount); err != nil {
return nil, err
}
s.IsAdmin = isAdmin == 1
s.CreatedAt = time.Unix(created, 0).UTC()
if lastSeen > 0 {
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
}
out = append(out, s)
}
return out, rows.Err()
}
// CountAdmins is what stops the last administrator from being deleted or demoted.
// An instance with no administrator has no way back: there is no console, and the
// setup route refuses to run once an account exists.
func (db *DB) CountAdmins(ctx context.Context) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE is_admin = 1`).Scan(&n)
return n, err
}
func (db *DB) SetUserAdmin(ctx context.Context, userID string, admin bool) error {
res, err := db.ExecContext(ctx,
`UPDATE users SET is_admin = ?, updated_at = ? WHERE id = ?`,
boolToInt(admin), time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteUser removes an account and everything that hangs off it.
//
// A hard delete, and the only one in this database. Every other delete here is a
// `deleted_at` with a trash behind it; this one relies on the foreign keys and
// there is no way back — which is why the handler sends the counts to the
// interface first.
//
// What goes is wider than it looks. `projects.owner_id` cascades, so their
// projects go and every task in them goes too. But `tasks.created_by` cascades as
// well, so **every task they ever wrote goes, including in projects somebody else
// owns**. Retiring a colleague's account therefore removes their contributions
// from shared projects rather than leaving them behind unattributed.
//
// That is the schema as it stands rather than a decision made here. Changing it —
// `created_by` as ON DELETE SET NULL, so the work stays and loses its author —
// means rebuilding the tasks table, which carries the FTS triggers with it. Worth
// doing; not worth doing quietly as part of adding a screen.
//
// What survives: tasks merely *assigned* to them, which are unassigned by
// `assignee_id ON DELETE SET NULL`, and completions, by the same rule on
// `completed_by`.
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ListPeople returns everybody the caller shares at least one project with, plus
// the caller.
//
// The set the interface needs to put a name on an `assignee_id` — in a task row,
// in a filter, anywhere a person appears next to work. Fetched once rather than
// looked up per row, and deliberately *not* the instance's user list: that is the
// administrator's page, and a task list has no business enumerating everybody with
// an account here.
//
// Themselves included, because a client comparing "is this me" against the same
// list it draws from has one source rather than two.
func (db *DB) ListPeople(ctx context.Context, userID string) ([]Person, error) {
rows, err := db.QueryContext(ctx, `
SELECT DISTINCT u.id, u.name, u.avatar_color
FROM users u
WHERE u.id = ?
-- the owners of projects the caller is a member of
OR u.id IN (SELECT p.owner_id FROM projects p
JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ? AND p.deleted_at IS NULL)
-- and the members of every project the caller can see
OR u.id IN (SELECT m2.user_id FROM project_members m2
JOIN projects p2 ON p2.id = m2.project_id
WHERE p2.deleted_at IS NULL
AND (p2.owner_id = ?
OR EXISTS (SELECT 1 FROM project_members m3
WHERE m3.project_id = p2.id AND m3.user_id = ?)))
ORDER BY u.name`, userID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
people := []Person{}
for rows.Next() {
var p Person
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
return nil, err
}
people = append(people, p)
}
return people, rows.Err()
}
// UserCount is what decides whether the instance still needs its first admin.
func (db *DB) UserCount(ctx context.Context) (int, error) {
var n int
+262 -6
View File
@@ -153,6 +153,9 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
'Integrationer',
'AI',
'API-tokens',
// Only an administrator sees this one, and the account these tests run as
// is the first account, which is one.
'Brugere',
'Data og skabeloner'
]) {
await tabs.getByRole('link', { name: label, exact: true }).click();
@@ -253,7 +256,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
await sidebar.getByLabel('Gruppens navn').press('Enter');
const heading = sidebar.getByRole('button', { name: 'Arbejde' });
// `exact`, because the heading's colour dot is a button too and its label is
// "Farve på Arbejde" — and Playwright matches an accessible name by substring
// unless told otherwise.
const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true });
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
@@ -292,11 +298,38 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await heading.click();
await expect(heading).toHaveAttribute('aria-expanded', 'false');
await page.reload();
await expect(sidebar.getByRole('button', { name: 'Arbejde' })).toHaveAttribute(
await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute(
'aria-expanded',
'false'
);
await sidebar.getByRole('button', { name: 'Arbejde' }).click();
await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click();
// Colour, and indentation. The dot is the control as well as the preview, so
// the thing you press is the thing you are about to change.
await sidebar.getByRole('button', { name: 'Farve på Arbejde' }).click();
await sidebar.getByRole('button', { name: 'Petrol' }).click();
const painted = await sidebar.locator('.folder-head .group-dot').evaluate((el) =>
getComputedStyle(el).backgroundColor
);
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
// A project inside a group starts further in than a row outside one. Measured
// rather than asserted on a class, because indentation is a fact about where
// the row's contents land — and measured at the *content* edge, not the box:
// the indent is padding, so the element's own left edge does not move.
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
const contentLeft = (el) =>
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
const [inside, outside] = await Promise.all([
sidebar.locator('.folder > a').first().evaluate(contentLeft),
sidebar.locator('.views a').first().evaluate(contentLeft)
]);
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
await sidebar
.getByRole('link', { name: 'Regnskab' })
.dragTo(sidebar.getByRole('heading', { name: 'Projekter' }));
// Renaming writes one row rather than one per project, which is the reason a
// group is a table and not a string repeated on every project.
@@ -304,19 +337,72 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeVisible();
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeVisible();
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeHidden();
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* A task can be dropped into a section, including one with nothing in it.
*
* Only the task *rows* took a drop, so a section with no rows had nothing to aim
* at — and a section you have just made is always empty, which is exactly when
* you want to fill it. The board has always taken a drop on the column; the list
* had no equivalent.
*
* The second half is the other thing that made this hard to report: the page used
* to say "Projektet findes ikke, eller du har ikke adgang til det" whenever the
* load failed *or* had not finished, so a dropped connection and a project you
* cannot see were one sentence, and it never asked again.
*/
test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning siger det', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await page.getByLabel('Ny opgave').fill('brænde kaffe');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('brænde kaffe')).toBeVisible();
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
await page.getByLabel('Ny sektion').fill('Uge 34');
await page.getByLabel('Ny sektion').press('Enter');
const section = page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Uge 34' }) });
await expect(section.getByText('Tom')).toBeVisible();
await page.locator('.sortable').filter({ hasText: 'brænde kaffe' }).dragTo(section);
await expect(section.getByText('brænde kaffe')).toBeVisible();
await expect(page.locator('.toast')).toHaveCount(0);
// A load that fails says so, and offers to try again.
await page.route('**/api/v1/projects/*', (r) => r.abort());
await page.reload();
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
await page.unroute('**/api/v1/projects/*');
await page.getByRole('button', { name: 'Prøv igen' }).click();
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
});
/**
* A task can be dragged onto another day, and onto another project.
*
@@ -440,6 +526,26 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
await context.close();
// The invitee opens the project. Nothing has ever driven this: the assertion
// above only proved the project reaches their *sidebar*, and a member's view
// of a project page is a different code path from an owner's — different role,
// different guards, and the page shows the same "findes ikke" message while it
// is still loading as it does when the load failed.
const inviteePage = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const member = await inviteePage.newPage();
const memberTrouble = watchForTrouble(member);
await member.goto('/');
await member.getByLabel('E-mail').fill('[email protected]');
await member.getByLabel('Adgangskode').fill('et langt kodeord til test');
await member.getByRole('button', { name: 'Log ind' }).click();
const theirs = member.getByRole('navigation', { name: 'Hovedmenu' });
await theirs.getByRole('link', { name: /Fælleshuset/ }).click();
await expect(member.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
await expect(member.getByText('findes ikke')).toHaveCount(0);
expect(memberTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
await inviteePage.close();
// Now that there is somebody to delegate to, the inviter can — and the view
// for it appears. It is hidden on an instance with one person, where it could
// never have anything in it.
@@ -464,7 +570,10 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await sidebar.getByRole('link', { name: 'Venter på andre' }).click();
await expect(page.getByRole('heading', { name: 'Venter på andre' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nabo' })).toBeVisible();
await expect(page.getByText('males inden fredag')).toBeVisible();
// The task's own text, not the whole row's: quick add read "fredag" as the due
// date, so the title is "males inden" and the rest of that line is metadata —
// which now has the assignee in it too.
await expect(page.getByText('males inden', { exact: true })).toBeVisible();
expect(trouble).toEqual([]);
});
@@ -533,6 +642,153 @@ test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ brows
expect(trouble).toEqual([]);
});
/**
* An administrator can put somebody on the instance without sharing a project
* with them first.
*
* This is the answer to "how do I create a user": there is no open registration
* and no account with a password somebody else chose, so adding a person means
* issuing an invite that carries no project. The whole loop is worth driving —
* the link, a second browser, the signup, and the new account showing up in the
* list as somebody who is *not* an administrator.
*/
test('en administrator kan invitere en bruger til instansen', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('[email protected]');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
expect(link).toContain('/invite?token=');
await expect(page.getByText('[email protected]')).toBeVisible();
await expect(page.getByText('til instansen')).toBeVisible();
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const invitee = await context.newPage();
await invitee.goto(link);
await invitee.getByLabel('Navn').fill('Anders');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
// They are in — with an Inbox of their own, and no project shared with them,
// which is the difference between this and a project invite.
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(invitee.getByRole('heading', { name: 'I dag' })).toBeVisible();
await context.close();
await page.reload();
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
const row = konti.locator('li').filter({ hasText: 'Anders' });
await expect(row).toBeVisible();
// Invited, not promoted. Asserted through the button that offers to promote
// them — which only reads this way for somebody who is not one. Matching the
// word "administrator" in the row would match that very button.
await expect(row.getByRole('button', { name: 'Gør til administrator' })).toBeVisible();
// And the invite is spent rather than left live.
await expect(page.getByText('Afventer svar')).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* The two refusals that keep an instance administrable. Driven here as well as in
* the API tests because the interface has to *say* why, and a 409 nobody explains
* is a button that appears broken.
*/
test('den sidste administrator kan hverken fjernes eller slette sig selv', async ({ page }) => {
await page.goto('/indstillinger/brugere');
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
const mine = konti.locator('li').filter({ hasText: 'dig' });
await expect(mine).toBeVisible();
// No buttons on your own row: the server refuses both, and a button whose only
// purpose is to be refused wastes a click to teach a rule.
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('[email protected]');
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 MCP connector address must not fall through to the app shell.
*
+28
View File
@@ -45,6 +45,34 @@
--danger: #cf6b6b;
--danger-sunken: #33201f;
/* Marks for projects, groups, labels and filters.
*
* The one set of colours in this file that is *not* redefined per theme, and
* on purpose. Everything else here is text, a ground or a border, and has to
* be measured against whatever sits behind it. These are none of those: they
* are small solid dots beside a label that carries the meaning, so what they
* owe is to be told apart from one another and to be visible on both a
* near-black and a white ground. All of them sit in the mid-tone band where
* that is true, which is why none is very dark or very pale.
*
* It also keeps them out of the trap the themes are in: a colour added to four
* blocks and forgotten in the fifth looks finished and falls back silently.
* There is no fifth block to forget.
*
* The ids are what the database stores. web/src/lib/colors.js lists them for
* the picker, and a Go test reads that file to check the server's list of
* accepted names has not drifted from it. */
--color-graphite: #8a8f98;
--color-tomato: #d9614f;
--color-amber: #d9a04f;
--color-lime: #a3b74d;
--color-green: #4fae7a;
--color-teal: #45b0ab;
--color-blue: #5b9bd9;
--color-indigo: #7f83d6;
--color-violet: #a86fd0;
--color-magenta: #d46aa8;
/* Type. System stack: it renders instantly, matches the platform, and has
proper Danish glyphs on every device — a webfont would cost a round trip
and a flash of unstyled text on the one screen people open fifty times a day. */
+13 -1
View File
@@ -46,7 +46,9 @@ const MESSAGES = {
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
totp_not_enabled: 'To-faktor er ikke slået til.',
inbox_protected: 'Indbakken kan ikke slettes.'
inbox_protected: 'Indbakken kan ikke slettes.',
last_admin:
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.'
};
/**
@@ -170,6 +172,13 @@ export const api = {
listSessions: () => get('/auth/sessions'),
endSession: (id) => del(`/auth/sessions/${id}`),
// --- users, administrators only
listUsers: () => get('/users'),
inviteUser: (email) => post('/users', { email }),
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
@@ -196,6 +205,8 @@ export const api = {
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
activity: (projectId) => get(`/projects/${projectId}/activity`),
@@ -296,5 +307,6 @@ export const api = {
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
delegated: () => get('/delegated'),
people: () => get('/people'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};
+45
View File
@@ -0,0 +1,45 @@
/**
* The colours a project, a group, a label or a filter can be marked with.
*
* The `color` column has been on projects since the first migration, defaulting
* to 'graphite', and nothing has ever read it. This is the list that makes it
* mean something.
*
* **One set for all five themes, deliberately.** Every other colour in this app is
* redefined per theme, because every other colour is text, a ground, or a border,
* and those have to be measured against the surface behind them. These are none of
* those: they are small solid marks sitting next to a label that carries the
* meaning. What they need is to be told apart from each other, and to be visible
* on both a near-black and a white ground — which mid-tone, medium-saturation
* colours are, and which is why they are all in that band.
*
* Defining them once is also what keeps them out of the trap the themes are in:
* a colour added to four theme blocks and forgotten in the fifth looks finished
* and falls back silently. There is no fifth block to forget.
*
* The ids are what the database stores, so they are not to be renamed. `app.css`
* carries a `--color-<id>` token for each, and a Go test reads this file to check
* the server's list of accepted names has not drifted from it.
*/
export const COLORS = [
{ id: 'graphite', name: 'Grafit' },
{ id: 'tomato', name: 'Tomat' },
{ id: 'amber', name: 'Rav' },
{ id: 'lime', name: 'Lime' },
{ id: 'green', name: 'Grøn' },
{ id: 'teal', name: 'Petrol' },
{ id: 'blue', name: 'Blå' },
{ id: 'indigo', name: 'Indigo' },
{ id: 'violet', name: 'Violet' },
{ id: 'magenta', name: 'Magenta' }
];
/** The CSS variable a colour id paints with, falling back to the default. */
export function colorVar(id) {
return COLORS.some((c) => c.id === id) ? `var(--color-${id})` : 'var(--color-graphite)';
}
/** The Danish name, for the swatch's label. */
export function colorName(id) {
return COLORS.find((c) => c.id === id)?.name ?? COLORS[0].name;
}
+103 -1
View File
@@ -2,6 +2,7 @@
import { app, sidebar } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -78,6 +79,8 @@
let newGroupName = $state('');
let renamingGroup = $state(null);
let groupName = $state('');
/** The group whose swatches are open, if any. */
let coloringGroup = $state(null);
async function createGroup(event) {
event.preventDefault();
@@ -353,7 +356,7 @@
}}
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
>
<span class="dot" aria-hidden="true"></span>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{project.name}
{#if project.shared}
<span class="count">{project.member_count}</span>
@@ -480,6 +483,20 @@
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The dot is the control as well as the preview: what you press is
the thing you are about to change. It also keeps the heading's
actions down to two — they carry `opacity: 0` rather than
`display: none`, so they take their width whether or not you can
see them, and a third one squeezed the group's name until it
truncated. -->
<button
class="dot group-dot"
style="background: {colorVar(group.color)}"
onclick={() => (coloringGroup = coloringGroup === group.id ? null : group.id)}
aria-expanded={coloringGroup === group.id}
aria-label="Farve på {group.name}"
title="Farve"
></button>
<h2 class="fold">
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
<svg
@@ -505,6 +522,25 @@
{/if}
</div>
{#if coloringGroup === group.id}
<div class="swatches" role="group" aria-label="Farve på {group.name}">
{#each COLORS as color (color.id)}
<button
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={color.name}
aria-label={color.name}
aria-pressed={(group.color ?? 'graphite') === color.id}
onclick={() => {
app.setGroupColor(group.id, color.id);
coloringGroup = null;
}}
></button>
{/each}
</div>
{/if}
{#if !group.collapsed}
{#each inside as project (project.id)}
{@render projectRow(project, true)}
@@ -852,6 +888,72 @@
margin-left: 0;
}
/* The group's own mark, in front of the chevron so the colours of the headings
line up with each other rather than with the projects underneath. */
.group-dot {
margin-left: var(--s2);
padding: 0;
box-shadow: 0 0 0 2px var(--surface-sunken);
transition: box-shadow var(--fast) var(--ease);
}
.group-dot:hover,
.group-dot:focus-visible {
box-shadow:
0 0 0 2px var(--surface-sunken),
0 0 0 3px var(--line-strong);
outline: none;
}
/* Projects inside a group are indented to the group's name, not to its dot:
the eye follows the text, and lining up under the dot leaves the names
looking a step short of where the heading starts. */
.folder > a {
padding-left: calc(var(--s2) + var(--s4));
}
/* The line is indented with them, so a drop between two projects in a group
reads as landing in the group rather than across the whole sidebar. */
.folder > a.sortable::before {
left: calc(var(--s2) + var(--s4));
}
.folder > .empty {
padding-left: calc(var(--s2) + var(--s4));
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
padding: var(--s2) var(--s2) var(--s3);
}
.swatch {
width: 16px;
height: 16px;
border-radius: var(--radius-full);
flex: none;
/* A ring in the ground colour, so the chosen one reads as selected without
the swatch changing size and shuffling the row as you move along it. */
box-shadow: 0 0 0 2px var(--surface-sunken);
transition: box-shadow var(--fast) var(--ease);
}
.swatch:hover,
.swatch:focus-visible {
box-shadow:
0 0 0 2px var(--surface-sunken),
0 0 0 3px var(--line-strong);
outline: none;
}
.swatch.chosen {
box-shadow:
0 0 0 2px var(--surface-sunken),
0 0 0 3px var(--ink);
}
.folder-head form {
flex: 1;
min-width: 0;
+4
View File
@@ -50,6 +50,10 @@
async function onDrop(event, target) {
event.preventDefault();
// The section around this list also takes drops, for the coarse "into here"
// gesture and for sections with no rows to aim at. A drop that landed on a
// row has already said exactly where it goes, so it stops here.
event.stopPropagation();
const id = draggingId;
const below = overBelow;
draggingId = null;
+33 -1
View File
@@ -48,6 +48,8 @@
};
}
// Only when it is somebody else's: see app.assigneeOf.
let assignee = $derived(app.assigneeOf(task));
let due = $derived(dueLabel(task.due_date));
let time = $derived(
task.due_datetime
@@ -81,8 +83,19 @@
<span class="description">{task.description}</span>
{/if}
{#if due || task.labels?.length || task.recurrence_text}
{#if assignee || due || task.labels?.length || task.recurrence_text}
<span class="meta">
{#if assignee}
<!-- First in the row, because "who" changes what the rest of the line
means: a date on somebody else's task is their deadline, not
yours. -->
<span class="assignee" title="Ansvarlig: {assignee.name}">
<span class="face" style="background: {assignee.avatar_color}">
{assignee.name[0]?.toUpperCase() ?? '?'}
</span>
{assignee.name}
</span>
{/if}
{#if due}
<span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if}
@@ -259,6 +272,25 @@
stroke-linejoin: round;
}
.assignee {
display: inline-flex;
align-items: center;
gap: var(--s1);
color: var(--ink-muted);
}
.face {
width: 14px;
height: 14px;
border-radius: var(--radius-full);
display: inline-grid;
place-items: center;
font-size: 9px;
font-weight: 560;
color: #fff;
flex: none;
}
.label {
color: var(--ink-muted);
background: var(--surface-raised);
+47 -2
View File
@@ -18,6 +18,14 @@ class AppState {
projects = $state([]);
/** The foldable headings over the projects in the sidebar. */
groups = $state([]);
/**
* Everybody you share a project with, and yourself.
*
* Loaded once so a task row can put a name on an `assignee_id` without a
* request per row — and without every list having to know which project each
* task belongs to in order to ask for its members.
*/
people = $state([]);
tasks = $state([]);
loading = $state(true);
/** Transient messages: a failed save, a rolled-back change. */
@@ -52,6 +60,23 @@ class AppState {
return this.projects.find((p) => p.id === id);
}
personById(id) {
return this.people.find((p) => p.id === id) ?? null;
}
/**
* The person a task is waiting on, or null when it is nobody or yourself.
*
* Yourself is nobody, on purpose: every row in your own list would otherwise
* carry your own face, which is a column of the same initial telling you
* nothing. The point of showing an assignee is that it is *not* you.
*/
assigneeOf(task) {
const id = task?.assignee_id;
if (!id || id === this.user?.id) return null;
return this.personById(id) ?? { id, name: 'Ukendt', avatar_color: '#8a8f98' };
}
get detailTask() {
return this.tasks.find((t) => t.id === this.detailId) ?? null;
}
@@ -68,12 +93,14 @@ class AppState {
this.loading = true;
try {
this.user = await api.me();
const [{ projects }, { groups }] = await Promise.all([
const [{ projects }, { groups }, { people }] = await Promise.all([
api.listProjects(),
api.listProjectGroups()
api.listProjectGroups(),
api.people()
]);
this.projects = projects;
this.groups = groups;
this.people = people;
this.connect();
} catch (e) {
this.user = null;
@@ -410,6 +437,10 @@ class AppState {
await this.#patchGroup(id, { name });
}
async setGroupColor(id, color) {
await this.#patchGroup(id, { color });
}
async #patchGroup(id, patch) {
const previous = this.groups.find((g) => g.id === id);
if (!previous) return;
@@ -459,6 +490,20 @@ class AppState {
}
}
/** Marks a project with one of the palette's colours. */
async setProjectColor(projectId, color) {
const previous = this.projects.find((p) => p.id === projectId);
if (!previous || previous.color === color) return;
this.upsertProject({ ...previous, color });
try {
this.upsertProject(await api.updateProject(projectId, { color }));
} catch (e) {
this.upsertProject(previous);
this.toast(humanMessage(e));
}
}
/** Files a project under a group, or takes it out of one when groupId is ''. */
async setProjectGroup(projectId, groupId) {
const previous = this.projects.find((p) => p.id === projectId);
+9 -3
View File
@@ -11,17 +11,23 @@
* is six places for them to drift apart.
*/
import { page } from '$app/stores';
import { app } from '$lib/stores.svelte.js';
let { children } = $props();
const SECTIONS = [
// Brugere is only shown to administrators — the API refuses everybody else, and
// a tab that answers 403 is a promise the interface cannot keep. Hidden rather
// than disabled: this is not a feature to upsell, it is one that does not
// apply to you.
let sections = $derived([
{ href: '/indstillinger', label: 'Konto' },
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
{ href: '/indstillinger/ai', label: 'AI' },
{ href: '/indstillinger/tokens', label: 'API-tokens' },
...(app.user?.is_admin ? [{ href: '/indstillinger/brugere', label: 'Brugere' }] : []),
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
];
]);
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
</script>
@@ -32,7 +38,7 @@
</header>
<nav aria-label="Indstillinger">
{#each SECTIONS as section (section.href)}
{#each sections as section (section.href)}
<a href={section.href} class:active={current === section.href}>{section.label}</a>
{/each}
</nav>
@@ -0,0 +1,316 @@
<script>
/**
* Brugere: who is on this instance, and how somebody else gets on it.
*
* There is no open registration and no account created with a password
* somebody else chose. Adding a person means issuing an invite with no project
* attached; the link ends with a password only its owner has seen. So this page
* has two lists — the accounts, and the invites that have gone out and not been
* used yet.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let users = $state([]);
let invites = $state([]);
let email = $state('');
let link = $state('');
let errors = $state({});
let busy = $state(false);
$effect(() => {
load();
});
async function load() {
try {
const r = await api.listUsers();
users = r.users;
invites = r.invites;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function invite(event) {
event.preventDefault();
if (busy) return;
busy = true;
errors = {};
link = '';
try {
const result = await api.inviteUser(email);
email = '';
// Shown whether or not it was emailed. With no mail server this is the
// only way it reaches anybody, and with one it saves waiting on
// delivery to find out whether it worked.
link = result.link;
await load();
} catch (e) {
errors = e.fields ?? {};
if (!Object.keys(errors).length) app.toast(humanMessage(e));
} finally {
busy = false;
}
}
async function setAdmin(user, isAdmin) {
const previous = users;
users = users.map((u) => (u.id === user.id ? { ...u, is_admin: isAdmin } : u));
try {
await api.setUserAdmin(user.id, isAdmin);
} catch (e) {
users = previous;
app.toast(humanMessage(e));
}
}
/**
* Deleting an account is the only thing in this app that cannot be undone.
*
* Everything else is soft-deleted with a trash behind it. This is not, and it
* takes more than the person's own work: `tasks.created_by` cascades, so what
* they wrote in projects somebody else owns goes too. The confirmation says the
* numbers out loud, which is why the list carries them.
*/
async function remove(user) {
const parts = [`Slet ${user.name} <${user.email}> for altid?`, ''];
if (user.project_count || user.task_count) {
parts.push(
`Det sletter ${count(user.project_count, 'projekt', 'projekter')} og ` +
`${count(user.task_count, 'opgave', 'opgaver')} — også opgaver, personen har ` +
`skrevet i projekter, andre ejer.`,
''
);
}
parts.push('Det kan ikke fortrydes. Der er ingen papirkurv for konti.');
if (!confirm(parts.join('\n'))) return;
const previous = users;
users = users.filter((u) => u.id !== user.id);
try {
await api.deleteUser(user.id);
} catch (e) {
users = previous;
app.toast(humanMessage(e));
}
}
async function revoke(pending) {
if (!confirm(`Træk invitationen til ${pending.email} tilbage? Linket holder op med at virke.`))
return;
const previous = invites;
invites = invites.filter((i) => i.id !== pending.id);
try {
await api.revokeInvite(pending.id);
} catch (e) {
invites = previous;
app.toast(humanMessage(e));
}
}
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
function ago(iso) {
if (!iso) return 'aldrig logget ind';
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' });
}
const expires = (iso) =>
new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
</script>
<section class="panel">
<header>
<h2>Inviter en bruger</h2>
<p class="hint">
Der er ingen åben registrering. Du sender et link, og personen vælger selv navn
og adgangskode — så er der ikke en adgangskode, to mennesker kender.
</p>
</header>
<form onsubmit={invite}>
<div class="field">
<label for="ny-email">E-mailadresse</label>
<input
id="ny-email"
type="email"
bind:value={email}
placeholder="[email protected]"
required
aria-invalid={errors.email ? 'true' : undefined}
/>
{#if errors.email}<p class="error">Skal være en e-mailadresse.</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={busy}>Send invitation</button>
</div>
</form>
{#if link}
<div class="field">
<p class="hint">Invitationen er oprettet. Send linket til personen:</p>
<code class="link-out">{link}</code>
</div>
{/if}
</section>
{#if invites.length}
<section class="panel">
<header>
<h2>Afventer svar</h2>
<p class="hint">
Invitationer, der er sendt og ikke brugt endnu. Kun linkets hash er gemt, så
det kan ikke vises igen — er det gået til den forkerte, så træk det tilbage.
</p>
</header>
<ul class="rows">
{#each invites as pending (pending.id)}
<li>
<div class="what">
<span class="primary-line">{pending.email}</span>
<span class="secondary">
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
· udløber {expires(pending.expires_at)}
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
</span>
</div>
<button class="secondary" onclick={() => revoke(pending)}>Træk tilbage</button>
</li>
{/each}
</ul>
</section>
{/if}
<section class="panel">
<header>
<h2>Konti</h2>
<p class="hint">Alle på denne instans.</p>
</header>
<ul class="rows">
{#each users as user (user.id)}
<li>
<span class="avatar" style="background: {user.avatar_color}">
{user.name[0]?.toUpperCase() ?? '?'}
</span>
<div class="what">
<span class="primary-line">
{user.name}
{#if user.is_admin}<span class="badge">administrator</span>{/if}
{#if user.self}<span class="badge quiet">dig</span>{/if}
</span>
<span class="secondary">
{user.email} · {ago(user.last_seen_at)}
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
</span>
</div>
<!-- Nobody demotes or deletes themselves from here. The server refuses
both, and a button that exists only to be refused is a button that
wastes a click to teach you a rule. -->
{#if !user.self}
<button class="secondary" onclick={() => setAdmin(user, !user.is_admin)}>
{user.is_admin ? 'Fjern administrator' : 'Gør til administrator'}
</button>
<button class="danger" onclick={() => remove(user)}>Slet</button>
{/if}
</li>
{/each}
</ul>
</section>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.rows li {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3) 0;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.rows li:last-child {
border-bottom: 0;
}
.avatar {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 560;
color: #fff;
flex: none;
}
.what {
flex: 1;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 2px;
}
.primary-line {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
flex-wrap: wrap;
}
.secondary {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.badge {
font-size: var(--text-xs);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius-full);
padding: 0 var(--s2);
}
.badge.quiet {
color: var(--ink-faint);
border-color: var(--line-strong);
}
.link-out {
display: block;
margin-top: var(--s1);
padding: var(--s2);
background: var(--surface-sunken);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow-wrap: anywhere;
color: var(--ink);
}
</style>
+423 -19
View File
@@ -4,15 +4,30 @@
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
import TaskList from '$lib/components/TaskList.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
let project = $state(null);
/**
* loading | ready | denied | failed.
*
* Four states rather than "is project null", which is what this was: the page
* showed "Projektet findes ikke, eller du har ikke adgang til det" before the
* first request had even returned, and again if it failed for any reason at
* all. A slow load, a dropped connection, a server being restarted underneath
* you and a project you genuinely cannot see were one message — and it never
* asked again, so the wrong one stayed until somebody reloaded.
*/
let status = $state('loading');
let sections = $state([]);
let members = $state([]);
let showShare = $state(false);
let showLog = $state(false);
let activity = $state([]);
let inviteEmail = $state('');
let inviteRole = $state('editor');
let inviteLink = $state('');
@@ -21,29 +36,94 @@
let id = $derived($page.params.id);
$effect(() => {
const projectId = id;
if (!projectId) return;
Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.loadTasks({ project_id: projectId })
])
.then(([p, s]) => {
project = p;
sections = s.sections;
})
.catch(() => {
project = null;
});
load(id);
});
async function load(projectId) {
if (!projectId) return;
status = 'loading';
try {
const [p, s] = await Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.loadTasks({ project_id: projectId })
]);
project = p;
sections = s.sections;
status = 'ready';
} catch (e) {
project = null;
// 404 is the app's answer for both "no such project" and "not yours" —
// deliberately, so probing ids teaches nothing. Anything else is the
// request itself having failed, which is worth saying differently and
// worth offering to try again.
status = e?.status === 404 ? 'denied' : 'failed';
}
}
$effect(() => {
if (showShare && project) {
api.listMembers(project.id).then((r) => (members = r.members));
}
});
// Loaded when it is opened rather than with the page: it is a record you go
// looking for, not something every visit should pay for.
$effect(() => {
if (showLog && project) {
api.activity(project.id).then((r) => (activity = r.activity)).catch(() => {});
}
});
/**
* What each recorded event is called.
*
* The log has been written since the beginning — nineteen kinds of entry — and
* nothing has ever read it. The keys are the server's and they are stable; this
* is the only place they become Danish.
*
* An unknown key falls through to the key itself rather than being hidden: a
* build that records something this table has not learned yet should still show
* that it happened.
*/
const EVENTS = {
'project.created': 'oprettede projektet',
'project.updated': 'ændrede projektet',
'project.deleted': 'slettede projektet',
'project.imported': 'importerede projektet',
'section.created': 'oprettede en sektion',
'section.updated': 'ændrede en sektion',
'section.deleted': 'slettede en sektion',
'member.invited': 'inviterede',
'member.added': 'tilføjede',
'member.removed': 'fjernede',
'member.role_changed': 'ændrede rollen for',
'task.created': 'oprettede en opgave',
'task.updated': 'ændrede en opgave',
'task.completed': 'lukkede en opgave',
'task.reopened': 'genåbnede en opgave',
'task.moved': 'flyttede en opgave',
'task.deleted': 'slettede en opgave',
'task.split': 'delte en opgave op',
'comment.created': 'skrev en kommentar'
};
/** The bit of context an entry carries, when it has one worth reading. */
function detail(entry) {
const p = entry.payload ?? {};
return p.name ?? p.email ?? p.role ?? '';
}
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
}
// The chosen view is kept locally as well as on the project, so switching is
// instant and only persists once the request lands.
let view = $state(null);
@@ -73,6 +153,18 @@
let unsectioned = $derived(open.filter((t) => !t.section_id));
let editing = $state(false);
let choosingColor = $state(false);
/**
* The colour is written to the store as well as here: the sidebar reads its own
* copy of the project list, and a dot that only changed on this page would be
* the same colour in the two places you look at it.
*/
async function setColor(color) {
choosingColor = false;
project = { ...project, color };
await app.setProjectColor(project.id, color);
}
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
async function rename(input) {
@@ -107,6 +199,56 @@
}
}
// --- dropping a task into a section ---------------------------------------------
/**
* The section under the pointer, '' for the unsectioned block at the top.
*
* Only the task *rows* used to accept a drop, so a section with nothing in it
* had nothing to aim at and could not be filled by dragging at all — and a
* section you have just made is always empty. The board has always taken a drop
* on the column; this is the same idea in the list.
*/
let overSection = $state(null);
function onSectionDragOver(event, sectionId) {
if (!canEdit || !carries(event, TASK)) return;
accept(event);
overSection = sectionId;
}
async function onSectionDrop(event, sectionId) {
event.preventDefault();
const id = dragged(event, TASK);
overSection = null;
if (!id || !canEdit) return;
const task = app.get(id);
if (!task || (task.section_id ?? '') === sectionId) return;
// At the end of the section: dropping on the area rather than between two
// rows is the coarse gesture, and landing at the top would push whatever is
// there down.
const existing = open.filter((t) => (t.section_id ?? '') === sectionId && t.id !== id);
const after = [...existing].sort((a, b) => a.sort_order - b.sort_order).at(-1);
const previous = { ...task };
app.replace(id, { ...task, section_id: sectionId });
try {
app.replace(
id,
await api.moveTask(id, {
project_id: project.id,
section_id: sectionId,
after_id: after?.id ?? ''
})
);
} catch (e) {
app.replace(id, previous);
app.toast(humanMessage(e));
}
}
// --- sections -------------------------------------------------------------------
let addingSection = $state(false);
@@ -162,6 +304,33 @@
}
}
/**
* What each standing is called in the interface.
*
* The API's words are `owner`, `editor` and `viewer`, and they were being
* printed straight into a Danish page. These are the same three words the
* invite form already uses, so the list and the form agree.
*/
const ROLES = { owner: 'Ejer', editor: 'Kan redigere', viewer: 'Kan kun se' };
/**
* Changes what somebody may do, without removing them.
*
* Removing and re-inviting was the only way to correct a role, and it
* unassigns every task they were responsible for on the way past — so fixing a
* dropdown cost somebody their work.
*/
async function setMemberRole(member, role) {
const previous = members;
members = members.map((m) => (m.user_id === member.user_id ? { ...m, role } : m));
try {
await api.setMemberRole(project.id, member.user_id, role);
} catch (e) {
members = previous;
app.toast(humanMessage(e));
}
}
async function invite(event) {
event.preventDefault();
inviteError = '';
@@ -216,6 +385,17 @@
{/if}
</h1>
{/if}
{#if isOwner && !project.is_inbox}
<button
class="color"
style="background: {colorVar(project.color)}"
onclick={() => (choosingColor = !choosingColor)}
aria-expanded={choosingColor}
aria-label="Farve på projektet"
title="Farve på projektet"
></button>
{/if}
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
<button
@@ -232,6 +412,12 @@
</button>
{/if}
{#if !project.is_inbox}
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
Historik
</button>
{/if}
{#if isOwner && !project.is_inbox}
<button class="remove" onclick={remove} aria-label="Slet projektet">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -241,6 +427,42 @@
{/if}
</header>
{#if showLog}
<div class="panel">
<ul class="log">
{#each activity as entry (entry.id)}
<li>
<span class="who">{entry.user_name}</span>
<span class="what">
{EVENTS[entry.event] ?? entry.event}{#if detail(entry)}
<span class="detail">{detail(entry)}</span>{/if}
</span>
<span class="when">{when(entry.created_at)}</span>
</li>
{/each}
</ul>
{#if !activity.length}
<p class="empty">Ingenting er sket her endnu.</p>
{/if}
</div>
{/if}
{#if choosingColor}
<div class="panel swatches" role="group" aria-label="Vælg farve">
{#each COLORS as color (color.id)}
<button
class="swatch"
class:chosen={(project.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={color.name}
aria-label={color.name}
aria-pressed={(project.color ?? 'graphite') === color.id}
onclick={() => setColor(color.id)}
></button>
{/each}
</div>
{/if}
{#if showShare}
<div class="panel">
<ul class="members">
@@ -250,7 +472,22 @@
{member.name[0]?.toUpperCase()}
</span>
<span class="member-name">{member.name}</span>
<span class="role">{member.role}</span>
<!-- The owner's standing is not a choice: ownership is transferred,
not granted, and the server refuses it. A disabled dropdown
would be a control that exists to say no. -->
{#if isOwner && member.role !== 'owner'}
<select
class="role-picker"
value={member.role}
aria-label="Rolle for {member.name}"
onchange={(e) => setMemberRole(member, e.currentTarget.value)}
>
<option value="editor">{ROLES.editor}</option>
<option value="viewer">{ROLES.viewer}</option>
</select>
{:else}
<span class="role">{ROLES[member.role] ?? member.role}</span>
{/if}
{#if isOwner && member.role !== 'owner'}
<button
class="remove"
@@ -303,13 +540,28 @@
{:else if mode === 'calendar'}
<CalendarView projectId={project.id} />
{:else}
<section>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class:over={overSection === ''}
ondragover={(e) => onSectionDragOver(e, '')}
ondragleave={() => (overSection = null)}
ondrop={(e) => onSectionDrop(e, '')}
>
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
{#if !unsectioned.length}
<p class="empty">Uden sektion</p>
{/if}
</section>
{#each sections as section (section.id)}
{@const tasks = open.filter((t) => t.section_id === section.id)}
<section>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class:over={overSection === section.id}
ondragover={(e) => onSectionDragOver(e, section.id)}
ondragleave={() => (overSection = null)}
ondrop={(e) => onSectionDrop(e, section.id)}
>
<div class="section-head">
{#if renamingSection === section.id}
<form onsubmit={(e) => renameSection(e, section)}>
@@ -384,8 +636,18 @@
{#if project.role === 'viewer'}
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
{/if}
{:else}
{:else if status === 'loading'}
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
<div class="booting"></div>
{:else if status === 'denied'}
<p class="clear">Projektet findes ikke, eller du har ikke adgang til det.</p>
{:else}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Kunne ikke hente projektet. Serveren svarede ikke.
<button class="retry" onclick={() => load(id)}>Prøv igen</button>
</p>
{/if}
</div>
@@ -482,6 +744,56 @@
stroke-linejoin: round;
}
/* A circle in the project's own colour, which is both the control and its own
preview: the thing you are about to change is what you press. */
.color {
flex: none;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
box-shadow: 0 0 0 2px var(--ground);
transition: box-shadow var(--fast) var(--ease);
}
.color:hover,
.color:focus-visible {
box-shadow:
0 0 0 2px var(--ground),
0 0 0 3px var(--line-strong);
outline: none;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: var(--s3);
}
.swatch {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
flex: none;
/* A ring in the panel's own colour, so the selected swatch reads as chosen
without changing size and shuffling the row as you move along it. */
box-shadow: 0 0 0 2px var(--surface);
transition: box-shadow var(--fast) var(--ease);
}
.swatch:hover,
.swatch:focus-visible {
box-shadow:
0 0 0 2px var(--surface),
0 0 0 3px var(--line-strong);
outline: none;
}
.swatch.chosen {
box-shadow:
0 0 0 2px var(--surface),
0 0 0 3px var(--ink);
}
.views {
display: flex;
gap: 1px;
@@ -579,6 +891,24 @@
font-size: var(--text-xs);
}
.role-picker {
flex: none;
padding: var(--s1) var(--s2);
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-muted);
font-size: var(--text-xs);
outline: none;
transition: border-color var(--fast) var(--ease);
}
.role-picker:hover,
.role-picker:focus {
border-color: var(--accent);
color: var(--ink);
}
.remove {
color: var(--ink-faint);
width: 20px;
@@ -626,6 +956,52 @@
font-weight: 550;
}
.log {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
/* Capped and scrolled: a busy project's log is thousands of lines, and a
panel that pushes the tasks off the screen is a panel you close again. */
max-height: 320px;
overflow-y: auto;
}
.log li {
display: flex;
align-items: baseline;
gap: var(--s2);
padding: var(--s2) 0;
border-bottom: 1px solid var(--line);
font-size: var(--text-sm);
}
.log li:last-child {
border-bottom: 0;
}
.who {
font-weight: 500;
flex: none;
}
.what {
color: var(--ink-muted);
flex: 1;
min-width: 0;
}
.detail {
color: var(--ink);
}
.when {
color: var(--ink-faint);
font-size: var(--text-xs);
flex: none;
}
.link-out {
margin: 0;
font-size: var(--text-xs);
@@ -651,6 +1027,14 @@
section {
margin-top: var(--s5);
border-radius: var(--radius);
}
/* The whole section lights up, because the whole section is the target. A line
between two rows means "here exactly"; this means "in this one". */
section.over {
box-shadow: 0 0 0 1px var(--accent);
background: var(--surface-sunken);
}
h2 {
@@ -765,4 +1149,24 @@
color: var(--accent);
opacity: 0.5;
}
.booting {
min-height: 50vh;
}
.retry {
padding: var(--s2) var(--s4);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink-muted);
transition:
border-color var(--fast) var(--ease),
color var(--fast) var(--ease);
}
.retry:hover {
border-color: var(--accent);
color: var(--ink);
}
</style>