Compare commits
@@ -45,3 +45,8 @@ node_modules/
|
||||
# the process is killed, which during a hung build is exactly what happens. One
|
||||
# was committed by accident.
|
||||
/web/vite.config.js.timestamp-*.mjs
|
||||
|
||||
# A compiled `go build ./tools/shots` in the repo root. It was committed once by
|
||||
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
|
||||
# it right back.
|
||||
/shots
|
||||
|
||||
@@ -34,9 +34,13 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
||||
@regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
|
||||
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||
views.
|
||||
- **Sharing.** Internal users are created by an admin; anyone else joins through an
|
||||
invite link sent to their email. Owner, editor and viewer roles.
|
||||
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.** 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
|
||||
@@ -60,12 +64,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Project roles and permission checks | Done, tested |
|
||||
| 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 |
|
||||
| Server errors kept in the database, readable after a restart | Done, tested |
|
||||
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
|
||||
| Invite and password-reset links that open the right page | Done, smoke-tested |
|
||||
| Drag to reorder, in both the list and the board | Done |
|
||||
| Drag a task to another project or another day | Done, smoke-tested |
|
||||
| Foldable project groups in the sidebar | Done, tested |
|
||||
| Month view on Upcoming, not only per project | Done, smoke-tested |
|
||||
| Recurring tasks (RRULE, Danish + English) | Done, tested |
|
||||
| Reminders, absolute and relative | Done, tested |
|
||||
| Saved filters with a query language | Done, tested |
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
board and calendar views.
|
||||
board and calendar 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** with owner, editor and viewer roles. Anyone outside your instance
|
||||
joins through an invite link.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
|
||||
+526
-2
@@ -351,6 +351,44 @@ paths:
|
||||
"204": { description: Off. }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/auth/sessions:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Where you are signed in.
|
||||
description: |
|
||||
Most recently used first. `id` is the stored hash of the cookie and never
|
||||
the cookie itself — it cannot be pasted into a browser, and it is what the
|
||||
delete below takes.
|
||||
|
||||
Session only: an API token cannot list the devices of the person it was
|
||||
stolen from.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
sessions:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Session" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/auth/sessions/{sessionID}:
|
||||
parameters:
|
||||
- { name: sessionID, in: path, required: true, schema: { type: string } }
|
||||
delete:
|
||||
tags: [auth]
|
||||
summary: Sign one device out.
|
||||
description: |
|
||||
Ending the current session is allowed and is simply logging out —
|
||||
somebody clearing every device wants this one gone too.
|
||||
responses:
|
||||
"204": { description: Ended. }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/auth/recovery-codes:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -818,6 +856,277 @@ paths:
|
||||
"204": { description: Deleted. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/errors:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: What has gone wrong lately. Administrators only.
|
||||
description: |
|
||||
Every 500 the API has answered, newest first, with what the handler was
|
||||
attempting and the error it hit.
|
||||
|
||||
These are written to the database rather than only to the log because the
|
||||
log does not survive. On a Rune the log belongs to the container, and the
|
||||
container is replaced on every restart — so a panel watcher reports "HTTP
|
||||
5xx, twice, at 11:49" long after the line explaining it has gone, which
|
||||
tells an operator that something broke and gives them no way to find out
|
||||
what.
|
||||
|
||||
Kept for thirty days by the nightly sweep: long enough to answer "what
|
||||
happened last week", short enough that a fault loop cannot fill the volume
|
||||
the tasks live on.
|
||||
parameters:
|
||||
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
at: { type: string, format: date-time }
|
||||
method: { type: string }
|
||||
path: { type: string }
|
||||
status: { type: integer }
|
||||
what:
|
||||
type: string
|
||||
description: What the handler was attempting, in its own words.
|
||||
example: list projects
|
||||
message: { type: string }
|
||||
user_name:
|
||||
type: string
|
||||
description: |
|
||||
Who was asking, when there was somebody. A fault only
|
||||
one account hits is a different problem from one
|
||||
everybody hits.
|
||||
request_id:
|
||||
type: string
|
||||
description: Ties the row back to the log line, for whoever still has it.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/users:
|
||||
get:
|
||||
tags: [auth]
|
||||
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]
|
||||
summary: Your foldable sidebar groups.
|
||||
description: |
|
||||
A group is a heading over some of your projects. It is yours rather than
|
||||
the project's: it has no members and no roles, and a project shared with
|
||||
you can be filed under one of your groups without the owner seeing it.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
groups:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/ProjectGroup" }
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Make a group.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
|
||||
responses:
|
||||
"201": { $ref: "#/components/responses/ProjectGroup" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/project-groups/reorder:
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Set the order groups appear in.
|
||||
description: The whole list at once, for the same reasons as `/projects/reorder`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items: { type: string }
|
||||
description: Group ids, top to bottom.
|
||||
responses:
|
||||
"204": { description: Reordered. }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/project-groups/{groupID}:
|
||||
parameters:
|
||||
- { name: groupID, in: path, required: true, schema: { type: string } }
|
||||
patch:
|
||||
tags: [projects]
|
||||
summary: Rename a group, or fold it.
|
||||
description: |
|
||||
`collapsed` is stored on the account rather than in the browser. The
|
||||
sidebar's width is a property of the screen you are at; a folded group is
|
||||
a statement about the group, and is true wherever you sign in.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/ProjectGroup" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
delete:
|
||||
tags: [projects]
|
||||
summary: Delete a group.
|
||||
description: |
|
||||
The heading goes; the projects under it come back out as ungrouped. A
|
||||
group holds no work of its own, so this is not soft-deleted — there is
|
||||
nothing in it to recover.
|
||||
responses:
|
||||
"204": { description: Deleted. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/trash/projects:
|
||||
get:
|
||||
tags: [projects]
|
||||
@@ -956,9 +1265,46 @@ paths:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
- { name: userID, in: path, required: true, schema: { type: string } }
|
||||
patch:
|
||||
tags: [projects]
|
||||
summary: Change what an existing member may do. The project's owner only.
|
||||
description: |
|
||||
Editor or viewer. An UPDATE against a membership that already exists —
|
||||
somebody who is not a member is not silently made one, because that is an
|
||||
invite and belongs to the invite flow.
|
||||
|
||||
Not the owner: ownership is transferred, not granted, and two owners is a
|
||||
state with no way to settle a disagreement about who may remove whom. The
|
||||
owner also has no membership row at all — ownership lives on the project —
|
||||
so this answers 409 with the real reason rather than "not a member".
|
||||
|
||||
Correcting a role used to mean removing the person and inviting them
|
||||
again, which also unassigns every task they were responsible for.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [role]
|
||||
properties:
|
||||
role: { type: string, enum: [editor, viewer] }
|
||||
responses:
|
||||
"204": { description: Changed. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409":
|
||||
description: The owner's standing cannot be changed.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
delete:
|
||||
tags: [projects]
|
||||
summary: Remove somebody from a project. Its owner only.
|
||||
description: |
|
||||
Their tasks are unassigned rather than left pointing at somebody who can
|
||||
no longer see the project — which is why changing a role is a PATCH and
|
||||
not a remove-and-reinvite.
|
||||
responses:
|
||||
"204": { description: Removed. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
@@ -1060,6 +1406,70 @@ 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]
|
||||
summary: What you are waiting on other people for.
|
||||
description: |
|
||||
Everything assigned to somebody who is not you, in the projects you can
|
||||
see, grouped by the person it is assigned to.
|
||||
|
||||
Grouped rather than flat because the question is about people, and a
|
||||
client grouping it itself would need every assignee's name — which it has
|
||||
no way to ask for. The names come back with the tasks, so one request is
|
||||
one screen.
|
||||
parameters:
|
||||
- { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
people:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
name: { type: string }
|
||||
avatar_color: { type: string }
|
||||
tasks:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Task" }
|
||||
|
||||
/api/v1/search:
|
||||
get:
|
||||
tags: [views]
|
||||
@@ -2007,6 +2417,11 @@ components:
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Project" }
|
||||
ProjectGroup:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectGroup" }
|
||||
FeedURL:
|
||||
description: OK
|
||||
content:
|
||||
@@ -2110,6 +2525,69 @@ 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:
|
||||
id: { type: string }
|
||||
current:
|
||||
type: boolean
|
||||
description: The one making this request.
|
||||
device:
|
||||
type: string
|
||||
description: |
|
||||
A summary of the user agent — "Chrome på macOS". A handful of
|
||||
substring tests rather than a parsing library: the question it
|
||||
answers is "is one of these me?", and the raw string is sent beside
|
||||
it so anything the summary gets wrong is still visible.
|
||||
example: Chrome på macOS
|
||||
user_agent: { type: string }
|
||||
ip: { type: string }
|
||||
created_at: { type: string, format: date-time }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
expires_at: { type: string, format: date-time }
|
||||
|
||||
Task:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2192,7 +2670,7 @@ components:
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
color: { type: string }
|
||||
color: { $ref: "#/components/schemas/Color" }
|
||||
icon: { type: string }
|
||||
view_mode: { type: string, enum: [list, board, calendar] }
|
||||
owner_id: { type: string }
|
||||
@@ -2202,16 +2680,62 @@ components:
|
||||
role: { type: string, enum: [owner, editor, viewer] }
|
||||
member_count: { type: integer }
|
||||
shared: { type: boolean }
|
||||
group_id:
|
||||
type: string
|
||||
description: |
|
||||
The sidebar group *you* have filed it under, absent for none. Always
|
||||
absent on a project shared with you: grouping is a column on the
|
||||
project row, so it is the owner's filing rather than yours.
|
||||
|
||||
ProjectInput:
|
||||
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 }
|
||||
sort_order: { type: number }
|
||||
group_id:
|
||||
type: string
|
||||
description: |
|
||||
Files the project under one of your groups. The empty string takes it
|
||||
out of one — `null` cannot mean that, because absent and null are the
|
||||
same thing once decoded, and telling "put it nowhere" apart from "I
|
||||
did not mention it" is what makes this a PATCH.
|
||||
|
||||
ProjectGroup:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
color: { $ref: "#/components/schemas/Color" }
|
||||
collapsed: { type: boolean }
|
||||
sort_order: { type: number }
|
||||
|
||||
ProjectGroupInput:
|
||||
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
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
@@ -88,7 +90,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
@@ -130,7 +132,7 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, "promote session", err)
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
// The cookie is reissued so its lifetime matches the session's new one.
|
||||
@@ -210,13 +212,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
|
||||
s.storeError(w, "update profile", err)
|
||||
s.storeError(w, r, "update profile", err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.db.UserByID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "reload user", err)
|
||||
s.internal(w, r, "reload user", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
||||
@@ -263,7 +265,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,14 +279,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
"an account already exists for that email address")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create user", err)
|
||||
s.internal(w, r, "create user", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Accepting the invite is what adds the membership and burns the token, in one
|
||||
// transaction: a signup that half-succeeded would leave a usable invite behind.
|
||||
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
|
||||
s.internal(w, "accept invite", err)
|
||||
s.internal(w, r, "accept invite", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -328,7 +330,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -341,7 +343,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create first admin", err)
|
||||
s.internal(w, r, "create first admin", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,7 +357,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.UserCount(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "count users", err)
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
@@ -364,7 +366,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
||||
@@ -393,7 +395,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create password reset", err)
|
||||
s.internal(w, r, "create password reset", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/reset?token=" + token
|
||||
@@ -431,13 +433,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
// Every session ends: a password reset is what somebody does when they think
|
||||
// another person has been in their account.
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
s.internal(w, r, "update password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -469,7 +471,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
keep := ""
|
||||
@@ -477,7 +479,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
keep = session.ID
|
||||
}
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
s.internal(w, r, "update password", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -520,7 +522,27 @@ func inboxName(locale string) string {
|
||||
// internal logs the real error and tells the client nothing about it. The detail
|
||||
// belongs in the log, where the operator can see it; in the response it would be a
|
||||
// description of the server's internals handed to whoever asked.
|
||||
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
|
||||
//
|
||||
// It is also written to the database. On a Rune the log is the container's, and
|
||||
// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at
|
||||
// 11:49" long after the line explaining it has gone, which tells an operator that
|
||||
// something broke and gives them no way to find out what. The row outlives the
|
||||
// container; Indstillinger → Fejl is where it is read.
|
||||
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
s.log.Error(what, "err", err)
|
||||
|
||||
userID := ""
|
||||
if u := userFrom(r.Context()); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
// Best effort, and deliberately not checked: this is the path that is already
|
||||
// reporting a failure, and a diagnostic that can fail the request would turn
|
||||
// one broken screen into two.
|
||||
s.db.RecordError(r.Context(), store.ServerError{
|
||||
Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError,
|
||||
What: what, Message: err.Error(), UserID: userID,
|
||||
RequestID: middleware.GetReqID(r.Context()),
|
||||
})
|
||||
|
||||
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
|
||||
}
|
||||
|
||||
@@ -761,3 +761,137 @@ func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
|
||||
t.Errorf("status %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// last_seen_at has been written on every request since sessions existed, for
|
||||
// exactly this list, and nothing read it. A session you cannot see is a session
|
||||
// you cannot end.
|
||||
func TestSessionsCanBeListedAndEnded(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// A second sign-in from "another device": same account, its own cookie jar.
|
||||
other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second login: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list sessions: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
sessions, _ := body["sessions"].([]any)
|
||||
if len(sessions) != 2 {
|
||||
t.Fatalf("want two sessions, got %v", sessions)
|
||||
}
|
||||
|
||||
// Exactly one is this one, or the interface cannot tell somebody which row is
|
||||
// the browser they are reading it in.
|
||||
current := ""
|
||||
for _, raw := range sessions {
|
||||
s := raw.(map[string]any)
|
||||
if s["current"] == true {
|
||||
if current != "" {
|
||||
t.Fatal("two sessions both claim to be the current one")
|
||||
}
|
||||
current = s["id"].(string)
|
||||
}
|
||||
if s["device"] == "" {
|
||||
t.Errorf("no device summary: %v", s)
|
||||
}
|
||||
if s["last_seen_at"] == nil {
|
||||
t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s)
|
||||
}
|
||||
}
|
||||
if current == "" {
|
||||
t.Fatal("none of the sessions is marked as the current one")
|
||||
}
|
||||
|
||||
// End the other one.
|
||||
var otherID string
|
||||
for _, raw := range sessions {
|
||||
if s := raw.(map[string]any); s["id"] != current {
|
||||
otherID = s["id"].(string)
|
||||
}
|
||||
}
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete session: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// That device is signed out, and this one is not.
|
||||
if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("the ended session still works: status %d", r.StatusCode)
|
||||
}
|
||||
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
|
||||
t.Errorf("ending another session logged this one out: status %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's session is not yours to end, even with its id in hand.
|
||||
func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
|
||||
mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string)
|
||||
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
// And it still works.
|
||||
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
|
||||
t.Errorf("somebody else ended my session: status %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An API token is accepted almost everywhere, and must not be accepted here: a
|
||||
// leaked token that could end its owner's sessions, or even list their devices,
|
||||
// turns a theft into a lockout.
|
||||
func TestSessionsAreNotReachableWithAToken(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, user.ID)
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", 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("status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||
cases := []struct {
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"},
|
||||
// An iPhone's user agent says Mac OS X too, so the order of the tests is
|
||||
// the whole correctness of this function.
|
||||
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"},
|
||||
// And Edge claims to be Chrome, which claims to be Safari.
|
||||
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"},
|
||||
{"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"},
|
||||
{"", "Ukendt enhed"},
|
||||
// Not a browser at all. Its own name beats a word that says the server
|
||||
// gave up: a CalDAV client is a real session.
|
||||
{"curl/8.4.0", "curl/8.4.0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := describeUserAgent(c.ua); got != c.want {
|
||||
t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav home", err)
|
||||
s.internal(w, r, "caldav home", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Depth") != "0" {
|
||||
tasks, err := s.caldavTasks(r, user, projectID)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav tasks", err)
|
||||
s.internal(w, r, "caldav tasks", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
@@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.caldavTasks(r, user, projectID)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav report", err)
|
||||
s.internal(w, r, "caldav report", err)
|
||||
return
|
||||
}
|
||||
byID := map[string]store.Task{}
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
update.DueDatetime = parsed.DueDatetime
|
||||
|
||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
|
||||
s.internal(w, "caldav update", err)
|
||||
s.internal(w, r, "caldav update", err)
|
||||
return
|
||||
}
|
||||
if parsed.Completed && !existing.Completed() {
|
||||
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
task.Content = "(uden titel)"
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
|
||||
s.internal(w, "caldav create", err)
|
||||
s.internal(w, r, "caldav create", err)
|
||||
return
|
||||
}
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||
s.internal(w, "caldav delete", err)
|
||||
s.internal(w, r, "caldav delete", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
comments, err := s.db.ListComments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list comments", err)
|
||||
s.internal(w, r, "list comments", err)
|
||||
return
|
||||
}
|
||||
out := make([]commentJSON, 0, len(comments))
|
||||
@@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list attachments", err)
|
||||
s.internal(w, r, "list attachments", err)
|
||||
return
|
||||
}
|
||||
direct := make([]attachmentJSON, 0, len(files))
|
||||
@@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
|
||||
if err != nil {
|
||||
s.internal(w, "create comment", err)
|
||||
s.internal(w, r, "create comment", err)
|
||||
return
|
||||
}
|
||||
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
|
||||
@@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
|
||||
if err != nil {
|
||||
s.storeError(w, "update comment", err)
|
||||
s.storeError(w, r, "update comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
taskID, err := s.db.CommentTask(r.Context(), commentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "comment task", err)
|
||||
s.storeError(w, r, "comment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
@@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
|
||||
s.storeError(w, "delete comment", err)
|
||||
s.storeError(w, r, "delete comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, "store upload", err)
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, "record attachment", err)
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
s.storeError(w, r, "attachment task", err)
|
||||
return
|
||||
}
|
||||
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
|
||||
@@ -316,7 +316,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
|
||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get attachment", err)
|
||||
s.storeError(w, r, "get attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
s.storeError(w, r, "attachment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
@@ -369,7 +369,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete attachment", err)
|
||||
s.storeError(w, r, "delete attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What has gone wrong lately, kept where a restart cannot take it.
|
||||
//
|
||||
// The panel's watcher reports "HTTP 5xx, twice, at 11:49" — which is enough to
|
||||
// know something broke and nothing at all to act on. The line that explains it is
|
||||
// in the container's log, and a Rune's container is replaced on every restart, so
|
||||
// by the time anybody looks the explanation is gone. These rows are the same
|
||||
// information written somewhere that survives.
|
||||
|
||||
type serverErrorJSON struct {
|
||||
ID string `json:"id"`
|
||||
At string `json:"at"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
// What the handler was doing, in its own words — "list projects", "move task".
|
||||
What string `json:"what"`
|
||||
Message string `json:"message"`
|
||||
// Who was asking, when there was somebody: a fault only one account hits is a
|
||||
// different problem from one everybody hits.
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
// RequestID ties the row back to the log line, for whoever still has it.
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListErrors(w http.ResponseWriter, r *http.Request) {
|
||||
errs, err := s.db.ListErrors(r.Context(), parseLimit(r.URL.Query().Get("limit"), 100, 500))
|
||||
if err != nil {
|
||||
s.internal(w, r, "list errors", err)
|
||||
return
|
||||
}
|
||||
out := make([]serverErrorJSON, 0, len(errs))
|
||||
for _, e := range errs {
|
||||
out = append(out, serverErrorJSON{
|
||||
ID: e.ID, At: e.At.Format(time.RFC3339), Method: e.Method, Path: e.Path,
|
||||
Status: e.Status, What: e.What, Message: e.Message,
|
||||
UserName: e.UserName, RequestID: e.RequestID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"errors": out})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed", err)
|
||||
s.internal(w, r, "ics feed", err)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed projects", err)
|
||||
s.internal(w, r, "ics feed projects", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
@@ -89,7 +89,7 @@ func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ics token", err)
|
||||
s.internal(w, r, "ics token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
@@ -103,11 +103,11 @@ func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, "generate feed token", err)
|
||||
s.internal(w, r, "generate feed token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
||||
s.internal(w, "set feed token", err)
|
||||
s.internal(w, r, "set feed token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
@@ -151,7 +151,7 @@ func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list reminders", err)
|
||||
s.internal(w, r, "list reminders", err)
|
||||
return
|
||||
}
|
||||
out := make([]reminderJSON, 0, len(reminders))
|
||||
@@ -197,7 +197,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
||||
if err != nil {
|
||||
s.internal(w, "create reminder", err)
|
||||
s.internal(w, r, "create reminder", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete reminder", err)
|
||||
s.storeError(w, r, "delete reminder", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
|
||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list templates", err)
|
||||
s.internal(w, r, "list templates", err)
|
||||
return
|
||||
}
|
||||
out := make([]templateJSON, 0, len(templates))
|
||||
@@ -273,7 +273,7 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
||||
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
||||
if err != nil {
|
||||
s.storeError(w, "save template", err)
|
||||
s.storeError(w, r, "save template", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
||||
@@ -295,7 +295,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get template", err)
|
||||
s.storeError(w, r, "get template", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
||||
if err != nil {
|
||||
s.internal(w, "create from template", err)
|
||||
s.internal(w, r, "create from template", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, project.ID, "", "project.created", map[string]any{
|
||||
@@ -323,7 +323,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete template", err)
|
||||
s.storeError(w, r, "delete template", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
|
||||
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
||||
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list filters", err)
|
||||
s.internal(w, r, "list filters", err)
|
||||
return
|
||||
}
|
||||
out := make([]filterJSON, 0, len(filters))
|
||||
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
f.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
||||
s.internal(w, "create filter", err)
|
||||
s.internal(w, r, "create filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
|
||||
@@ -108,7 +108,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
s.storeError(w, "update filter", err)
|
||||
s.storeError(w, r, "update filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -117,7 +117,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete filter", err)
|
||||
s.storeError(w, r, "delete filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -129,7 +129,7 @@ func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get filter", err)
|
||||
s.storeError(w, r, "get filter", err)
|
||||
return
|
||||
}
|
||||
s.runQuery(w, r, f.Query)
|
||||
@@ -167,7 +167,7 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string)
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "run filter", err)
|
||||
s.internal(w, r, "run filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||
@@ -210,7 +210,7 @@ type labelJSON struct {
|
||||
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
|
||||
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list labels", err)
|
||||
s.internal(w, r, "list labels", err)
|
||||
return
|
||||
}
|
||||
out := make([]labelJSON, 0, len(labels))
|
||||
@@ -242,9 +242,10 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create label", err)
|
||||
s.internal(w, r, "create label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
}
|
||||
|
||||
@@ -260,17 +261,19 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "update label", err)
|
||||
s.storeError(w, r, "update label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete label", err)
|
||||
s.storeError(w, r, "delete label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
pkce, err := gmail.NewPKCE()
|
||||
if err != nil {
|
||||
s.internal(w, "gmail pkce", err)
|
||||
s.internal(w, r, "gmail pkce", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
settings["pkce_verifier"] = pkce.Verifier
|
||||
@@ -53,7 +53,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||
@@ -83,7 +83,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
verifier, _ := settings["pkce_verifier"].(string)
|
||||
@@ -138,7 +138,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, "save gmail tokens", err)
|
||||
s.internal(w, r, "save gmail tokens", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
|
||||
project := todoist.FromRows(rows, name)
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import project", err)
|
||||
s.internal(w, r, "import project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import csv", err)
|
||||
s.internal(w, r, "import csv", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
@@ -255,12 +255,12 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
exported, err := s.buildExport(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "export project", err)
|
||||
s.internal(w, r, "export project", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "export projects", err)
|
||||
s.internal(w, r, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export tasks", err)
|
||||
s.internal(w, r, "export tasks", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
@@ -445,14 +445,14 @@ func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export ics", err)
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "mail token", err)
|
||||
s.internal(w, r, "mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
@@ -49,11 +49,11 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, "generate mail token", err)
|
||||
s.internal(w, r, "generate mail token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
||||
s.internal(w, "set mail token", err)
|
||||
s.internal(w, r, "set mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
@@ -134,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, "create task from mail", err)
|
||||
s.internal(w, r, "create task from mail", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ type aiSettings struct {
|
||||
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
// The key is never sent back. A settings page that repopulates a password
|
||||
@@ -216,7 +216,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
existing, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
||||
@@ -231,7 +231,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
"model": req.Model, "api_key": key,
|
||||
}
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
|
||||
s.internal(w, "save ai settings", err)
|
||||
s.internal(w, r, "save ai settings", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -266,7 +266,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
@@ -278,7 +278,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
Priority: task.Priority, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
|
||||
s.internal(w, "create subtask", err)
|
||||
s.internal(w, r, "create subtask", err)
|
||||
return
|
||||
}
|
||||
created = append(created, toTaskJSON(*child))
|
||||
@@ -315,7 +315,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cfg, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
@@ -326,7 +326,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
||||
if err != nil {
|
||||
s.internal(w, "list tasks", err)
|
||||
s.internal(w, r, "list tasks", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
@@ -372,7 +372,7 @@ type gmailSettings struct {
|
||||
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
str := func(key string) string {
|
||||
@@ -396,14 +396,14 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
values["trigger"] = req.Trigger
|
||||
values["label"] = req.Label
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
||||
s.internal(w, "save gmail settings", err)
|
||||
s.internal(w, r, "save gmail settings", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -412,7 +412,7 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
// handleDisconnectGmail forgets the connection, tokens and all.
|
||||
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
|
||||
s.internal(w, "disconnect gmail", err)
|
||||
s.internal(w, r, "disconnect gmail", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -154,7 +154,7 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "mcp key", err)
|
||||
s.internal(w, r, "mcp key", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
// as everybody being logged out at once — the frontend clears its state
|
||||
// and shows the sign-in screen — and nothing in the logs says why.
|
||||
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "authenticate", err)
|
||||
s.internal(w, r, "authenticate", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
||||
|
||||
@@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
||||
if err != nil {
|
||||
s.internal(w, "list notifications", err)
|
||||
s.internal(w, r, "list notifications", err)
|
||||
return
|
||||
}
|
||||
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count notifications", err)
|
||||
s.internal(w, r, "count notifications", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
||||
if err != nil {
|
||||
s.internal(w, "mark notifications read", err)
|
||||
s.internal(w, r, "mark notifications read", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -148,7 +148,7 @@ type pushSubscriptionRequest struct {
|
||||
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||
public, _, err := s.vapidKeys(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "vapid keys", err)
|
||||
s.internal(w, r, "vapid keys", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
||||
@@ -172,7 +172,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
UserAgent: r.UserAgent(),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "save push subscription", err)
|
||||
s.internal(w, r, "save push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||
s.internal(w, "delete push subscription", err)
|
||||
s.internal(w, r, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Groups are the foldable headings over the projects in the sidebar.
|
||||
//
|
||||
// They sit outside /projects rather than under it: a group is not a project and
|
||||
// has no members, no tasks and no roles. Every route here is scoped to the caller
|
||||
// in the query itself, so there is no permission middleware to forget — a group
|
||||
// that is not yours is not found.
|
||||
|
||||
type projectGroupJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||
return projectGroupJSON{
|
||||
ID: g.ID, Name: g.Name, Color: g.Color,
|
||||
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list project groups", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectGroupJSON, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, toProjectGroupJSON(g))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
|
||||
}
|
||||
|
||||
type projectGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req projectGroupRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if fields := validateGroupName(name); len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Color != nil && !store.ValidColor(*req.Color) {
|
||||
writeFieldErrors(w, map[string]string{"color": colorFieldError})
|
||||
return
|
||||
}
|
||||
|
||||
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
|
||||
if req.Color != nil {
|
||||
g.Color = *req.Color
|
||||
}
|
||||
if req.Collapsed != nil {
|
||||
g.Collapsed = *req.Collapsed
|
||||
}
|
||||
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
||||
s.internal(w, r, "create project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||||
writeJSON(w, http.StatusCreated, toProjectGroupJSON(*g))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req projectGroupRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
userID := userFrom(r.Context()).ID
|
||||
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if fields := validateGroupName(name); len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
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, Color: req.Color, Collapsed: req.Collapsed,
|
||||
}); err != nil {
|
||||
s.storeError(w, r, "update project group", err)
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
||||
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
|
||||
}
|
||||
|
||||
// handleDeleteProjectGroup removes the heading. The projects under it come back
|
||||
// out as ungrouped, which is why this is not behind a confirmation about losing
|
||||
// work: there is none to lose.
|
||||
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "delete project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleReorderProjectGroups takes the whole list, like the projects do — see
|
||||
// handleReorderProjects for why.
|
||||
func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, r, "reorder project groups", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateGroupName(name string) map[string]string {
|
||||
switch {
|
||||
case name == "":
|
||||
return map[string]string{"name": "required"}
|
||||
case len([]rune(name)) > 200:
|
||||
return map[string]string{"name": "must be 200 characters or fewer"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAGroupHoldsProjectsAndSurvivesBeingEmptied(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create group: %d %v", resp.StatusCode, group)
|
||||
}
|
||||
groupID := group["id"].(string)
|
||||
if group["collapsed"] != false {
|
||||
t.Errorf("a new group should be open: %v", group)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
resp, filed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID,
|
||||
map[string]any{"group_id": groupID})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("file project: %d %v", resp.StatusCode, filed)
|
||||
}
|
||||
if filed["group_id"] != groupID {
|
||||
t.Errorf("group_id = %v, want %s", filed["group_id"], groupID)
|
||||
}
|
||||
|
||||
// The listing is what the sidebar reads, so the filing has to survive it.
|
||||
if got := groupOf(t, ts, projectID); got != groupID {
|
||||
t.Errorf("group_id in the list = %q, want %s", got, groupID)
|
||||
}
|
||||
|
||||
// An empty group is still a group. This is the whole reason it is a table and
|
||||
// not a string repeated on every project row.
|
||||
resp, _ = ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": ""})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ungroup: status %d", resp.StatusCode)
|
||||
}
|
||||
if got := groupOf(t, ts, projectID); got != "" {
|
||||
t.Errorf("group_id = %q after ungrouping, want empty", got)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
if groups, _ := listed["groups"].([]any); len(groups) != 1 {
|
||||
t.Fatalf("the emptied group is gone: %v", listed)
|
||||
}
|
||||
}
|
||||
|
||||
// An omitted group_id must not clear the field: that is the difference between a
|
||||
// PATCH and a PUT, and renaming a project would otherwise tip it out of its group.
|
||||
func TestRenamingAProjectLeavesItInItsGroup(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)
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||
|
||||
_, renamed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"name": "Bogholderi"})
|
||||
if renamed["group_id"] != groupID {
|
||||
t.Errorf("group_id = %v after a rename, want %s", renamed["group_id"], groupID)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the heading is not a request to delete the work filed under it.
|
||||
func TestDeletingAGroupKeepsItsProjects(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)
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||
|
||||
resp, _ := ts.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete group: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
found := false
|
||||
for _, raw := range projects["projects"].([]any) {
|
||||
p := raw.(map[string]any)
|
||||
if p["id"] != projectID {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if p["group_id"] != nil && p["group_id"] != "" {
|
||||
t.Errorf("the project still points at a group that is gone: %v", p["group_id"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the project went with the group it was filed under")
|
||||
}
|
||||
}
|
||||
|
||||
// Folding is stored on the account rather than in the browser, so it has to come
|
||||
// back from the server.
|
||||
func TestFoldingAGroupIsRemembered(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
resp, folded := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||
map[string]any{"collapsed": true})
|
||||
if resp.StatusCode != http.StatusOK || folded["collapsed"] != true {
|
||||
t.Fatalf("fold: %d %v", resp.StatusCode, folded)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
first := listed["groups"].([]any)[0].(map[string]any)
|
||||
if first["collapsed"] != true {
|
||||
t.Errorf("collapsed = %v after re-reading, want true", first["collapsed"])
|
||||
}
|
||||
}
|
||||
|
||||
// Groups are per person: somebody else's is not yours to see, rename or delete,
|
||||
// and a group that is not yours is not found rather than forbidden.
|
||||
func TestGroupsArePerPerson(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)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
_, listed := other.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
if groups, _ := listed["groups"].([]any); len(groups) != 0 {
|
||||
t.Errorf("somebody else's groups are visible: %v", groups)
|
||||
}
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||
map[string]any{"name": "Mit"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("renaming somebody else's group: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("deleting somebody else's group: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A project cannot be filed into a group that is not the caller's, even though
|
||||
// both the project and the group exist and both requests would be well-formed.
|
||||
func TestAProjectCannotJoinSomebodyElsesGroup(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
_, group := other.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Deres"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404 — a group you cannot see is not one you can file into",
|
||||
resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsReorderAsAWholeList(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ids := make([]string, 3)
|
||||
for i, name := range []string{"En", "To", "Tre"} {
|
||||
_, g := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": name})
|
||||
ids[i] = g["id"].(string)
|
||||
}
|
||||
|
||||
reversed := []string{ids[2], ids[1], ids[0]}
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/project-groups/reorder", map[string]any{"ids": reversed})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
got := listed["groups"].([]any)
|
||||
for i, want := range reversed {
|
||||
if got[i].(map[string]any)["id"] != want {
|
||||
t.Fatalf("order = %v, want %v", got, reversed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A name is required, the same as a project's. An unnamed heading is a heading
|
||||
// nobody can tell apart from the next one.
|
||||
func TestAGroupNeedsAName(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": " "})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, want 422 — body %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// groupOf reads a project's group back out of the listing the sidebar uses.
|
||||
func groupOf(t *testing.T, ts *testServer, projectID string) string {
|
||||
t.Helper()
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
for _, raw := range body["projects"].([]any) {
|
||||
p := raw.(map[string]any)
|
||||
if p["id"] == projectID {
|
||||
id, _ := p["group_id"].(string)
|
||||
return id
|
||||
}
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type projectJSON struct {
|
||||
Role string `json:"role"`
|
||||
MemberCount int `json:"member_count"`
|
||||
Shared bool `json:"shared"`
|
||||
// GroupID is the sidebar group the asking user has filed it under, empty for
|
||||
// none. Always empty on a project shared with them: grouping is a column on
|
||||
// the project row, so it is the owner's filing and nobody else's.
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
}
|
||||
|
||||
func toProjectJSON(p store.Project) projectJSON {
|
||||
@@ -31,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
|
||||
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
||||
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
|
||||
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
|
||||
Shared: p.MemberCount > 1,
|
||||
Shared: p.MemberCount > 1, GroupID: p.GroupID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
||||
if err != nil {
|
||||
s.internal(w, "list projects", err)
|
||||
s.internal(w, r, "list projects", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectJSON, 0, len(projects))
|
||||
@@ -55,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||
@@ -67,6 +71,11 @@ type projectRequest struct {
|
||||
Icon *string `json:"icon"`
|
||||
ViewMode *string `json:"view_mode"`
|
||||
Archived *bool `json:"archived"`
|
||||
// GroupID files the project under a sidebar group; "" takes it out of one.
|
||||
// The empty string rather than a JSON null, because null and absent are the
|
||||
// same nil pointer once decoded — and telling "put it nowhere" apart from
|
||||
// "I did not mention it" is the whole point of a PATCH.
|
||||
GroupID *string `json:"group_id"`
|
||||
}
|
||||
|
||||
type reorderProjectsRequest struct {
|
||||
@@ -96,7 +105,7 @@ func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder projects", err)
|
||||
s.internal(w, r, "reorder projects", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -113,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
|
||||
}
|
||||
@@ -129,10 +138,11 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
p.ViewMode = *req.ViewMode
|
||||
}
|
||||
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
||||
s.internal(w, "create project", err)
|
||||
s.internal(w, r, "create project", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||
s.publishToOwner(r, "project.created", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||
}
|
||||
|
||||
@@ -148,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
|
||||
}
|
||||
@@ -158,16 +174,28 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
ViewMode: req.ViewMode, Archived: req.Archived,
|
||||
})
|
||||
if err != nil {
|
||||
s.storeError(w, "update project", err)
|
||||
s.storeError(w, r, "update project", err)
|
||||
return
|
||||
}
|
||||
// Grouping is written separately because it is scoped differently: the fields
|
||||
// above belong to the project, and this one belongs to whoever is filing it.
|
||||
// The route already requires ownership, so the check inside is a second lock
|
||||
// on the same door rather than a different one.
|
||||
if req.GroupID != nil {
|
||||
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "set project group", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.activity(r, projectID, "", "project.updated", nil)
|
||||
|
||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||
}
|
||||
|
||||
@@ -178,10 +206,11 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "delete project", err)
|
||||
s.storeError(w, r, "delete project", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "project.deleted", nil)
|
||||
s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -201,7 +230,7 @@ type trashedProjectJSON struct {
|
||||
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list trashed projects", err)
|
||||
s.internal(w, r, "list trashed projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -223,7 +252,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
// deliberately invisible to it. Ownership is what governs restoring.
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, "project owner", err)
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if owner != userFrom(r.Context()).ID {
|
||||
@@ -231,13 +260,18 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
||||
s.storeError(w, "restore project", err)
|
||||
s.storeError(w, r, "restore project", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateProject(name string, viewMode *string) map[string]string {
|
||||
// colorFieldError names the closed set rather than listing it. The list is ten
|
||||
// long and lives in three places already; a caller that got it wrong needs to be
|
||||
// pointed at the documentation, not handed a copy that will one day be stale.
|
||||
const colorFieldError = "must be one of the colours in the palette — see the Color schema"
|
||||
|
||||
func validateProject(name string, viewMode, color *string) map[string]string {
|
||||
fields := map[string]string{}
|
||||
switch {
|
||||
case name == "":
|
||||
@@ -252,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
|
||||
}
|
||||
|
||||
@@ -267,7 +306,7 @@ type sectionJSON struct {
|
||||
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
||||
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
||||
if err != nil {
|
||||
s.internal(w, "list sections", err)
|
||||
s.internal(w, r, "list sections", err)
|
||||
return
|
||||
}
|
||||
out := make([]sectionJSON, 0, len(sections))
|
||||
@@ -297,7 +336,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
||||
sec.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
||||
s.internal(w, "create section", err)
|
||||
s.internal(w, r, "create section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
||||
@@ -311,7 +350,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
|
||||
sectionID := chi.URLParam(r, "sectionID")
|
||||
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
||||
if err != nil {
|
||||
s.storeError(w, "section project", err)
|
||||
s.storeError(w, r, "section project", err)
|
||||
return "", "", false
|
||||
}
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
||||
@@ -342,7 +381,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
||||
Name: req.Name, SortOrder: req.SortOrder,
|
||||
}); err != nil {
|
||||
s.storeError(w, "update section", err)
|
||||
s.storeError(w, r, "update section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.updated", nil)
|
||||
@@ -355,7 +394,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
||||
s.storeError(w, "delete section", err)
|
||||
s.storeError(w, r, "delete section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.deleted", nil)
|
||||
@@ -375,7 +414,7 @@ type memberJSON struct {
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||
if err != nil {
|
||||
s.internal(w, "list members", err)
|
||||
s.internal(w, r, "list members", err)
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
@@ -424,7 +463,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -436,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||
s.internal(w, "add member", err)
|
||||
s.internal(w, r, "add member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||
@@ -446,7 +485,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create invite", err)
|
||||
s.internal(w, r, "create invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
@@ -463,13 +502,60 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
|
||||
}
|
||||
|
||||
type memberRoleRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleSetMemberRole changes an existing member between editor and viewer.
|
||||
//
|
||||
// Until now the only way to correct an invite sent with the wrong role was to
|
||||
// remove the person and invite them again — which also unassigns everything they
|
||||
// were responsible for, because removing a member clears their tasks' assignee.
|
||||
// Fixing a dropdown should not cost somebody their work.
|
||||
func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req memberRoleRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
role := store.Role(req.Role)
|
||||
if !role.Valid() || role == store.RoleOwner {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||
return
|
||||
}
|
||||
|
||||
// The owner has no project_members row at all — ownership lives on the project
|
||||
// — so this would report "not a member", which is true and completely
|
||||
// unhelpful. Say the real reason instead.
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"the owner's standing cannot be changed; ownership is transferred, not granted")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil {
|
||||
s.storeError(w, r, "set member role", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.role_changed",
|
||||
map[string]any{"user_id": userID, "role": string(role)})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, "project owner", err)
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
@@ -478,7 +564,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
|
||||
s.storeError(w, "remove member", err)
|
||||
s.storeError(w, r, "remove member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
||||
@@ -490,13 +576,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
|
||||
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
|
||||
// to learn.
|
||||
func (s *Server) storeError(w http.ResponseWriter, what string, err error) {
|
||||
func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
case errors.Is(err, store.ErrEmailInUse):
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
||||
default:
|
||||
s.internal(w, what, err)
|
||||
s.internal(w, r, what, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,3 +153,133 @@ func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The member list errored on every project — the ORDER BY named an expression,
|
||||
// which SQLite refuses across a UNION — so the share panel had never shown
|
||||
// anybody, not even the owner.
|
||||
func TestListMembersWorksAtAll(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
members, _ := body["members"].([]any)
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
|
||||
}
|
||||
if m := members[0].(map[string]any); m["role"] != "owner" {
|
||||
t.Errorf("role = %v, want owner", m["role"])
|
||||
}
|
||||
}
|
||||
|
||||
// And the Inbox, which is the one every task drawer asks about.
|
||||
func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
var inboxID string
|
||||
for _, raw := range list["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["is_inbox"] == true {
|
||||
inboxID = p["id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A role sent by mistake used to be uncorrectable: the only way back was to remove
|
||||
// the person and invite them again, which unassigns every task they were
|
||||
// responsible for. Fixing a dropdown should not cost somebody their work.
|
||||
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
member := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "anden@example.dk", "role": "viewer",
|
||||
})
|
||||
|
||||
// A viewer cannot write. That is the rule, not the bug.
|
||||
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
||||
map[string]any{"content": "prøv", "project_id": projectID}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("a viewer created a task: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||
map[string]any{"role": "editor"})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("promote to editor: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
||||
map[string]any{"content": "nu kan jeg", "project_id": projectID}); resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("an editor could not create a task: status %d", resp.StatusCode)
|
||||
}
|
||||
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
for _, raw := range members["members"].([]any) {
|
||||
if m := raw.(map[string]any); m["user_id"] == memberUser.ID && m["role"] != "editor" {
|
||||
t.Errorf("role = %v after the change, want editor", m["role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The owner is not a member row, and ownership is not a role you can hand out.
|
||||
func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
member := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
owner, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "anden@example.dk", "role": "editor",
|
||||
})
|
||||
|
||||
// Demoting the owner would leave a project nobody can administer.
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+owner.ID,
|
||||
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("changing the owner's role: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
// And no member may promote themselves to owner.
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||
map[string]any{"role": "owner"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("granting ownership: status %d, want 422", resp.StatusCode)
|
||||
}
|
||||
// Nor may an editor change anybody's role: this is the owner's to do.
|
||||
if resp, _ := member.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an editor changed a role: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
// Somebody with no membership at all is not quietly given one.
|
||||
stranger := ts.newUser(t, "tredje@example.dk", "Tredje")
|
||||
_ = stranger
|
||||
strangerUser, _ := ts.db.UserByEmail(t.Context(), "tredje@example.dk")
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+strangerUser.ID,
|
||||
map[string]any{"role": "editor"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@ func (s *Server) publish(projectID, event string, payload any) {
|
||||
s.hub.Publish(projectID, event, payload)
|
||||
}
|
||||
|
||||
// publishToOwner sends a change that belongs to a person rather than to a
|
||||
// project: their projects, their labels, their saved filters.
|
||||
//
|
||||
// Project membership is what the socket subscribes to, and a project that has
|
||||
// just been created has no subscribers — the person is not watching a thing that
|
||||
// did not exist a moment ago. So these go to the user instead, which also
|
||||
// reaches their other devices.
|
||||
func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
if user := userFrom(r.Context()); user != nil {
|
||||
s.hub.PublishToUser(user.ID, event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebSocket is the live-sync connection.
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
@@ -151,6 +151,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Behind requireSession, like the API tokens and for the same
|
||||
// reason: a leaked token must not be able to end the sessions of
|
||||
// the person it was stolen from, or list their devices.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -254,6 +263,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
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) {
|
||||
@@ -282,11 +293,38 @@ 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("/errors", s.handleListErrors)
|
||||
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.
|
||||
r.Route("/project-groups", func(r chi.Router) {
|
||||
r.Get("/", s.handleListProjectGroups)
|
||||
r.Post("/", s.handleCreateProjectGroup)
|
||||
// Above /{groupID}, or chi reads "reorder" as an id.
|
||||
r.Post("/reorder", s.handleReorderProjectGroups)
|
||||
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
|
||||
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
|
||||
})
|
||||
|
||||
// The trash is outside the block above: a deleted project is invisible
|
||||
// to the permission check by design, so these handlers resolve
|
||||
// ownership themselves.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Where a person is signed in, and how to end one of them.
|
||||
//
|
||||
// `last_seen_at` has been written on every request since sessions existed —
|
||||
// throttled to once a minute, precisely so this list could say "this device, 2
|
||||
// minutes ago" — and nothing has ever read it. A session you cannot see is a
|
||||
// session you cannot end.
|
||||
|
||||
type sessionJSON struct {
|
||||
ID string `json:"id"`
|
||||
// Current marks the one making the request, so the interface can say "denne
|
||||
// enhed" rather than making somebody work it out from a user agent.
|
||||
Current bool `json:"current"`
|
||||
// Device is a summary; UserAgent is what it was summarised from. Both, because
|
||||
// the summary is what you read and the original is what settles the question
|
||||
// when the summary is not enough.
|
||||
Device string `json:"device"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
// handleListSessions lists the caller's live sessions, most recently used first.
|
||||
//
|
||||
// The id here is the stored hash of the cookie, never the cookie itself: it cannot
|
||||
// be pasted into a browser, and it is what the delete below needs.
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
current := sessionFrom(r.Context())
|
||||
|
||||
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list sessions", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]sessionJSON, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
out = append(out, sessionJSON{
|
||||
ID: sess.ID,
|
||||
Current: current != nil && sess.ID == current.ID,
|
||||
Device: describeUserAgent(sess.UA),
|
||||
UserAgent: sess.UA,
|
||||
IP: sess.IP,
|
||||
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
||||
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
|
||||
ExpiresAt: sess.ExpiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
|
||||
}
|
||||
|
||||
// handleDeleteSession signs one device out. Ending the current one is allowed and
|
||||
// is simply logging out — refusing it would be a rule with no reason behind it,
|
||||
// and somebody clearing every device wants this one gone too.
|
||||
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionID")
|
||||
|
||||
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
||||
s.storeError(w, r, "delete session", err)
|
||||
return
|
||||
}
|
||||
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
||||
s.clearSessionCookie(w)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// describeUserAgent turns a user-agent string into something a person recognises.
|
||||
//
|
||||
// Deliberately a handful of substring tests rather than a parsing library. The
|
||||
// question this answers is "is one of these me?", and for that a wrong guess
|
||||
// between two Chromium forks costs nothing while a dependency that has to be kept
|
||||
// current costs something every year. The raw string is sent alongside, so
|
||||
// anything this gets wrong is still visible.
|
||||
//
|
||||
// Order matters: every browser's user agent claims to be several other browsers,
|
||||
// so the most specific name has to be tested first.
|
||||
func describeUserAgent(ua string) string {
|
||||
if strings.TrimSpace(ua) == "" {
|
||||
return "Ukendt enhed"
|
||||
}
|
||||
|
||||
browser := ""
|
||||
switch {
|
||||
case strings.Contains(ua, "Edg/"):
|
||||
browser = "Edge"
|
||||
case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"):
|
||||
browser = "Opera"
|
||||
case strings.Contains(ua, "Firefox/"):
|
||||
browser = "Firefox"
|
||||
case strings.Contains(ua, "Chrome/"):
|
||||
browser = "Chrome"
|
||||
case strings.Contains(ua, "Safari/"):
|
||||
browser = "Safari"
|
||||
}
|
||||
|
||||
system := ""
|
||||
switch {
|
||||
// Before "Mac OS X": an iPhone's user agent says both.
|
||||
case strings.Contains(ua, "iPhone"):
|
||||
system = "iPhone"
|
||||
case strings.Contains(ua, "iPad"):
|
||||
system = "iPad"
|
||||
case strings.Contains(ua, "Android"):
|
||||
system = "Android"
|
||||
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
|
||||
system = "macOS"
|
||||
case strings.Contains(ua, "Windows"):
|
||||
system = "Windows"
|
||||
case strings.Contains(ua, "Linux"):
|
||||
system = "Linux"
|
||||
}
|
||||
|
||||
switch {
|
||||
case browser != "" && system != "":
|
||||
return browser + " på " + system
|
||||
case browser != "":
|
||||
return browser
|
||||
case system != "":
|
||||
return system
|
||||
}
|
||||
// Nothing recognised: show the string itself rather than "Ukendt". A CalDAV
|
||||
// client or a script is a real session, and its own name is more useful than a
|
||||
// word that says the server gave up.
|
||||
return truncateRunes(ua, 60)
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
||||
if err != nil {
|
||||
s.internal(w, "list tasks", err)
|
||||
s.internal(w, r, "list tasks", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||
@@ -116,7 +116,7 @@ func toTaskList(tasks []store.Task) []taskJSON {
|
||||
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
@@ -162,7 +162,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
||||
s.internal(w, "create task", err)
|
||||
s.internal(w, r, "create task", err)
|
||||
return
|
||||
}
|
||||
// The store wrote the labels; the struct it was handed does not know that, and
|
||||
@@ -302,13 +302,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
||||
s.storeError(w, "update task", err)
|
||||
s.storeError(w, r, "update task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||
@@ -327,13 +327,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "complete task", err)
|
||||
s.storeError(w, r, "complete task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,13 +369,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
||||
s.storeError(w, "reopen task", err)
|
||||
s.storeError(w, r, "reopen task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
||||
@@ -394,11 +394,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||
s.storeError(w, "delete task", err)
|
||||
s.storeError(w, r, "delete task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
||||
@@ -427,7 +427,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
@@ -449,12 +449,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
||||
s.storeError(w, "move task", err)
|
||||
s.storeError(w, r, "move task", err)
|
||||
return
|
||||
}
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
||||
@@ -502,7 +502,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -523,7 +523,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
||||
s.internal(w, "create task", err)
|
||||
s.internal(w, r, "create task", err)
|
||||
return
|
||||
}
|
||||
t.Labels = parsed.Labels
|
||||
|
||||
@@ -36,7 +36,7 @@ type createdTokenJSON struct {
|
||||
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list api tokens", err)
|
||||
s.internal(w, r, "list api tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
|
||||
if err != nil {
|
||||
s.internal(w, "create api token", err)
|
||||
s.internal(w, r, "create api token", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete api token", err)
|
||||
s.storeError(w, r, "delete api token", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -29,12 +29,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
|
||||
if err != nil {
|
||||
s.internal(w, "generate totp secret", err)
|
||||
s.internal(w, r, "generate totp secret", err)
|
||||
return
|
||||
}
|
||||
// Stored but not enabled: confirm decides that.
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
|
||||
s.internal(w, "store totp secret", err)
|
||||
s.internal(w, r, "store totp secret", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
||||
@@ -63,15 +63,15 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
s.internal(w, r, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
s.internal(w, r, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
||||
s.internal(w, "enable totp", err)
|
||||
s.internal(w, r, "enable totp", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
|
||||
s.internal(w, "disable totp", err)
|
||||
s.internal(w, r, "disable totp", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
||||
@@ -127,11 +127,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
s.internal(w, r, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
s.internal(w, r, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||
@@ -140,7 +140,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count recovery codes", err)
|
||||
s.internal(w, r, "count recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
|
||||
|
||||
@@ -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, r, "list users", err)
|
||||
return
|
||||
}
|
||||
invites, err := s.db.ListPendingInvites(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "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, r, "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, r, "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, r, "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, r, "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, r, "get user", err)
|
||||
return err
|
||||
}
|
||||
if !target.IsAdmin {
|
||||
return nil
|
||||
}
|
||||
admins, err := s.db.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "count admins", err)
|
||||
return err
|
||||
}
|
||||
if admins <= 1 {
|
||||
writeError(w, http.StatusConflict, CodeLastAdmin, message)
|
||||
return errLastAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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": "ny@example.dk"})
|
||||
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"] != "ny@example.dk" {
|
||||
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"] == "ny@example.dk" && 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(), "kristian@example.dk")
|
||||
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, "anden@example.dk", "Anden")
|
||||
|
||||
other, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||
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, "anden@example.dk", "Anden")
|
||||
|
||||
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||
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": "anden@example.dk", "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, "anden@example.dk", "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": "x@y.dk"}},
|
||||
{"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(), "kristian@example.dk")
|
||||
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": "forkert@example.dk"})
|
||||
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": "kristian@example.dk"})
|
||||
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
|
||||
}
|
||||
|
||||
// A 500 has to leave something behind that a restart cannot take.
|
||||
//
|
||||
// The panel's watcher already reports that one happened. What it cannot report is
|
||||
// what it was: that lives in the container's log, and a Rune replaces the
|
||||
// container on every restart, so the explanation is usually gone before anybody
|
||||
// looks.
|
||||
func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Nothing has gone wrong yet.
|
||||
_, empty := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
if rows, _ := empty["errors"].([]any); len(rows) != 0 {
|
||||
t.Fatalf("a fresh instance has errors already: %v", rows)
|
||||
}
|
||||
|
||||
// Break something the handler cannot survive: the table it reads is gone, so
|
||||
// the query fails and the handler answers 500. Dropping it is a blunt way to
|
||||
// provoke that, and a faithful one — it is the same code path a disk error or
|
||||
// a corrupt page would take.
|
||||
if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := body["errors"].([]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want one recorded error, got %v", rows)
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["what"] != "list project groups" {
|
||||
t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"])
|
||||
}
|
||||
if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" {
|
||||
t.Errorf("path/method = %v %v", row["method"], row["path"])
|
||||
}
|
||||
if row["status"] != float64(500) {
|
||||
t.Errorf("status = %v", row["status"])
|
||||
}
|
||||
if row["message"] == "" {
|
||||
t.Error("no message: the row says something broke and not what")
|
||||
}
|
||||
if row["user_name"] != "Kristian" {
|
||||
t.Errorf("user_name = %v — a fault only one account hits is a different problem",
|
||||
row["user_name"])
|
||||
}
|
||||
if row["request_id"] == "" {
|
||||
t.Error("no request id, so the row cannot be tied back to the log line")
|
||||
}
|
||||
}
|
||||
|
||||
// The list is an administrator's: it carries paths, accounts and the server's own
|
||||
// error strings.
|
||||
func TestTheErrorLogIsAdminsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "today", err)
|
||||
s.internal(w, r, "today", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "upcoming", err)
|
||||
s.internal(w, r, "upcoming", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,6 +92,104 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
||||
}
|
||||
|
||||
// handlePeople lists everybody the caller shares a project with, so an interface
|
||||
// can put a name and a face on an assignee id.
|
||||
//
|
||||
// One request per session rather than a lookup per row, and not the instance's
|
||||
// user list — that is the administrator's page. A task list has no business
|
||||
// enumerating everybody with an account here.
|
||||
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
|
||||
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list people", err)
|
||||
return
|
||||
}
|
||||
out := make([]personJSON, 0, len(people))
|
||||
for _, p := range people {
|
||||
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"people": out})
|
||||
}
|
||||
|
||||
type personJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
}
|
||||
|
||||
type delegatedPerson struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
Tasks []taskJSON `json:"tasks"`
|
||||
}
|
||||
|
||||
// handleDelegated is "waiting on others": everything assigned to somebody who is
|
||||
// not the caller, in the projects the caller can see.
|
||||
//
|
||||
// Grouped by person rather than returned flat, because the question this view
|
||||
// answers is about people — "what is Anders sitting on" — and a client grouping it
|
||||
// itself would need every assignee's name, which it has no way to ask for. The
|
||||
// names come back with the tasks, so one request is one screen.
|
||||
//
|
||||
// People are ordered by name, and their tasks by whatever the list query decided,
|
||||
// which puts the overdue and the soonest first.
|
||||
func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
DelegatedBy: user.ID,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "delegated", err)
|
||||
return
|
||||
}
|
||||
|
||||
byPerson := map[string][]taskJSON{}
|
||||
ids := []string{}
|
||||
for _, t := range tasks {
|
||||
if _, seen := byPerson[t.AssigneeID]; !seen {
|
||||
ids = append(ids, t.AssigneeID)
|
||||
}
|
||||
byPerson[t.AssigneeID] = append(byPerson[t.AssigneeID], toTaskJSON(t))
|
||||
}
|
||||
|
||||
people, err := s.db.PeopleByIDs(r.Context(), ids)
|
||||
if err != nil {
|
||||
s.internal(w, r, "delegated people", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]delegatedPerson, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
// A person who no longer has an account still has their id on the task.
|
||||
// Naming them "Ukendt" beats dropping the tasks, which would make work
|
||||
// disappear from the one view that exists to stop that happening.
|
||||
p, ok := people[id]
|
||||
if !ok {
|
||||
p = store.Person{ID: id, Name: "Ukendt", AvatarColor: "#8a8f98"}
|
||||
}
|
||||
out = append(out, delegatedPerson{
|
||||
UserID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor, Tasks: byPerson[id],
|
||||
})
|
||||
}
|
||||
sortByName(out)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"people": out})
|
||||
}
|
||||
|
||||
// sortByName orders the groups the way a list of people reads. Insertion sort:
|
||||
// this is a handful of entries, and it keeps the package free of a dependency on
|
||||
// sort just for this.
|
||||
func sortByName(people []delegatedPerson) {
|
||||
for i := 1; i < len(people); i++ {
|
||||
for j := i; j > 0 && lower(people[j].Name) < lower(people[j-1].Name); j-- {
|
||||
people[j], people[j-1] = people[j-1], people[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleSearch is what Cmd+K calls. It searches across every project the user can
|
||||
// see, which is the point — the thing you are looking for is usually somewhere you
|
||||
// were not looking.
|
||||
@@ -111,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "search tasks", err)
|
||||
s.internal(w, r, "search tasks", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// second FTS index for that would be more machinery than the problem needs.
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||
if err != nil {
|
||||
s.internal(w, "search projects", err)
|
||||
s.internal(w, r, "search projects", err)
|
||||
return
|
||||
}
|
||||
matched := []projectJSON{}
|
||||
@@ -141,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
||||
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
||||
if err != nil {
|
||||
s.internal(w, "list activity", err)
|
||||
s.internal(w, r, "list activity", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A label is how you find a task whose wording you have forgotten. Search
|
||||
// covered the text only, so "regnskab" found nothing even with the task
|
||||
// labelled @regnskab in front of you.
|
||||
func TestSearchFindsTasksByLabel(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "ring til revisoren @regnskab"})
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "køb kaffe"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||
tasks, _ := body["tasks"].([]any)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("found %d tasks searching for a label, want 1: %v", len(tasks), body)
|
||||
}
|
||||
if got := tasks[0].(map[string]any)["content"]; got != "ring til revisoren" {
|
||||
t.Errorf("found %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The text search still works, and a label match does not swallow it.
|
||||
func TestSearchStillFindsTasksByText(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": "køb grøn maling"})
|
||||
|
||||
// Diacritic folding still applies to the text side.
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
|
||||
if tasks, _ := body["tasks"].([]any); len(tasks) != 1 {
|
||||
t.Errorf("searching \"gron\" found %d tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's label must not pull their task into your results — the label
|
||||
// join has to stay inside the visibility the outer query already applies.
|
||||
func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
other.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "deres hemmelighed @regnskab"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||
if tasks, _ := body["tasks"].([]any); len(tasks) != 0 {
|
||||
t.Errorf("found somebody else's task: %v", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// "Waiting on others" is the other half of an assignee filter, and the half that
|
||||
// is easy to get backwards: what somebody has handed over, not what they have
|
||||
// been handed.
|
||||
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "anders@example.dk", "Anders")
|
||||
|
||||
anders, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "anders@example.dk", "role": "editor",
|
||||
})
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
|
||||
})
|
||||
// Mine, and unassigned: neither belongs in a view about other people.
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "ingen har den", "project_id": projectID,
|
||||
})
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
people, _ := body["people"].([]any)
|
||||
if len(people) != 1 {
|
||||
t.Fatalf("want one person, got %v", people)
|
||||
}
|
||||
person := people[0].(map[string]any)
|
||||
if person["name"] != "Anders" {
|
||||
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
|
||||
person["name"])
|
||||
}
|
||||
if person["avatar_color"] == nil || person["avatar_color"] == "" {
|
||||
t.Errorf("no avatar colour: %v", person)
|
||||
}
|
||||
|
||||
tasks, _ := person["tasks"].([]any)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("want one task, got %v", tasks)
|
||||
}
|
||||
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
|
||||
t.Errorf("content = %v", tasks[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The view is per person: Anders is not waiting on himself, and he cannot see
|
||||
// what somebody has delegated in a project he is not in.
|
||||
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
anders := ts.newUser(t, "anders@example.dk", "Anders")
|
||||
|
||||
andersUser, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A project Anders is not a member of.
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "anders@example.dk", "role": "editor",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
|
||||
})
|
||||
|
||||
// It is assigned to him, so it is not something he is waiting on.
|
||||
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
|
||||
if people, _ := body["people"].([]any); len(people) != 0 {
|
||||
t.Errorf("Anders is waiting on himself: %v", people)
|
||||
}
|
||||
|
||||
// And somebody with no access to the project sees none of it.
|
||||
other := ts.newUser(t, "tredje@example.dk", "Tredje")
|
||||
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
|
||||
if people, _ := third["people"].([]any); len(people) != 0 {
|
||||
t.Errorf("a task in a project they cannot see: %v", people)
|
||||
}
|
||||
}
|
||||
|
||||
// /people is what puts a name on an assignee id. It must be the people you share
|
||||
// work with — not the instance's user list, which is the administrator's page.
|
||||
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
colleague := ts.newUser(t, "anders@example.dk", "Anders")
|
||||
ts.newUser(t, "fremmed@example.dk", "Fremmed")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites", map[string]any{
|
||||
"email": "anders@example.dk", "role": "editor",
|
||||
})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/people", nil)
|
||||
names := map[string]bool{}
|
||||
for _, raw := range body["people"].([]any) {
|
||||
p := raw.(map[string]any)
|
||||
names[p["name"].(string)] = true
|
||||
if p["avatar_color"] == "" {
|
||||
t.Errorf("no avatar colour for %v — the row cannot draw a face", p["name"])
|
||||
}
|
||||
}
|
||||
|
||||
if !names["Kristian"] {
|
||||
t.Error("yourself is missing, so a client cannot tell 'me' from 'somebody else' against one list")
|
||||
}
|
||||
if !names["Anders"] {
|
||||
t.Error("somebody sharing a project is missing")
|
||||
}
|
||||
if names["Fremmed"] {
|
||||
t.Error("an account with no project in common is listed — this is not the user directory")
|
||||
}
|
||||
|
||||
// And it is symmetric: the person invited sees the person who invited them.
|
||||
_, theirs := colleague.do(t, "GET", "/api/v1/people", nil)
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range theirs["people"].([]any) {
|
||||
seen[raw.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !seen["Kristian"] || !seen["Anders"] {
|
||||
t.Errorf("the member cannot see who they share with: %v", seen)
|
||||
}
|
||||
if seen["Fremmed"] {
|
||||
t.Errorf("a stranger is listed: %v", seen)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
||||
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
||||
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
|
||||
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
|
||||
// Ten minutes is a compromise: Gmail's push notifications would be instant but
|
||||
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||
@@ -265,6 +266,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer
|
||||
// "what happened last week" and short enough that a fault loop cannot fill the
|
||||
// volume the tasks live on.
|
||||
func (r *Runner) purgeErrors(ctx context.Context) error {
|
||||
n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
r.log.Debug("old error rows removed", "count", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) purgeSessions(ctx context.Context) error {
|
||||
n, err := r.db.PurgeExpiredSessions(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A ServerError is one 500 the API answered, kept so it can be read after the
|
||||
// container that produced it has gone.
|
||||
type ServerError struct {
|
||||
ID string
|
||||
At time.Time
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
What string
|
||||
Message string
|
||||
UserID string
|
||||
UserName string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// RecordError stores one failure.
|
||||
//
|
||||
// Errors here are dropped rather than returned: this is called from the path that
|
||||
// is already reporting a failure to somebody, and a diagnostic that can itself
|
||||
// fail the request would turn one broken screen into two.
|
||||
func (db *DB) RecordError(ctx context.Context, e ServerError) {
|
||||
var user any
|
||||
if e.UserID != "" {
|
||||
user = e.UserID
|
||||
}
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`INSERT INTO error_log (id, at, method, path, status, what, message, user_id, request_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), time.Now().Unix(), e.Method, truncate(e.Path, 500), e.Status,
|
||||
truncate(e.What, 200), truncate(e.Message, 2000), user, truncate(e.RequestID, 100))
|
||||
}
|
||||
|
||||
// ListErrors returns the most recent failures, newest first.
|
||||
func (db *DB) ListErrors(ctx context.Context, limit int) ([]ServerError, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT e.id, e.at, e.method, e.path, e.status, e.what, e.message,
|
||||
COALESCE(e.user_id, ''), COALESCE(u.name, ''), e.request_id
|
||||
FROM error_log e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
ORDER BY e.at DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ServerError{}
|
||||
for rows.Next() {
|
||||
var e ServerError
|
||||
var at int64
|
||||
if err := rows.Scan(&e.ID, &at, &e.Method, &e.Path, &e.Status, &e.What,
|
||||
&e.Message, &e.UserID, &e.UserName, &e.RequestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.At = time.Unix(at, 0).UTC()
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PurgeOldErrors is run by the nightly sweep. A diagnostic that grows without
|
||||
// limit is a disk that fills for a reason nobody expected.
|
||||
func (db *DB) PurgeOldErrors(ctx context.Context, keep time.Duration) (int64, error) {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM error_log WHERE at < ?`, time.Now().Add(-keep).Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -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,33 @@
|
||||
-- Foldable groups over the projects in the sidebar.
|
||||
--
|
||||
-- A table rather than a `group_name` column on the project: a group has to be
|
||||
-- renamable in one write, has to survive being emptied, and has to remember
|
||||
-- whether it is folded. A repeated string on every project row can do none of
|
||||
-- those — renaming would touch N rows, and an empty group would simply cease to
|
||||
-- exist the moment its last project left it.
|
||||
CREATE TABLE project_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- Folded state lives on the row, not in localStorage.
|
||||
--
|
||||
-- The sidebar's *width* is a property of the screen you are sitting at, which
|
||||
-- is why that one is stored per browser. A folded group is not: it is a
|
||||
-- statement about that group — "this is the part of my work I am not in right
|
||||
-- now" — and it is true on the laptop and the desktop alike. A group is also
|
||||
-- owned by exactly one person, so there is nobody else's answer to disagree
|
||||
-- with.
|
||||
collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order REAL NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_groups_owner ON project_groups (owner_id);
|
||||
|
||||
-- ON DELETE SET NULL: deleting a group is a decision about the heading, not about
|
||||
-- the projects underneath it. They fall back to ungrouped, in the order they
|
||||
-- already had.
|
||||
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_projects_group ON projects (group_id) WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- A colour on a sidebar group, so a group can be marked the way a project is.
|
||||
--
|
||||
-- Projects, labels and filters have carried a `color` since 0001; groups arrived
|
||||
-- in 0005 without one because nothing rendered any of them yet. Now that the
|
||||
-- sidebar paints them, a group without one would be the only thing in the list
|
||||
-- that could not be told apart at a glance.
|
||||
--
|
||||
-- Same default and same spelling as the others: a colour name, not a hex. The
|
||||
-- value has to survive a theme change and a redesign, and '#8a8f98' in ten
|
||||
-- thousand rows is a decision nobody can revisit.
|
||||
ALTER TABLE project_groups ADD COLUMN color TEXT NOT NULL DEFAULT 'graphite';
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Server errors, kept where they can outlive the container that produced them.
|
||||
--
|
||||
-- verdande already logs the real cause of every 500 to stdout. On a Rune that is
|
||||
-- the container's log, and every restart starts a new one — so the panel can
|
||||
-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone.
|
||||
-- An operator is then told that something broke and given no way to find out what.
|
||||
--
|
||||
-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that
|
||||
-- grows without limit is a disk that fills for a reason nobody expected.
|
||||
CREATE TABLE error_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
at INTEGER NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
-- what: the operation the handler was attempting, in the words it uses in the
|
||||
-- log — "list projects", "move task". The detail a stack trace would give and
|
||||
-- a status code cannot.
|
||||
what TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
-- Whose request it was, so a fault that only one account hits is visible as
|
||||
-- one. NULL for anything that failed before authentication.
|
||||
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
request_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_error_log_at ON error_log (at DESC);
|
||||
@@ -0,0 +1,211 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A ProjectGroup is a heading in the sidebar with projects filed under it.
|
||||
//
|
||||
// Groups belong to one person. They are not shared and never travel with a
|
||||
// project: somebody who has a project shared with them files it where they like,
|
||||
// which is possible precisely because the group is theirs and the membership is
|
||||
// not. That is also why a shared project can never be in a group — see
|
||||
// SetProjectGroup.
|
||||
type ProjectGroup struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
Name string
|
||||
Color string
|
||||
Collapsed bool
|
||||
SortOrder float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
|
||||
FROM project_groups WHERE owner_id = ?
|
||||
ORDER BY sort_order, created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := []ProjectGroup{}
|
||||
for rows.Next() {
|
||||
var g ProjectGroup
|
||||
var collapsed int
|
||||
var created, updated int64
|
||||
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder,
|
||||
&created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Collapsed = collapsed == 1
|
||||
g.CreatedAt = time.Unix(created, 0).UTC()
|
||||
g.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// GetProjectGroup is scoped to the owner rather than looked up by id alone, so a
|
||||
// handler cannot accidentally hand somebody else's group back by forgetting a
|
||||
// check the query could have made itself.
|
||||
func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*ProjectGroup, error) {
|
||||
var g ProjectGroup
|
||||
var collapsed int
|
||||
var created, updated int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`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, &g.Color, &collapsed, &g.SortOrder, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Collapsed = collapsed == 1
|
||||
g.CreatedAt = time.Unix(created, 0).UTC()
|
||||
g.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
|
||||
if g.ID == "" {
|
||||
g.ID = NewID()
|
||||
}
|
||||
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,
|
||||
`SELECT max(sort_order) FROM project_groups WHERE owner_id = ?`,
|
||||
g.OwnerID).Scan(&max); err != nil {
|
||||
return err
|
||||
}
|
||||
g.SortOrder = max.Float64 + 1024
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
set = append(set, "updated_at = ?")
|
||||
args = append(args, time.Now().Unix(), groupID, userID)
|
||||
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE project_groups SET `+joinComma(set)+` WHERE id = ? AND owner_id = ?`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProjectGroup removes the heading and leaves the projects.
|
||||
//
|
||||
// The projects are ungrouped explicitly rather than left to the foreign key's
|
||||
// ON DELETE SET NULL: the pragma is set per connection, and a schema that quietly
|
||||
// depends on it would keep the rows pointing at a group that no longer exists on
|
||||
// any connection that forgot. Hard deleted rather than soft: a group holds no
|
||||
// work of its own, so there is nothing in it to recover.
|
||||
func (db *DB) DeleteProjectGroup(ctx context.Context, groupID, userID string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE projects SET group_id = NULL, updated_at = ? WHERE group_id = ?`,
|
||||
time.Now().Unix(), groupID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ReorderProjectGroups writes the given order, and only for groups the caller
|
||||
// owns — the same whole-list write the projects use, for the same reason: a
|
||||
// handful of rows, one transaction, no precision to run out of.
|
||||
func (db *DB) ReorderProjectGroups(ctx context.Context, userID string, ids []string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
now := time.Now().Unix()
|
||||
for i, id := range ids {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE project_groups SET sort_order = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_id = ?`,
|
||||
float64(i), now, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetProjectGroup files a project under a group, or takes it out of one when
|
||||
// groupID is empty.
|
||||
//
|
||||
// Both ends are checked against the caller: the group must be theirs, and so must
|
||||
// the project. Ownership rather than edit rights, and for the same reason
|
||||
// sort_order is owner-scoped — the column lives on the project row, so a member
|
||||
// filing a shared project into a group of their own would move it in the owner's
|
||||
// sidebar as well. Until grouping is per viewer, a shared project stays where its
|
||||
// owner has it.
|
||||
func (db *DB) SetProjectGroup(ctx context.Context, projectID, groupID, userID string) error {
|
||||
var group any
|
||||
if groupID != "" {
|
||||
var exists int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group = groupID
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE projects SET group_id = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_id = ? AND is_inbox = 0 AND deleted_at IS NULL`,
|
||||
group, time.Now().Unix(), projectID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+61
-14
@@ -20,6 +20,11 @@ type Project struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// GroupID is which sidebar group the *asking* user has filed it under, and is
|
||||
// empty for a project shared with them: the column is on the project row, so
|
||||
// the owner's filing is not an answer anybody else asked for.
|
||||
GroupID string
|
||||
|
||||
// Role is the asking user's standing, filled in by the list and get calls so
|
||||
// the frontend can grey out what they cannot do without a second request.
|
||||
Role Role
|
||||
@@ -44,7 +49,8 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
|
||||
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count,
|
||||
CASE WHEN p.owner_id = ? THEN p.group_id END AS group_id
|
||||
FROM projects p
|
||||
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
||||
WHERE p.deleted_at IS NULL
|
||||
@@ -54,7 +60,7 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
|
||||
}
|
||||
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
|
||||
|
||||
rows, err := db.QueryContext(ctx, query, userID, userID, userID)
|
||||
rows, err := db.QueryContext(ctx, query, userID, userID, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,8 +87,9 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
|
||||
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||
? AS role,
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
|
||||
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), projectID)
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1,
|
||||
CASE WHEN p.owner_id = ? THEN p.group_id END
|
||||
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), userID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,17 +107,18 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
|
||||
|
||||
func scanProject(rows *sql.Rows) (Project, error) {
|
||||
var p Project
|
||||
var icon sql.NullString
|
||||
var icon, groupID sql.NullString
|
||||
var isInbox, archived int
|
||||
var created, updated int64
|
||||
var role string
|
||||
|
||||
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
|
||||
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount)
|
||||
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &groupID)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.Icon = icon.String
|
||||
p.GroupID = groupID.String
|
||||
p.IsInbox = isInbox == 1
|
||||
p.Archived = archived == 1
|
||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||
@@ -328,15 +336,27 @@ type Member struct {
|
||||
// project_members row — ownership is the projects table — so they are unioned in
|
||||
// rather than left out of the list of people who can see it.
|
||||
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
||||
// The owner first, then everybody else by name.
|
||||
//
|
||||
// The ordering is a column rather than an expression, and the whole compound
|
||||
// select is wrapped: SQLite only accepts ORDER BY terms from the result set
|
||||
// of a UNION, so `ORDER BY role = 'owner' DESC` — which reads perfectly well
|
||||
// — fails at query time with "1st ORDER BY term does not match any column in
|
||||
// the result set". Every call errored, so the share panel had never listed
|
||||
// anybody.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
|
||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||
WHERE p.id = ?
|
||||
UNION ALL
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
|
||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.project_id = ?
|
||||
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
|
||||
SELECT id, email, name, avatar_color, role, added_at FROM (
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role,
|
||||
p.created_at AS added_at, 0 AS rank
|
||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||
WHERE p.id = ?
|
||||
UNION ALL
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role,
|
||||
m.added_at, 1 AS rank
|
||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.project_id = ?
|
||||
)
|
||||
ORDER BY rank, name`, projectID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -369,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
|
||||
|
||||
@@ -123,6 +123,23 @@ func (db *DB) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserSession ends one session, and only if it belongs to the caller.
|
||||
//
|
||||
// Scoped in the statement rather than checked first: an id read and then deleted
|
||||
// in two steps is a window, and the whole point of this call is that somebody is
|
||||
// using it because they think another person is in their account.
|
||||
func (db *DB) DeleteUserSession(ctx context.Context, sessionID, userID string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
||||
return err
|
||||
|
||||
+29
-4
@@ -48,8 +48,14 @@ type TaskFilter struct {
|
||||
// renders them nested under their parents rather than as separate rows.
|
||||
TopLevelOnly bool
|
||||
AssigneeID string
|
||||
LabelID string
|
||||
Priority int
|
||||
// DelegatedBy is the other side of AssigneeID: assigned, but not to this
|
||||
// person. A second field rather than a magic value in AssigneeID, because the
|
||||
// two are different questions — "what is on my plate" and "what have I handed
|
||||
// over" — and one string that means an id most of the time and a sentinel the
|
||||
// rest is the kind of field somebody eventually passes a real id to.
|
||||
DelegatedBy string
|
||||
LabelID string
|
||||
Priority int
|
||||
|
||||
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
|
||||
DueBefore string
|
||||
@@ -116,6 +122,10 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
where = append(where, `t.assignee_id = ?`)
|
||||
args = append(args, f.AssigneeID)
|
||||
}
|
||||
if f.DelegatedBy != "" {
|
||||
where = append(where, `t.assignee_id IS NOT NULL AND t.assignee_id <> ?`)
|
||||
args = append(args, f.DelegatedBy)
|
||||
}
|
||||
if f.Priority > 0 {
|
||||
where = append(where, `t.priority = ?`)
|
||||
args = append(args, f.Priority)
|
||||
@@ -136,8 +146,23 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
args = append(args, f.DueFrom)
|
||||
}
|
||||
if expr := MatchExpr(f.Search); expr != "" {
|
||||
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
|
||||
args = append(args, expr)
|
||||
// Text or a label. A label is how you find a task whose wording you have
|
||||
// forgotten — "that thing about the accounts" is a search for @regnskab —
|
||||
// and leaving them out made the box look like it had lost the task.
|
||||
//
|
||||
// The label side is a plain lowercase match rather than another FTS index:
|
||||
// labels are short, and their names are typed by the person searching for
|
||||
// them, so there is no spelling to be generous about. The diacritic
|
||||
// folding that matters for task text does not earn a second index here.
|
||||
where = append(where, `(
|
||||
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
||||
OR t.id IN (
|
||||
SELECT tl.task_id FROM task_labels tl
|
||||
JOIN labels l ON l.id = tl.label_id
|
||||
WHERE lower(l.name) LIKE ?
|
||||
)
|
||||
)`)
|
||||
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
||||
}
|
||||
if f.FilterSQL != "" {
|
||||
where = append(where, "("+f.FilterSQL+")")
|
||||
|
||||
@@ -122,6 +122,202 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Person is the public half of a user: what somebody else is allowed to see when
|
||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||
type Person struct {
|
||||
ID string
|
||||
Name string
|
||||
AvatarColor string
|
||||
}
|
||||
|
||||
// PeopleByIDs looks up several users at once.
|
||||
//
|
||||
// One query rather than a loop of UserByID, because the caller is resolving the
|
||||
// assignees of a list of tasks and a per-row lookup is a query per row. Unknown
|
||||
// ids are simply absent from the result; a user who has been deleted is not an
|
||||
// error for a view that is only trying to put a name on a row.
|
||||
func (db *DB) PeopleByIDs(ctx context.Context, ids []string) (map[string]Person, error) {
|
||||
people := map[string]Person{}
|
||||
if len(ids) == 0 {
|
||||
return people, nil
|
||||
}
|
||||
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, name, avatar_color FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var p Person
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
people[p.ID] = p
|
||||
}
|
||||
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
|
||||
|
||||
@@ -7,11 +7,7 @@ import { test as setup, expect } from '@playwright/test';
|
||||
* or the app shell is broken, nothing below runs and the failure names this file.
|
||||
*/
|
||||
|
||||
export const USER = {
|
||||
name: 'Kristian',
|
||||
email: 'test@example.dk',
|
||||
password: 'et langt kodeord til test'
|
||||
};
|
||||
import { USER } from './user.js';
|
||||
|
||||
const AUTH_FILE = 'e2e/.auth/user.json';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USER } from './user.js';
|
||||
|
||||
/**
|
||||
* The four flows a broken build must not survive: add a task, tick it off, reach
|
||||
@@ -152,6 +153,10 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
'Integrationer',
|
||||
'AI',
|
||||
'API-tokens',
|
||||
// Only an administrator sees these two, and the account these tests run as
|
||||
// is the first account, which is one.
|
||||
'Brugere',
|
||||
'Fejl',
|
||||
'Data og skabeloner'
|
||||
]) {
|
||||
await tabs.getByRole('link', { name: label, exact: true }).click();
|
||||
@@ -179,6 +184,12 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
||||
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
|
||||
}
|
||||
|
||||
// Safari never reads the manifest's icons; it looks for this by name, and
|
||||
// without it an installed PWA on iOS gets a screenshot of the page as its
|
||||
// home-screen icon.
|
||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||
|
||||
// The service worker is what Web Push needs; without it a subscription cannot
|
||||
// be created at all.
|
||||
const worker = await request.get('/sw.js');
|
||||
@@ -227,6 +238,572 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A group can be made, folded, filled and deleted without taking its projects
|
||||
* with it.
|
||||
*
|
||||
* The fold is the part worth driving in a browser: it is stored on the account
|
||||
* rather than in localStorage, so it is a round trip, and "the arrow turned" is
|
||||
* not the same claim as "it came back folded after a reload".
|
||||
*/
|
||||
test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne med', 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('Regnskab');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
|
||||
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
// `exact`, because the heading's colour dot is a button too and its label is
|
||||
// "Farve på Arbejde" — and Playwright matches an accessible name by substring
|
||||
// unless told otherwise.
|
||||
const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true });
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||
|
||||
// The words in the heading must not sit on top of each other.
|
||||
//
|
||||
// Everything else here clicks by accessible name, which is exactly why this
|
||||
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
|
||||
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
|
||||
// Both buttons still worked, so every assertion in this file passed.
|
||||
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
|
||||
[...el.querySelectorAll('button')].map((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
return { text: b.textContent.trim(), left: r.left, right: r.right };
|
||||
})
|
||||
);
|
||||
for (let i = 1; i < boxes.length; i++) {
|
||||
expect(
|
||||
boxes[i].left,
|
||||
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
|
||||
).toBeGreaterThanOrEqual(boxes[i - 1].right);
|
||||
}
|
||||
|
||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||
// that heading is a drop target at all: with every project filed away there
|
||||
// would otherwise be no loose row left to aim at.
|
||||
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeHidden();
|
||||
|
||||
await sidebar
|
||||
.getByRole('link', { name: 'Regnskab' })
|
||||
.dragTo(sidebar.getByRole('heading', { name: 'Projekter' }));
|
||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||
|
||||
// Folded, reloaded, still folded. localStorage would have passed the first
|
||||
// half of that and failed the second on another machine.
|
||||
await heading.click();
|
||||
await expect(heading).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.reload();
|
||||
await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
);
|
||||
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.
|
||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
||||
const name = sidebar.getByLabel('Gruppens navn');
|
||||
await name.fill('Kontoret');
|
||||
await name.press('Enter');
|
||||
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', 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.
|
||||
*
|
||||
* Both are one gesture standing in for a form, and both cross a component
|
||||
* boundary: the row is dragged out of Kommende and dropped on the sidebar, which
|
||||
* is a different part of the tree entirely. What decides whether a target will
|
||||
* take it is the drag's MIME type, and that is only readable while the drag is in
|
||||
* the air — so this is the one thing here that cannot be checked any other way.
|
||||
*/
|
||||
test('en opgave kan trækkes til en anden dag og videre til et projekt', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('hent pakken i dag');
|
||||
await box.press('Enter');
|
||||
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
|
||||
const day = (name) =>
|
||||
page.locator('section').filter({ has: page.getByRole('heading', { name, exact: true }) });
|
||||
const row = page.locator('[draggable="true"]').filter({ hasText: 'hent pakken' });
|
||||
|
||||
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
|
||||
await row.dragTo(day('I morgen'));
|
||||
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
|
||||
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
|
||||
|
||||
// Onto "I dag" in the sidebar: the most-made rescheduling there is, and the
|
||||
// sidebar is where the pointer already is. "Kommende" deliberately does not
|
||||
// take one — it is a range, not a day, so a drop would have to invent a date
|
||||
// its label does not name.
|
||||
await row.dragTo(sidebar.getByRole('link', { name: 'I dag' }));
|
||||
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
|
||||
await row.dragTo(day('I morgen'));
|
||||
|
||||
// And onto a project, which is a move rather than a field: the task has to
|
||||
// find a place among that project's tasks, and leave its old section behind.
|
||||
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
|
||||
await sidebar.getByRole('link', { name: 'Ærinder' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Ærinder' })).toBeVisible();
|
||||
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
|
||||
|
||||
// And the same task in the month grid, where a chip is dragged from one cell
|
||||
// to another. The date is read off the grid rather than worked out here: the
|
||||
// browser is pinned to Europe/Copenhagen and this process is not, and for two
|
||||
// hours of every day they disagree about what today is.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Kalender' }).click();
|
||||
|
||||
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
|
||||
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
||||
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
|
||||
await expect(page.locator(`[data-date="${from}"]`).getByText('hent pakken')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* An invite link creates the account it was sent to.
|
||||
*
|
||||
* The server has emailed this URL since invites were built and nothing rendered
|
||||
* for it: the SPA fallback served the shell, the shell found no session, and the
|
||||
* recipient got a plain sign-in form for an account that did not exist yet. A 200
|
||||
* all the way down — the same shape as `/mcp` and `/.well-known/`, and invisible
|
||||
* to every test that does not open the link.
|
||||
*
|
||||
* A second browser context, because the invitee is a different person: inheriting
|
||||
* the signed-in session would test the one case that never happens.
|
||||
*/
|
||||
test('et invitationslink opretter kontoen og giver adgang til projektet', 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ælleshuset');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await page.getByLabel('Inviter via e-mail').fill('nabo@example.dk');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
|
||||
// With no mail server the link is shown rather than swallowed, which is also
|
||||
// the only way this test can get hold of it.
|
||||
const link = await page.locator('.link-out code').textContent();
|
||||
expect(link).toContain('/invite?token=');
|
||||
|
||||
// Explicitly empty, because a context made through the `browser` fixture picks
|
||||
// up the project's storageState — and an invitee who is already signed in as
|
||||
// the person who sent the invite is the one case that never happens.
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const invitee = await context.newPage();
|
||||
const inviteeTrouble = watchForTrouble(invitee);
|
||||
await invitee.goto(link);
|
||||
|
||||
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
|
||||
await invitee.getByLabel('Navn').fill('Nabo');
|
||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
// Signed in, with the project they were invited to.
|
||||
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(
|
||||
invitee.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Fælleshuset/ })
|
||||
).toBeVisible();
|
||||
|
||||
// One failure here is not trouble: the page loads before the account exists, so
|
||||
// the shell's first `me()` is answered 401. That 401 is how the signed-out
|
||||
// screen appears at all.
|
||||
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
||||
await context.close();
|
||||
|
||||
// The invitee opens the project. Nothing has ever driven this: the assertion
|
||||
// above only proved the project reaches their *sidebar*, and a member's view
|
||||
// of a project page is a different code path from an owner's — different role,
|
||||
// different guards, and the page shows the same "findes ikke" message while it
|
||||
// is still loading as it does when the load failed.
|
||||
const inviteePage = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const member = await inviteePage.newPage();
|
||||
const memberTrouble = watchForTrouble(member);
|
||||
await member.goto('/');
|
||||
await member.getByLabel('E-mail').fill('nabo@example.dk');
|
||||
await member.getByLabel('Adgangskode').fill('et langt kodeord til test');
|
||||
await member.getByRole('button', { name: 'Log ind' }).click();
|
||||
|
||||
const theirs = member.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await theirs.getByRole('link', { name: /Fælleshuset/ }).click();
|
||||
await expect(member.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||
await expect(member.getByText('findes ikke')).toHaveCount(0);
|
||||
expect(memberTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
||||
await inviteePage.close();
|
||||
|
||||
// Now that there is somebody to delegate to, the inviter can — and the view
|
||||
// for it appears. It is hidden on an instance with one person, where it could
|
||||
// never have anything in it.
|
||||
await page.reload();
|
||||
await expect(sidebar.getByRole('link', { name: 'Venter på andre' })).toBeVisible();
|
||||
|
||||
// A project the inviter owns stays under "Projekter" after sharing it. It used
|
||||
// to move to "Delt med mig" the moment somebody accepted, and lose its place in
|
||||
// the order on the way.
|
||||
await expect(
|
||||
page.locator('.group').filter({ hasText: 'Projekter' }).getByText('Fælleshuset')
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('males inden fredag', { exact: true }).click();
|
||||
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
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();
|
||||
// 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([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The reset link renders its own form.
|
||||
*
|
||||
* The token is nonsense, so this stops at the form — which is the part that did
|
||||
* not exist. "Glemt adgangskode?" has always sent a link to this address and the
|
||||
* API has always accepted the token; what the link opened was the sign-in screen,
|
||||
* asking for the password the person had just said they had forgotten.
|
||||
*/
|
||||
test('nulstillingslinket viser et felt til den nye adgangskode', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/reset?token=ugyldigt');
|
||||
await expect(page.getByText('Vælg en ny adgangskode')).toBeVisible();
|
||||
|
||||
await page.getByLabel(/adgangskode/i).fill('et langt kodeord til test');
|
||||
await page.getByRole('button', { name: 'Gem adgangskoden' }).click();
|
||||
|
||||
// And it says so rather than pretending: the token is not a real one.
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* The device list, and ending one of them.
|
||||
*
|
||||
* `last_seen_at` has been written on every request since sessions existed — once
|
||||
* a minute, deliberately, so this list could say "lige nu" — and until now
|
||||
* nothing read it. A session you cannot see is a session you cannot end, which
|
||||
* matters most in the one situation it exists for: somebody else has your cookie.
|
||||
*/
|
||||
test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ browser, page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// A second sign-in as the same person: a real second session, not a second tab
|
||||
// sharing this one's cookie.
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const elsewhere = await context.newPage();
|
||||
await elsewhere.goto('/');
|
||||
await elsewhere.getByLabel('E-mail').fill(USER.email);
|
||||
await elsewhere.getByLabel('Adgangskode').fill(USER.password);
|
||||
await elsewhere.getByRole('button', { name: 'Log ind' }).click();
|
||||
await expect(elsewhere.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
await page.goto('/indstillinger');
|
||||
const panel = page.locator('section.panel').filter({ hasText: 'Enheder' });
|
||||
await expect(panel.getByText('denne enhed')).toBeVisible();
|
||||
await expect(panel.locator('li')).toHaveCount(2);
|
||||
|
||||
// End the one that is not this browser — the row without the badge.
|
||||
await panel.locator('li').filter({ hasNot: page.getByText('denne enhed') })
|
||||
.getByRole('button', { name: 'Log ud' })
|
||||
.click();
|
||||
await expect(panel.locator('li')).toHaveCount(1);
|
||||
|
||||
// That session is really gone, and this one is untouched.
|
||||
await elsewhere.reload();
|
||||
await expect(elsewhere.getByText('Log ind for at fortsætte')).toBeVisible();
|
||||
await context.close();
|
||||
|
||||
await expect(panel.getByText('denne enhed')).toBeVisible();
|
||||
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('anders@example.dk');
|
||||
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('anders@example.dk')).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('andreas@example.dk');
|
||||
await page.getByLabel('Rolle').selectOption('viewer');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
const link = await page.locator('.link-out code').textContent();
|
||||
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const andreas = await context.newPage();
|
||||
await andreas.goto(link);
|
||||
await andreas.getByLabel('Navn').fill('andreas');
|
||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
await andreas
|
||||
.getByRole('navigation', { name: 'Hovedmenu' })
|
||||
.getByRole('link', { name: /Fælles/ })
|
||||
.click();
|
||||
// A viewer sees the project and is told, plainly, that it is read-only.
|
||||
await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible();
|
||||
await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0);
|
||||
|
||||
// The owner promotes them from the member list. The owner's own row is text,
|
||||
// not a dropdown: ownership is transferred, not granted.
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Delt/ }).click();
|
||||
await expect(page.getByText('Ejer')).toBeVisible();
|
||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||
|
||||
await andreas.reload();
|
||||
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
|
||||
|
||||
// Now that they can edit, hand them something — and the row says whose it is.
|
||||
// Only when it is somebody else's: your own list would otherwise carry your own
|
||||
// face on every line, which is a column of one initial telling you nothing.
|
||||
await page.getByLabel('Ny opgave').fill('brænde kaffe');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('brænde kaffe', { exact: true }).click();
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
||||
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
|
||||
// And not on a task that is nobody's.
|
||||
await page.getByLabel('Ny opgave').fill('min egen');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(
|
||||
page.locator('.row').filter({ hasText: 'min egen' }).locator('.assignee')
|
||||
).toHaveCount(0);
|
||||
|
||||
// The log has recorded all of this since the beginning and nothing showed it.
|
||||
await page.getByRole('button', { name: 'Historik' }).click();
|
||||
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
||||
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The account every test runs as.
|
||||
*
|
||||
* In its own file rather than exported from auth.setup.js, because Playwright
|
||||
* refuses to let one test file import another — and both the setup that creates
|
||||
* this account and the device-list test that signs in as it a second time need
|
||||
* the same credentials.
|
||||
*/
|
||||
export const USER = {
|
||||
name: 'Kristian',
|
||||
email: 'test@example.dk',
|
||||
password: 'et langt kodeord til test'
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Renders static/icon.svg to the PNGs the manifest and iOS need.
|
||||
*
|
||||
* node scripts/generate-icons.js
|
||||
*
|
||||
* A script rather than a test, so CI never runs it and never rewrites a tracked
|
||||
* binary. Run it when the icon changes; the smoke test fetches every icon the
|
||||
* manifest names, so a reference with no file behind it fails there.
|
||||
*
|
||||
* It drives Chromium because there is no SVG renderer on this machine — which is
|
||||
* why these references were once removed rather than left pointing at files that
|
||||
* did not exist. Playwright is a renderer, and it is already a dependency.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const SIZES = [
|
||||
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
|
||||
{ file: 'static/icon-192.png', size: 192 },
|
||||
{ file: 'static/icon-512.png', size: 512 },
|
||||
// And the one Safari wants by name. It ignores the manifest's icons entirely
|
||||
// and looks for this, which is why an installed PWA on iOS otherwise gets a
|
||||
// screenshot of the page as its home-screen icon.
|
||||
{ file: 'static/apple-touch-icon.png', size: 180 }
|
||||
];
|
||||
|
||||
const svg = readFileSync('static/icon.svg', 'utf8');
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
for (const { file, size } of SIZES) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
||||
);
|
||||
writeFileSync(file, await page.locator('svg').screenshot());
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
+191
-5
@@ -22,7 +22,10 @@
|
||||
|
||||
--ink: #e8ebe9;
|
||||
--ink-muted: #9aa39e;
|
||||
--ink-faint: #6b736f;
|
||||
/* The quiet tier, and the one that decides whether the interface is
|
||||
readable: dates, counts and hints all live here. Lifted from #6b736f,
|
||||
which sat at 3.8:1 against the ground — under AA, and it showed. */
|
||||
--ink-faint: #828a84;
|
||||
|
||||
/* Nordlys-grøn. One accent, used for the checkmark, focus rings, and the
|
||||
single primary action on a screen — and for nothing else, which is what
|
||||
@@ -42,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. */
|
||||
@@ -87,7 +118,160 @@
|
||||
--content-max: 720px;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
/*
|
||||
* The dark palette again, by name.
|
||||
*
|
||||
* :root carries it as the default, and :root only ever matches <html> — so a
|
||||
* swatch in the settings picker asking for data-theme="dark" would otherwise
|
||||
* inherit whatever the page is wearing. Repeated rather than restructured: the
|
||||
* alternative is splitting the token block in two and leaving somebody to work
|
||||
* out which half a new token belongs in.
|
||||
*/
|
||||
[data-theme='dark'] {
|
||||
--ground: #111312;
|
||||
--surface: #171a19;
|
||||
--surface-raised: #1d211f;
|
||||
--surface-sunken: #0d0f0e;
|
||||
--line: #262b29;
|
||||
--line-strong: #343a37;
|
||||
|
||||
--ink: #e8ebe9;
|
||||
--ink-muted: #9aa39e;
|
||||
/* The quiet tier, and the one that decides whether the interface is
|
||||
readable: dates, counts and hints all live here. Lifted from #6b736f,
|
||||
which sat at 3.8:1 against the ground — under AA, and it showed. */
|
||||
--ink-faint: #828a84;
|
||||
|
||||
--accent: #5fbf8f;
|
||||
--accent-hover: #6fcf9f;
|
||||
--accent-sunken: #1c3329;
|
||||
--accent-ink: #0d1411;
|
||||
|
||||
--p1: #cf6b6b;
|
||||
--p2: #d69f5c;
|
||||
--p3: #6b9ecf;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #cf6b6b;
|
||||
--danger-sunken: #33201f;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
/*
|
||||
* The other themes.
|
||||
*
|
||||
* Each one redefines colour and nothing else: no spacing, no type, no radii.
|
||||
* That is what keeps them themes rather than five separate designs, and it is
|
||||
* why a component that reaches for a raw hex breaks in four places at once
|
||||
* instead of one.
|
||||
*
|
||||
* Every theme states its full palette rather than inheriting the dark one and
|
||||
* patching it. A partial theme looks finished until somebody adds a token, at
|
||||
* which point four themes silently fall back to a colour from a fifth.
|
||||
*/
|
||||
|
||||
/* Skumring — the same dark, turned warm, with amber where the green was. */
|
||||
[data-theme='dusk'] {
|
||||
--ground: #14110f;
|
||||
--surface: #1b1714;
|
||||
--surface-raised: #221d19;
|
||||
--surface-sunken: #100d0b;
|
||||
--line: #2b2521;
|
||||
--line-strong: #3a322c;
|
||||
|
||||
--ink: #ece6df;
|
||||
--ink-muted: #a3988c;
|
||||
/* Light enough to clear 4.5:1 against the ground. The quiet tier is where
|
||||
dates and counts live, and it is the first thing to disappear. */
|
||||
--ink-faint: #8d8377;
|
||||
|
||||
--accent: #d99a4e;
|
||||
--accent-hover: #e8a95c;
|
||||
--accent-sunken: #33261480;
|
||||
--accent-ink: #16110a;
|
||||
|
||||
--p1: #cf6b6b;
|
||||
--p2: #d69f5c;
|
||||
--p3: #6b9ecf;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #cf6b6b;
|
||||
--danger-sunken: #33201f;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Papir — warm light, closer to paper than to a screen. */
|
||||
[data-theme='paper'] {
|
||||
--ground: #f6f1e7;
|
||||
--surface: #fffcf6;
|
||||
--surface-raised: #fffcf6;
|
||||
--surface-sunken: #ece5d7;
|
||||
--line: #ded5c3;
|
||||
--line-strong: #c8bda6;
|
||||
|
||||
--ink: #241f18;
|
||||
--ink-muted: #665c4c;
|
||||
--ink-faint: #756a55;
|
||||
|
||||
--accent: #8a5a2b;
|
||||
--accent-hover: #74491f;
|
||||
--accent-sunken: #ebdfcb;
|
||||
--accent-ink: #fffcf6;
|
||||
|
||||
--p1: #b0452f;
|
||||
--p2: #96701f;
|
||||
--p3: #3f6b8a;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #b0452f;
|
||||
--danger-sunken: #f3e2dc;
|
||||
|
||||
--shadow: 0 1px 2px rgb(60 40 10 / 0.08);
|
||||
--shadow-lg: 0 8px 32px rgb(60 40 10 / 0.14);
|
||||
}
|
||||
|
||||
/*
|
||||
* Kontrast — for reading in bright sun, or for eyes that the quiet greys of the
|
||||
* other four do not serve. Pure black on white, borders that are actually
|
||||
* visible, and an accent dark enough to pass against white text.
|
||||
*
|
||||
* The muted inks are deliberately much darker than elsewhere: "secondary text"
|
||||
* at 60% grey is the first thing to disappear, and it is where dates live.
|
||||
*/
|
||||
[data-theme='contrast'] {
|
||||
--ground: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #f2f2f2;
|
||||
--surface-sunken: #e8e8e8;
|
||||
--line: #767676;
|
||||
--line-strong: #1a1a1a;
|
||||
|
||||
--ink: #000000;
|
||||
--ink-muted: #2b2b2b;
|
||||
--ink-faint: #4a4a4a;
|
||||
|
||||
--accent: #005c2e;
|
||||
--accent-hover: #00753a;
|
||||
--accent-sunken: #cfe8da;
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--p1: #a10000;
|
||||
--p2: #7a4a00;
|
||||
--p3: #00417a;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #a10000;
|
||||
--danger-sunken: #f7dcdc;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
--ground: #faf9f7;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
@@ -97,10 +281,12 @@
|
||||
|
||||
--ink: #1c201e;
|
||||
--ink-muted: #5f6763;
|
||||
--ink-faint: #8b918d;
|
||||
/* Was #8b918d at 3.1:1 — the worst of the five, and the theme most likely
|
||||
to be read in daylight. */
|
||||
--ink-faint: #6e756f;
|
||||
|
||||
--accent: #2f8f63;
|
||||
--accent-hover: #277a54;
|
||||
--accent: #257552;
|
||||
--accent-hover: #1f6a49;
|
||||
--accent-sunken: #e2f0e9;
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<!-- Safari ignores the manifest's icons entirely and looks for this by name.
|
||||
Without it an installed PWA on iOS gets a screenshot of the page as its
|
||||
home-screen icon. Generated from the same SVG by scripts/generate-icons.js. -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>verdande</title>
|
||||
<script>
|
||||
// Applied before first paint, so a dark-theme user never sees a white
|
||||
|
||||
+24
-1
@@ -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.'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,6 +169,17 @@ export const api = {
|
||||
totpSetup: () => post('/auth/totp/setup'),
|
||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
||||
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}`),
|
||||
listErrors: () => get('/errors'),
|
||||
|
||||
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||
|
||||
@@ -180,6 +193,12 @@ export const api = {
|
||||
listTrashedProjects: () => get('/trash/projects'),
|
||||
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
||||
|
||||
listProjectGroups: () => get('/project-groups'),
|
||||
createProjectGroup: (name) => post('/project-groups', { name }),
|
||||
updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data),
|
||||
deleteProjectGroup: (id) => del(`/project-groups/${id}`),
|
||||
reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }),
|
||||
|
||||
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
||||
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
||||
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
||||
@@ -187,6 +206,8 @@ export const api = {
|
||||
|
||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
||||
setMemberRole: (projectId, userId, role) =>
|
||||
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
||||
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
||||
activity: (projectId) => get(`/projects/${projectId}/activity`),
|
||||
|
||||
@@ -286,5 +307,7 @@ export const api = {
|
||||
// --- views
|
||||
today: () => get('/today'),
|
||||
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
|
||||
delegated: () => get('/delegated'),
|
||||
people: () => get('/people'),
|
||||
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
|
||||
let { project, sections, canEdit } = $props();
|
||||
@@ -24,23 +25,30 @@
|
||||
...sections.map((s) => ({ id: s.id, name: s.name }))
|
||||
]);
|
||||
|
||||
// Scoped to this project, not just to the section: the store holds whatever the
|
||||
// last view loaded, and a task that has left the project would otherwise turn
|
||||
// up in the "no section" column, which is exactly where a task with no section
|
||||
// belongs — in some other project.
|
||||
const tasksIn = (sectionId) =>
|
||||
app.tasks
|
||||
.filter((t) => !t.completed && !t.parent_id && (t.section_id ?? '') === sectionId)
|
||||
.filter(
|
||||
(t) =>
|
||||
!t.completed &&
|
||||
!t.parent_id &&
|
||||
t.project_id === project.id &&
|
||||
(t.section_id ?? '') === sectionId
|
||||
)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
function onDragStart(event, task) {
|
||||
if (!canEdit) return;
|
||||
draggingId = task.id;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
||||
event.dataTransfer.setData('text/plain', task.id);
|
||||
startDrag(event, TASK, task.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, sectionId) {
|
||||
if (!canEdit || !draggingId) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (!canEdit || !carries(event, TASK) || !draggingId) return;
|
||||
accept(event);
|
||||
overColumn = sectionId;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,32 @@
|
||||
* misread the whole month at a glance.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
|
||||
let { onselect } = $props();
|
||||
let {
|
||||
/**
|
||||
* What a chip does when clicked. Opening the task is what it should do
|
||||
* everywhere, so it is the default rather than something each caller has to
|
||||
* remember — the project page did not, and its chips were dead for as long
|
||||
* as the month view has existed.
|
||||
*/
|
||||
onselect = (task) => app.openDetail(task.id),
|
||||
/**
|
||||
* Told which dates the grid is showing, whenever that changes.
|
||||
*
|
||||
* A project's month view needs nothing: the page has already loaded every
|
||||
* task in the project. Kommende is the other case — it cannot load every
|
||||
* task anybody has ever dated — so it asks for the month on screen, and
|
||||
* this is how it learns which one that is.
|
||||
*/
|
||||
onrange,
|
||||
/**
|
||||
* Narrows the grid to one project. The store holds whatever the last view
|
||||
* loaded, so a project's month has to say which project it is — Kommende,
|
||||
* which shows every project, is the one that leaves this out.
|
||||
*/
|
||||
projectId = null
|
||||
} = $props();
|
||||
|
||||
let cursor = $state(startOfMonth(new Date()));
|
||||
|
||||
@@ -41,10 +65,23 @@
|
||||
return days;
|
||||
});
|
||||
|
||||
// Reported after the grid is worked out rather than from the cursor, because
|
||||
// the grid runs whole weeks: a month view nearly always shows a few days of the
|
||||
// month before it and a few of the one after, and a caller loading only the
|
||||
// month itself would leave those corners empty.
|
||||
$effect(() => {
|
||||
onrange?.({ from: iso(grid[0]), to: iso(grid[grid.length - 1]) });
|
||||
});
|
||||
|
||||
const todayISO = iso(new Date());
|
||||
|
||||
const tasksOn = (date) =>
|
||||
app.tasks.filter((t) => !t.completed && t.due_date === iso(date));
|
||||
app.tasks.filter(
|
||||
(t) =>
|
||||
!t.completed &&
|
||||
t.due_date === iso(date) &&
|
||||
(!projectId || t.project_id === projectId)
|
||||
);
|
||||
|
||||
const monthName = $derived(
|
||||
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
|
||||
@@ -53,6 +90,25 @@
|
||||
function step(months) {
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1);
|
||||
}
|
||||
|
||||
// --- dropping a task on a day ---------------------------------------------------
|
||||
|
||||
/** The cell lit up under the pointer. */
|
||||
let over = $state(null);
|
||||
|
||||
function onDragOver(event, date) {
|
||||
if (!carries(event, TASK)) return;
|
||||
accept(event);
|
||||
over = iso(date);
|
||||
}
|
||||
|
||||
async function onDrop(event, date) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
over = null;
|
||||
if (!id) return;
|
||||
await app.reschedule(id, iso(date));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="calendar">
|
||||
@@ -73,11 +129,32 @@
|
||||
{#each grid as date (date.toISOString())}
|
||||
{@const tasks = tasksOn(date)}
|
||||
{@const outside = date.getMonth() !== cursor.getMonth()}
|
||||
<div class="day" class:outside class:today={iso(date) === todayISO}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day"
|
||||
data-date={iso(date)}
|
||||
class:outside
|
||||
class:today={iso(date) === todayISO}
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDrop(e, date)}
|
||||
>
|
||||
<span class="number">{date.getDate()}</span>
|
||||
|
||||
{#each tasks.slice(0, 3) as task (task.id)}
|
||||
<button class="chip" data-priority={task.priority} onclick={() => onselect?.(task)}>
|
||||
<!-- Draggable as well as clickable: within a month, moving something
|
||||
to another day is the whole reason to be looking at a month. The
|
||||
browser needs a few pixels of movement before it calls a press a
|
||||
drag, so the click survives. -->
|
||||
<button
|
||||
class="chip"
|
||||
data-priority={task.priority}
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
ondragend={() => (over = null)}
|
||||
onclick={() => onselect?.(task)}
|
||||
>
|
||||
{task.content}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -120,7 +197,12 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.today {
|
||||
/* Scoped to the header, because `.today` is not only this button: the day cell
|
||||
for today carries the same class, and an unscoped `margin-left: auto` made a
|
||||
grid item shrink to its content and sit against the right of its column. The
|
||||
month always had one cell narrower than the other six, on every project
|
||||
calendar, and it read as a grid that had not quite laid out yet. */
|
||||
header .today {
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
padding: 0 var(--s3);
|
||||
@@ -178,6 +260,13 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* The whole cell, because the whole cell is the target: a day is a box, not a
|
||||
gap between two boxes. */
|
||||
.day.over {
|
||||
background: var(--surface-raised);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.day.today .number {
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script>
|
||||
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';
|
||||
|
||||
@@ -27,21 +29,89 @@
|
||||
let filters = $state([]);
|
||||
let labels = $state([]);
|
||||
|
||||
// Reads app.labelsChanged so the effect re-runs when a label is created,
|
||||
// renamed or deleted anywhere — including in another tab, or by Claude
|
||||
// through the connector.
|
||||
$effect(() => {
|
||||
app.labelsChanged;
|
||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||
});
|
||||
|
||||
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
|
||||
// Split by who owns it, not by whether anybody else is in it.
|
||||
//
|
||||
// The two headings say "Projekter" and "Delt med mig", and the split used to be
|
||||
// on `shared`, which only means the project has more than one member. So the
|
||||
// moment you invited somebody to a project of your own it moved under "Delt med
|
||||
// mig" — where it lost its place in the order, stopped being draggable, and
|
||||
// could not be filed under a group. Sharing your own project is not the same
|
||||
// event as somebody sharing theirs with you.
|
||||
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.role !== 'owner'));
|
||||
// Sorted here rather than relying on the order the server sent, so a drag
|
||||
// settles the moment it is dropped instead of after the round trip.
|
||||
let own = $derived(
|
||||
app.projects
|
||||
.filter((p) => !p.is_inbox && !p.shared)
|
||||
.filter((p) => !p.is_inbox && p.role === 'owner')
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
);
|
||||
let current = $derived($page.url.pathname);
|
||||
|
||||
// --- groups -------------------------------------------------------------------
|
||||
|
||||
let groups = $derived([...app.groups].sort((a, b) => a.sort_order - b.sort_order));
|
||||
let groupIds = $derived(new Set(groups.map((g) => g.id)));
|
||||
|
||||
/**
|
||||
* Which heading a project renders under — '' for the loose ones at the top.
|
||||
*
|
||||
* It is not simply `group_id`, and the difference is not paranoia: a group
|
||||
* deleted in another tab arrives as one event, without a second one per
|
||||
* project, so for a moment the projects here still name a heading that no
|
||||
* longer renders. Filing by the raw id would drop them out of the sidebar
|
||||
* entirely — under a group that is not drawn, which is nowhere.
|
||||
*/
|
||||
const bucketOf = (p) => (p.group_id && groupIds.has(p.group_id) ? p.group_id : '');
|
||||
|
||||
let ungrouped = $derived(own.filter((p) => bucketOf(p) === ''));
|
||||
const inGroup = (id) => own.filter((p) => p.group_id === id);
|
||||
|
||||
let addingGroup = $state(false);
|
||||
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();
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
newGroupName = '';
|
||||
addingGroup = false;
|
||||
await app.createGroup(name);
|
||||
}
|
||||
|
||||
async function renameGroup(event, group) {
|
||||
event.preventDefault();
|
||||
const name = groupName.trim();
|
||||
renamingGroup = null;
|
||||
if (!name || name === group.name) return;
|
||||
await app.renameGroup(group.id, name);
|
||||
}
|
||||
|
||||
async function removeGroup(group) {
|
||||
const count = inGroup(group.id).length;
|
||||
// Says what happens to the projects, because "slet gruppen" reads like it
|
||||
// might take them with it. It does not: only the heading goes.
|
||||
const kept =
|
||||
count === 1
|
||||
? 'Projektet i den bliver, men ligger bagefter uden gruppe.'
|
||||
: `De ${count} projekter bliver, men ligger bagefter uden gruppe.`;
|
||||
if (!confirm(count ? `Slet gruppen "${group.name}"? ${kept}` : `Slet gruppen "${group.name}"?`))
|
||||
return;
|
||||
await app.deleteGroup(group.id);
|
||||
}
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
@@ -73,40 +143,185 @@
|
||||
let draggingId = $state(null);
|
||||
let overId = $state(null);
|
||||
let overBelow = $state(false);
|
||||
/** The group whose heading is lit up as a drop target. */
|
||||
let overGroup = $state(null);
|
||||
let draggingGroup = $state(null);
|
||||
|
||||
function clearDrag() {
|
||||
draggingId = null;
|
||||
draggingGroup = null;
|
||||
overId = null;
|
||||
overGroup = null;
|
||||
overProject = null;
|
||||
}
|
||||
|
||||
function onDragStart(event, project) {
|
||||
draggingId = project.id;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
||||
event.dataTransfer.setData('text/plain', project.id);
|
||||
startDrag(event, PROJECT, project.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, project) {
|
||||
if (!draggingId || draggingId === project.id) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (!carries(event, PROJECT) || draggingId === project.id) return;
|
||||
accept(event);
|
||||
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
overId = project.id;
|
||||
overGroup = null;
|
||||
overBelow = event.clientY > box.top + box.height / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropping onto a project puts the dragged one in that gap — and into the same
|
||||
* group, which is what makes dragging between two headings work without a
|
||||
* separate gesture. The order is one write and the group is another; each is
|
||||
* meaningful on its own, and neither can leave the other half-applied.
|
||||
*/
|
||||
async function onDrop(event, target) {
|
||||
event.preventDefault();
|
||||
const id = draggingId;
|
||||
const id = dragged(event, PROJECT) || draggingId;
|
||||
const below = overBelow;
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
clearDrag();
|
||||
if (!id || id === target.id) return;
|
||||
|
||||
const moved = own.find((p) => p.id === id);
|
||||
if (!moved) return;
|
||||
|
||||
const without = own.filter((p) => p.id !== id);
|
||||
const at = without.findIndex((p) => p.id === target.id);
|
||||
if (at < 0) return;
|
||||
|
||||
const ordered = [...without];
|
||||
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
|
||||
ordered.splice(below ? at + 1 : at, 0, moved);
|
||||
|
||||
await app.setProjectGroup(id, bucketOf(target));
|
||||
await app.reorderProjects(ordered.map((p) => p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropping onto a heading files the project under it and puts it last.
|
||||
*
|
||||
* Last rather than first: a heading is a wide target and dropping on it is the
|
||||
* coarse gesture — "somewhere in here" — where dropping on a row is the precise
|
||||
* one. Landing at the top would push whatever is already there down, which is a
|
||||
* rearrangement nobody asked for.
|
||||
*/
|
||||
async function onDropInGroup(event, groupID) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, PROJECT) || draggingId;
|
||||
clearDrag();
|
||||
if (!id) return;
|
||||
|
||||
const moved = own.find((p) => p.id === id);
|
||||
if (!moved) return;
|
||||
|
||||
await app.setProjectGroup(id, groupID);
|
||||
|
||||
// Everything else keeps the order it already had; the dragged project is
|
||||
// reinserted after the last one already under that heading.
|
||||
const others = own.filter((p) => p.id !== id);
|
||||
const ordered = [...others];
|
||||
ordered.splice(others.findLastIndex((p) => bucketOf(p) === groupID) + 1, 0, moved);
|
||||
await app.reorderProjects(ordered.map((p) => p.id));
|
||||
}
|
||||
|
||||
// --- a task dropped on "I dag" ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* "Gør det i dag" is the most-made rescheduling there is, and the sidebar is
|
||||
* where the pointer already goes.
|
||||
*
|
||||
* Only this one of the two date views. "Kommende" is a range rather than a
|
||||
* day — it starts today and runs a week — so a drop on it would have to invent
|
||||
* a date the label does not name. The month grid inside it takes drops per
|
||||
* cell, which is where a specific day is actually offered.
|
||||
*/
|
||||
let overToday = $state(false);
|
||||
|
||||
function todayISO() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
now.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function onDropOnToday(event) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
overToday = false;
|
||||
if (!id) return;
|
||||
await app.reschedule(id, todayISO());
|
||||
}
|
||||
|
||||
// --- a task dropped on a project ------------------------------------------------
|
||||
|
||||
/** The project row lit up because a task is hovering over it. */
|
||||
let overProject = $state(null);
|
||||
|
||||
/**
|
||||
* A viewer can open a project but not write to it, so it is not a place a task
|
||||
* can be dropped. Refusing here rather than letting the server refuse is the
|
||||
* difference between a row that does not light up and a task that visibly moves
|
||||
* and then jumps back.
|
||||
*/
|
||||
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
|
||||
|
||||
function onTaskDragOver(event, project) {
|
||||
if (!carries(event, TASK) || !canReceive(project)) return;
|
||||
accept(event);
|
||||
overProject = project.id;
|
||||
}
|
||||
|
||||
async function onTaskDrop(event, project) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
overProject = null;
|
||||
if (!id || !canReceive(project)) return;
|
||||
await app.moveToProject(id, project.id);
|
||||
}
|
||||
|
||||
function onGroupDragStart(event, group) {
|
||||
draggingGroup = group.id;
|
||||
startDrag(event, GROUP, group.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A heading takes two kinds of drop, so it has to say which one it is showing.
|
||||
* A project joins the group; another heading reorders against it.
|
||||
*/
|
||||
function onGroupDragOver(event, group) {
|
||||
if (carries(event, GROUP)) {
|
||||
if (draggingGroup === group.id) return;
|
||||
accept(event);
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
overId = group.id;
|
||||
overGroup = null;
|
||||
overBelow = event.clientY > box.top + box.height / 2;
|
||||
return;
|
||||
}
|
||||
if (!carries(event, PROJECT)) return;
|
||||
accept(event);
|
||||
overGroup = group.id;
|
||||
overId = null;
|
||||
}
|
||||
|
||||
async function onGroupDrop(event, group) {
|
||||
if (carries(event, GROUP)) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, GROUP) || draggingGroup;
|
||||
const below = overBelow;
|
||||
clearDrag();
|
||||
if (!id || id === group.id) return;
|
||||
|
||||
const without = groups.filter((g) => g.id !== id);
|
||||
const at = without.findIndex((g) => g.id === group.id);
|
||||
if (at < 0) return;
|
||||
const ordered = [...without];
|
||||
ordered.splice(below ? at + 1 : at, 0, groups.find((g) => g.id === id));
|
||||
await app.reorderGroups(ordered.map((g) => g.id));
|
||||
return;
|
||||
}
|
||||
await onDropInGroup(event, group.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" aria-label="Hovedmenu">
|
||||
@@ -119,7 +334,20 @@
|
||||
</div>
|
||||
|
||||
<div class="views">
|
||||
<a href="/" class:active={current === '/'} onclick={onnavigate}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<a
|
||||
href="/"
|
||||
class:active={current === '/'}
|
||||
class:receiving={overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={onDropOnToday}
|
||||
>
|
||||
<span class="dot today" aria-hidden="true"></span>
|
||||
I dag
|
||||
</a>
|
||||
@@ -127,22 +355,96 @@
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
Kommende
|
||||
</a>
|
||||
{#if app.inbox}
|
||||
<a
|
||||
href="/projekt/{app.inbox.id}"
|
||||
class:active={current === `/projekt/${app.inbox.id}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
{#if app.projects.some((p) => p.shared)}
|
||||
<!-- Only where delegating is possible at all. On an instance with one
|
||||
person this view can never have anything in it, and a permanent empty
|
||||
entry is a question the sidebar keeps asking and answering "no". -->
|
||||
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{app.inbox.name}
|
||||
Venter på andre
|
||||
</a>
|
||||
{/if}
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- One row for every project the sidebar shows — your own, the Inbox and the
|
||||
shared ones — because all three are somewhere a task can be dropped, and
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
Only your own reorder: `sort_order` is a column on the project, so a shared
|
||||
one stays where its owner put it. -->
|
||||
{#snippet projectRow(project, sortable)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
class:sortable
|
||||
class:active={current === `/projekt/${project.id}`}
|
||||
class:dragging={draggingId === project.id}
|
||||
class:drop-above={sortable && overId === project.id && !overBelow}
|
||||
class:drop-below={sortable && overId === project.id && overBelow}
|
||||
class:receiving={overProject === project.id}
|
||||
onclick={onnavigate}
|
||||
draggable={sortable}
|
||||
ondragstart={(e) => sortable && onDragStart(e, project)}
|
||||
ondragend={clearDrag}
|
||||
ondragover={(e) => {
|
||||
if (carries(e, TASK)) onTaskDragOver(e, project);
|
||||
else if (sortable) onDragOver(e, project);
|
||||
}}
|
||||
ondragleave={() => {
|
||||
overId = null;
|
||||
overProject = null;
|
||||
}}
|
||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{project.name}
|
||||
{#if project.shared}
|
||||
<span class="count">{project.member_count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div class="group">
|
||||
<div class="group-head">
|
||||
<!-- The heading is also the drop target for "no group": without it, a project
|
||||
dragged into a group could only be got out again by emptying the group,
|
||||
and with every project filed there would be no loose row left to aim at. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-head"
|
||||
class:over={overGroup === ''}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, PROJECT)) return;
|
||||
accept(e);
|
||||
overGroup = '';
|
||||
overId = null;
|
||||
}}
|
||||
ondragleave={() => (overGroup = null)}
|
||||
ondrop={(e) => onDropInGroup(e, '')}
|
||||
>
|
||||
<h2>Projekter</h2>
|
||||
<button onclick={() => (adding = !adding)} aria-label="Nyt projekt">+</button>
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => {
|
||||
adding = !adding;
|
||||
addingGroup = false;
|
||||
}}
|
||||
aria-label="Nyt projekt">+</button
|
||||
>
|
||||
<button
|
||||
class="icon new-group"
|
||||
onclick={() => {
|
||||
addingGroup = !addingGroup;
|
||||
adding = false;
|
||||
newGroupName = '';
|
||||
}}
|
||||
aria-label="Ny gruppe"
|
||||
title="Ny gruppe"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if adding}
|
||||
@@ -159,28 +461,22 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#each own as project (project.id)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
class="sortable"
|
||||
class:active={current === `/projekt/${project.id}`}
|
||||
class:dragging={draggingId === project.id}
|
||||
class:drop-above={overId === project.id && !overBelow}
|
||||
class:drop-below={overId === project.id && overBelow}
|
||||
onclick={onnavigate}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, project)}
|
||||
ondragend={() => {
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
}}
|
||||
ondragover={(e) => onDragOver(e, project)}
|
||||
ondragleave={() => (overId = null)}
|
||||
ondrop={(e) => onDrop(e, project)}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{project.name}
|
||||
</a>
|
||||
{#if addingGroup}
|
||||
<form onsubmit={createGroup}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={newGroupName}
|
||||
autofocus
|
||||
placeholder="Gruppens navn"
|
||||
aria-label="Gruppens navn"
|
||||
onblur={() => !newGroupName.trim() && (addingGroup = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#each ungrouped as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
|
||||
{#if own.length === 0 && !adding}
|
||||
@@ -188,19 +484,120 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each groups as group (group.id)}
|
||||
{@const inside = inGroup(group.id)}
|
||||
<div class="group folder">
|
||||
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
|
||||
the pointer from selecting text in the field it contains. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-head folder-head"
|
||||
class:over={overGroup === group.id}
|
||||
class:drop-above={overId === group.id && !overBelow}
|
||||
class:drop-below={overId === group.id && overBelow}
|
||||
class:dragging={draggingGroup === group.id}
|
||||
draggable={renamingGroup !== group.id}
|
||||
ondragstart={(e) => onGroupDragStart(e, group)}
|
||||
ondragend={clearDrag}
|
||||
ondragover={(e) => onGroupDragOver(e, group)}
|
||||
ondragleave={() => {
|
||||
overGroup = null;
|
||||
overId = null;
|
||||
}}
|
||||
ondrop={(e) => onGroupDrop(e, group)}
|
||||
>
|
||||
{#if renamingGroup === group.id}
|
||||
<form onsubmit={(e) => renameGroup(e, group)}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={groupName}
|
||||
autofocus
|
||||
aria-label="Gruppens navn"
|
||||
onblur={() => (renamingGroup = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- The button sits inside the heading, not around it, the same way a
|
||||
project's title does: a group *is* a heading in the sidebar, and
|
||||
wrapping an h2 in a button both invalidates the markup and takes
|
||||
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
|
||||
class="chevron"
|
||||
class:collapsed={group.collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
{group.name}
|
||||
</button>
|
||||
</h2>
|
||||
<span class="count">{inside.length}</span>
|
||||
<button
|
||||
class="group-action"
|
||||
onclick={() => {
|
||||
renamingGroup = group.id;
|
||||
groupName = group.name;
|
||||
}}>Omdøb</button
|
||||
>
|
||||
<button class="group-action remove" onclick={() => removeGroup(group)}>Slet</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if coloringGroup === group.id}
|
||||
<div class="swatches" role="group" aria-label="Farve på {group.name}">
|
||||
{#each COLORS as color (color.id)}
|
||||
<button
|
||||
class="swatch"
|
||||
class:chosen={(group.color ?? 'graphite') === color.id}
|
||||
style="background: {colorVar(color.id)}"
|
||||
title={color.name}
|
||||
aria-label={color.name}
|
||||
aria-pressed={(group.color ?? 'graphite') === color.id}
|
||||
onclick={() => {
|
||||
app.setGroupColor(group.id, color.id);
|
||||
coloringGroup = null;
|
||||
}}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !group.collapsed}
|
||||
{#each inside as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
{#if !inside.length}
|
||||
<p class="empty">Tom — træk et projekt herop.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if shared.length}
|
||||
<div class="group">
|
||||
<div class="group-head"><h2>Delt med mig</h2></div>
|
||||
{#each shared as project (project.id)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
class:active={current === `/projekt/${project.id}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{project.name}
|
||||
<span class="count">{project.member_count}</span>
|
||||
</a>
|
||||
{@render projectRow(project, false)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -386,7 +783,12 @@
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.group-head button {
|
||||
/* Only the square icon buttons — "+" and the folder — and asked for by class
|
||||
rather than by "a button in a heading". A group's heading also has Omdøb and
|
||||
Slet in it, and they are words: a selector that reached them squeezed both
|
||||
into a 20px box, where they overflowed and were drawn on top of each other.
|
||||
The class is the boundary, so the next text button added here is safe. */
|
||||
.group-head > button.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
@@ -395,13 +797,209 @@
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.group-head button:hover {
|
||||
.group-head > button.icon:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.new-group svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* --- groups ------------------------------------------------------------- */
|
||||
|
||||
.folder {
|
||||
margin-top: calc(var(--s3) * -1);
|
||||
}
|
||||
|
||||
.folder-head {
|
||||
position: relative;
|
||||
gap: var(--s1);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.folder-head.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* The heading lights up as a whole, because the whole heading is the target —
|
||||
unlike a project row, where the target is the gap next to it. Both headings
|
||||
do it: "Projekter" is where a project goes to leave its group. */
|
||||
.group-head.over {
|
||||
background: var(--surface-raised);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.folder-head::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-head.drop-above::before {
|
||||
top: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.folder-head.drop-below::before {
|
||||
bottom: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fold {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fold button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fold button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Hidden until the heading is hovered, like the section actions on a project
|
||||
page: two buttons permanently beside a label stop it reading as a label. */
|
||||
.group-action {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
padding: 0 var(--s1);
|
||||
flex: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.folder-head:hover .group-action,
|
||||
.group-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.group-action:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.group-action.remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.folder-head .count {
|
||||
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;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -460,6 +1058,15 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* A task hovering over a project lights the whole row, where a project hovering
|
||||
between two rows draws a line in the gap. Two different questions — "into
|
||||
this one" and "between these two" — so two different marks. */
|
||||
a.receiving {
|
||||
background: var(--surface-raised);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
<script>
|
||||
/**
|
||||
* Sign-in, first-run setup and the second factor, in one component.
|
||||
* Sign-in, first-run setup, the second factor, the invite that creates an
|
||||
* account, and choosing a new password — in one component.
|
||||
*
|
||||
* They are one screen because they are one decision tree: an instance with no
|
||||
* accounts asks you to create one, an instance with accounts asks you to log
|
||||
* in, and a login with 2FA asks for a code. Splitting them across routes would
|
||||
* mean redirecting somebody mid-authentication, which is where sessions get
|
||||
* lost.
|
||||
*
|
||||
* The two that arrive by email — /invite and /reset — do have routes, because a
|
||||
* link has to be an address. Those routes render this component and it reads
|
||||
* the token out of the URL, so the form is the same form and there is one place
|
||||
* where signing in is written down.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let mode = $state('loading'); // loading | login | setup | totp | forgot | sent
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
@@ -20,7 +29,23 @@
|
||||
let fields = $state({});
|
||||
let busy = $state(false);
|
||||
|
||||
/**
|
||||
* The token from an emailed link, if this is one.
|
||||
*
|
||||
* Read from the URL rather than passed in as a prop: the layout renders this
|
||||
* component in several places and none of them should have to know about
|
||||
* tokens.
|
||||
*/
|
||||
let token = $derived($page.url.searchParams.get('token') ?? '');
|
||||
let linkMode = $derived(
|
||||
$page.url.pathname === '/invite' ? 'invite' : $page.url.pathname === '/reset' ? 'reset' : ''
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (linkMode) {
|
||||
mode = linkMode;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.setupState()
|
||||
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
||||
@@ -78,6 +103,31 @@
|
||||
await api.forgotPassword(email);
|
||||
mode = 'sent';
|
||||
break;
|
||||
|
||||
case 'invite':
|
||||
// The email address is not asked for and not sent: it comes off the
|
||||
// invite on the server, which is what stops a link being redirected
|
||||
// to somebody the inviter did not invite.
|
||||
await api.signup({
|
||||
token,
|
||||
name,
|
||||
password,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
locale: navigator.language?.startsWith('en') ? 'en' : 'da'
|
||||
});
|
||||
// Signup starts the session itself, so there is nothing to log into.
|
||||
await app.load();
|
||||
goto('/');
|
||||
break;
|
||||
|
||||
case 'reset':
|
||||
await api.resetPassword(token, password);
|
||||
// Not signed in afterwards, on purpose: the reset ends every session
|
||||
// the account had, and quietly starting a new one here would undo
|
||||
// the half of that which matters.
|
||||
password = '';
|
||||
mode = 'done';
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -142,6 +192,60 @@
|
||||
|
||||
<button type="submit" disabled={busy}>Send link</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
|
||||
{:else if mode === 'invite' || mode === 'reset'}
|
||||
{#if !token}
|
||||
<!-- A link that lost its token on the way. Saying so beats a form that
|
||||
cannot succeed and a message that arrives only after it is filled in. -->
|
||||
<p class="lede">
|
||||
Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.
|
||||
</p>
|
||||
<a class="link" href="/">Til log ind</a>
|
||||
{:else if app.user}
|
||||
<!-- Already somebody else. The invite is tied to an address, so using it
|
||||
from this session would create a second account for a person who is
|
||||
right here in the first one. -->
|
||||
<p class="lede">
|
||||
Du er logget ind som {app.user.name}. Log ud først, og åbn så linket igen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
onclick={async () => {
|
||||
await api.logout();
|
||||
location.reload();
|
||||
}}>Log ud</button
|
||||
>
|
||||
{:else if mode === 'invite'}
|
||||
<p class="lede">Du er inviteret. Vælg et navn og en adgangskode.</p>
|
||||
|
||||
<label>
|
||||
Navn
|
||||
<input bind:value={name} autocomplete="name" required />
|
||||
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
Adgangskode
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Opret konto</button>
|
||||
{:else}
|
||||
<p class="lede">Vælg en ny adgangskode.</p>
|
||||
|
||||
<label>
|
||||
Ny adgangskode
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. Alle andre enheder bliver logget ud.</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Gem adgangskoden</button>
|
||||
{/if}
|
||||
{:else if mode === 'done'}
|
||||
<p class="lede">Adgangskoden er skiftet. Log ind med den nye.</p>
|
||||
<a class="link" href="/">Til log ind</a>
|
||||
{:else if mode === 'sent'}
|
||||
<!-- Deliberately not "we sent an email": that would confirm the address
|
||||
has an account here to anybody who tried it. -->
|
||||
@@ -280,6 +384,7 @@
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
let priority = $state(4);
|
||||
let dueDate = $state('');
|
||||
let labels = $state('');
|
||||
let projectId = $state('');
|
||||
let recurrence = $state('');
|
||||
let assignee = $state('');
|
||||
|
||||
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||
let members = $state([]);
|
||||
|
||||
// The rules worth a menu entry. Anything more specific is typed into the
|
||||
// title, where the parser understands far more than a list ever could.
|
||||
const REPEATS = [
|
||||
{ rule: '', label: 'Aldrig' },
|
||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||
{ rule: 'FREQ=MONTHLY', label: 'Hver måned' },
|
||||
{ rule: 'FREQ=YEARLY', label: 'Hvert år' }
|
||||
];
|
||||
|
||||
let subtasks = $state([]);
|
||||
let comments = $state([]);
|
||||
@@ -46,6 +63,17 @@
|
||||
priority = task.priority;
|
||||
dueDate = task.due_date ?? '';
|
||||
labels = (task.labels ?? []).join(', ');
|
||||
projectId = task.project_id;
|
||||
recurrence = task.recurrence_rule ?? '';
|
||||
assignee = task.assignee_id ?? '';
|
||||
|
||||
members = [];
|
||||
api.listMembers(task.project_id)
|
||||
.then((r) => r.members.length > 1 && (members = r.members))
|
||||
.catch(() => {
|
||||
// A project you are not the owner of may refuse the member list.
|
||||
// Then there is nobody to choose from, which is the same as none.
|
||||
});
|
||||
|
||||
subtasks = [];
|
||||
comments = [];
|
||||
@@ -73,13 +101,83 @@
|
||||
await app.update(task.id, patch);
|
||||
}
|
||||
|
||||
function saveContent() {
|
||||
/**
|
||||
* Saves the title, reading it the way quick add would.
|
||||
*
|
||||
* Typing "i morgen kl. 14" into a title used to leave it as those words. The
|
||||
* same sentence in the quick-add box becomes a date, which made where you
|
||||
* typed it decide what it meant.
|
||||
*
|
||||
* The parse runs on the server — the grammar is Danish and English and lives
|
||||
* there — and is only applied when it actually consumed something. What it
|
||||
* took is said out loud, because a title that edits itself without a word is
|
||||
* worse than one that does nothing.
|
||||
*/
|
||||
async function saveContent() {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || trimmed === task.content) {
|
||||
content = task.content;
|
||||
return;
|
||||
}
|
||||
save({ content: trimmed });
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = await api.quickAddPreview(trimmed);
|
||||
} catch {
|
||||
// Offline, or the parser refused. Saving the words is still right.
|
||||
}
|
||||
|
||||
const patch = { content: trimmed };
|
||||
const took = [];
|
||||
if (parsed && parsed.content && parsed.content !== trimmed) {
|
||||
patch.content = parsed.content;
|
||||
if (parsed.due_date) {
|
||||
patch.due_date = parsed.due_date;
|
||||
took.push(parsed.due_date);
|
||||
}
|
||||
if (parsed.priority && parsed.priority < 4) {
|
||||
patch.priority = parsed.priority;
|
||||
took.push(`P${parsed.priority}`);
|
||||
}
|
||||
if (parsed.recurrence) {
|
||||
patch.recurrence_rule = parsed.recurrence;
|
||||
took.push('gentagelse');
|
||||
}
|
||||
if (parsed.labels?.length) {
|
||||
patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])];
|
||||
took.push(parsed.labels.map((l) => `@${l}`).join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
await save(patch);
|
||||
|
||||
// The fields are seeded once per task so a save cannot overwrite what
|
||||
// somebody is typing. That rule has to bend exactly here: this save
|
||||
// changed the very fields on screen, and leaving them showing what was
|
||||
// typed while the toast reports something else is the worst of both.
|
||||
if (took.length) {
|
||||
content = patch.content;
|
||||
if (patch.due_date) dueDate = patch.due_date;
|
||||
if (patch.priority) priority = patch.priority;
|
||||
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
||||
if (patch.labels) labels = patch.labels.join(', ');
|
||||
app.toast(`Læst som ${took.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
if (projectId === task.project_id) return;
|
||||
await save({ project_id: projectId });
|
||||
}
|
||||
|
||||
async function saveRecurrence() {
|
||||
if (recurrence === (task.recurrence_rule ?? '')) return;
|
||||
await save({ recurrence_rule: recurrence });
|
||||
}
|
||||
|
||||
async function saveAssignee() {
|
||||
if (assignee === (task.assignee_id ?? '')) return;
|
||||
await save({ assignee_id: assignee });
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
@@ -316,8 +414,42 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if task.recurrence_text}
|
||||
<p class="repeats">Gentages {task.recurrence_text}.</p>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="project">Projekt</label>
|
||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="repeat">Gentages</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
{/each}
|
||||
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||
showing it rather than snapping to the nearest preset is the
|
||||
difference between a menu and a menu that quietly edits. -->
|
||||
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
||||
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if members.length > 1}
|
||||
<div class="field">
|
||||
<label for="assignee">Ansvarlig</label>
|
||||
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
|
||||
<option value="">Ingen</option>
|
||||
{#each members as member (member.user_id)}
|
||||
<option value={member.user_id}>{member.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
@@ -567,12 +699,6 @@
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.repeats {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
|
||||
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
||||
@@ -25,15 +26,15 @@
|
||||
function onDragStart(event, task) {
|
||||
if (!canEdit) return;
|
||||
draggingId = task.id;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
||||
event.dataTransfer.setData('text/plain', task.id);
|
||||
// Typed, because the same task can now be dropped somewhere else entirely —
|
||||
// a project in the sidebar, a day in Kommende — and those targets have to be
|
||||
// able to tell what is coming while the drag is still in the air.
|
||||
startDrag(event, TASK, task.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, task) {
|
||||
if (!canEdit || !draggingId || draggingId === task.id) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (!canEdit || !carries(event, TASK) || !draggingId || draggingId === task.id) return;
|
||||
accept(event);
|
||||
|
||||
// Which half of the row the pointer is in decides the gap. Comparing against
|
||||
// the row's own midpoint rather than counting rows is what keeps the line
|
||||
@@ -49,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;
|
||||
|
||||
@@ -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} {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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* What a drag is carrying.
|
||||
*
|
||||
* More than one kind of thing is now dragged onto the same targets: the sidebar
|
||||
* takes a project (to reorder it, or file it under a group) *and* a task (to move
|
||||
* it into that project), and Kommende takes a task onto a day.
|
||||
*
|
||||
* The distinction has to be a MIME type rather than something read out of the
|
||||
* payload, because `getData` is deliberately unreadable while a drag is in the
|
||||
* air — the browser only hands the data over on drop, so that a page cannot read
|
||||
* what is being dragged across it. During `dragover`, `dataTransfer.types` is all
|
||||
* a drop target has to decide with, and deciding is exactly what it has to do:
|
||||
* `preventDefault` on a drag it cannot accept is what turns "no" into a silent,
|
||||
* wrong drop.
|
||||
*/
|
||||
|
||||
export const TASK = 'application/x-verdande-task';
|
||||
export const PROJECT = 'application/x-verdande-project';
|
||||
export const GROUP = 'application/x-verdande-group';
|
||||
|
||||
/** Starts a drag carrying one id of the given kind. */
|
||||
export function startDrag(event, type, id) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(type, id);
|
||||
// Firefox refuses to start a drag unless text/plain is set as well.
|
||||
event.dataTransfer.setData('text/plain', id);
|
||||
}
|
||||
|
||||
/** Whether the drag in flight is of this kind. Safe to call during dragover. */
|
||||
export function carries(event, type) {
|
||||
return event.dataTransfer?.types?.includes(type) ?? false;
|
||||
}
|
||||
|
||||
/** The id being dragged. Only meaningful in a drop handler. */
|
||||
export function dragged(event, type) {
|
||||
return event.dataTransfer?.getData(type) ?? '';
|
||||
}
|
||||
|
||||
/** Marks a drop target as willing, which is what allows the drop to happen at all. */
|
||||
export function accept(event) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
+340
-15
@@ -16,6 +16,16 @@ import { api, ApiError, humanMessage } from './api.js';
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
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. */
|
||||
@@ -29,6 +39,16 @@ class AppState {
|
||||
*/
|
||||
detailId = $state(null);
|
||||
|
||||
/**
|
||||
* Bumped whenever a label changes anywhere.
|
||||
*
|
||||
* A counter rather than the labels themselves: the sidebar wants each label's
|
||||
* task count, which no single event carries and which changes whenever a task
|
||||
* gains or loses one. Whoever is showing labels re-reads them; that is one
|
||||
* small request against modelling a count in two places.
|
||||
*/
|
||||
labelsChanged = $state(0);
|
||||
|
||||
#socket = null;
|
||||
#reconnectDelay = 1000;
|
||||
|
||||
@@ -40,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;
|
||||
}
|
||||
@@ -56,8 +93,14 @@ class AppState {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.user = await api.me();
|
||||
const { projects } = await api.listProjects();
|
||||
const [{ projects }, { groups }, { people }] = await Promise.all([
|
||||
api.listProjects(),
|
||||
api.listProjectGroups(),
|
||||
api.people()
|
||||
]);
|
||||
this.projects = projects;
|
||||
this.groups = groups;
|
||||
this.people = people;
|
||||
this.connect();
|
||||
} catch (e) {
|
||||
this.user = null;
|
||||
@@ -110,6 +153,36 @@ class AppState {
|
||||
case 'task.deleted':
|
||||
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
|
||||
break;
|
||||
|
||||
// Projects and labels belong to a person rather than to a project, so
|
||||
// they arrive on the user's own channel — a project that has just been
|
||||
// created has no subscribers, because nobody was watching a thing that
|
||||
// did not exist a moment ago.
|
||||
case 'project.created':
|
||||
case 'project.updated':
|
||||
this.upsertProject(task);
|
||||
break;
|
||||
case 'project.deleted':
|
||||
this.projects = this.projects.filter((p) => p.id !== task?.id);
|
||||
break;
|
||||
|
||||
case 'project_group.created':
|
||||
case 'project_group.updated':
|
||||
this.upsertGroup(task);
|
||||
break;
|
||||
case 'project_group.deleted':
|
||||
// The projects filed under it are not deleted with it; they come back
|
||||
// out as ungrouped, which the sidebar works out from the group being
|
||||
// gone rather than from a second event per project.
|
||||
this.groups = this.groups.filter((g) => g.id !== task?.id);
|
||||
break;
|
||||
|
||||
// The payload is not always the label — a rename says only that
|
||||
// something changed — and the sidebar wants counts anyway, which no
|
||||
// single event carries. Asking is cheaper than modelling it.
|
||||
case 'label.changed':
|
||||
this.labelsChanged++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +209,13 @@ class AppState {
|
||||
* copy is not worth a moment's thought next to the class of bug it removes.
|
||||
*/
|
||||
replace(id, next) {
|
||||
// A task can invent a label just by mentioning one — "@regnskab" in quick
|
||||
// add creates it — and no label event is sent for that, because it happened
|
||||
// inside a task write. Comparing the two lists is what keeps the sidebar
|
||||
// honest without re-reading the labels on every completed checkbox.
|
||||
const before = this.tasks.find((t) => t.id === id)?.labels ?? [];
|
||||
if ((next?.labels ?? []).join() !== before.join()) this.labelsChanged++;
|
||||
|
||||
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
|
||||
}
|
||||
|
||||
@@ -151,14 +231,33 @@ class AppState {
|
||||
*/
|
||||
upsert(task) {
|
||||
if (!task?.id) return;
|
||||
if (this.tasks.some((t) => t.id === task.id)) this.replace(task.id, task);
|
||||
else this.tasks = [...this.tasks, task];
|
||||
if (this.tasks.some((t) => t.id === task.id)) {
|
||||
this.replace(task.id, task);
|
||||
return;
|
||||
}
|
||||
if (task.labels?.length) this.labelsChanged++;
|
||||
this.tasks = [...this.tasks, task];
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.tasks.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a project, or replaces it if the list already has it.
|
||||
*
|
||||
* The same guard the tasks need, and for the same reason: a project arrives
|
||||
* twice, once as the response to the request that created it and once over
|
||||
* the socket. Appending both puts two rows with one id into a keyed `{#each}`,
|
||||
* which throws and stops the sidebar rendering.
|
||||
*/
|
||||
upsertProject(project) {
|
||||
if (!project?.id) return;
|
||||
this.projects = this.projects.some((p) => p.id === project.id)
|
||||
? this.projects.map((p) => (p.id === project.id ? project : p))
|
||||
: [...this.projects, project];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks a task off. The row is struck through and gone before the request
|
||||
* leaves — which is the single most-repeated action in the app, and the one
|
||||
@@ -197,6 +296,35 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a task into another project, which is what dropping it on the sidebar
|
||||
* means.
|
||||
*
|
||||
* Through `move` rather than a `project_id` on an update: `sort_order` belongs
|
||||
* to the project, so a task arriving in a new one needs a place among its
|
||||
* tasks. The section goes too — sections belong to the project it is leaving,
|
||||
* and carrying the id across would file it under a heading that is not there.
|
||||
*/
|
||||
async moveToProject(id, projectId) {
|
||||
const previous = this.get(id);
|
||||
if (!previous || previous.project_id === projectId) return;
|
||||
|
||||
this.replace(id, { ...previous, project_id: projectId, section_id: '' });
|
||||
try {
|
||||
this.replace(id, await api.moveTask(id, { project_id: projectId, section_id: '' }));
|
||||
} catch (e) {
|
||||
this.replace(id, previous);
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */
|
||||
async reschedule(id, date) {
|
||||
const previous = this.get(id);
|
||||
if (!previous || previous.due_date === date) return;
|
||||
await this.update(id, { due_date: date });
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
const previous = [...this.tasks];
|
||||
this.tasks = this.tasks.filter((t) => t.id !== id);
|
||||
@@ -233,7 +361,7 @@ class AppState {
|
||||
async createProject(name) {
|
||||
try {
|
||||
const project = await api.createProject({ name });
|
||||
this.projects = [...this.projects, project];
|
||||
this.upsertProject(project);
|
||||
return project;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
@@ -269,6 +397,130 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
// --- project groups -----------------------------------------------------------
|
||||
|
||||
/** The same new-array upsert the projects need, and for the same reason. */
|
||||
upsertGroup(group) {
|
||||
if (!group?.id) return;
|
||||
this.groups = this.groups.some((g) => g.id === group.id)
|
||||
? this.groups.map((g) => (g.id === group.id ? group : g))
|
||||
: [...this.groups, group];
|
||||
}
|
||||
|
||||
async createGroup(name) {
|
||||
try {
|
||||
const group = await api.createProjectGroup(name);
|
||||
this.upsertGroup(group);
|
||||
return group;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds or unfolds a group.
|
||||
*
|
||||
* Applied first and saved after, like everything else here — a fold that waits
|
||||
* for a round trip feels like a click that did not land. The rollback matters
|
||||
* more than usual: this is stored on the account rather than in the browser, so
|
||||
* a failed save that left the arrow turned would be a lie that survives a
|
||||
* reload.
|
||||
*/
|
||||
async toggleGroup(id) {
|
||||
const group = this.groups.find((g) => g.id === id);
|
||||
if (!group) return;
|
||||
await this.#patchGroup(id, { collapsed: !group.collapsed });
|
||||
}
|
||||
|
||||
async renameGroup(id, name) {
|
||||
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;
|
||||
|
||||
this.upsertGroup({ ...previous, ...patch });
|
||||
try {
|
||||
this.upsertGroup(await api.updateProjectGroup(id, patch));
|
||||
} catch (e) {
|
||||
this.upsertGroup(previous);
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the heading. The projects come back out as ungrouped, which is done
|
||||
* here too rather than waiting for a re-read: the sidebar reads `group_id` to
|
||||
* decide where a project sits, and a project pointing at a group that is gone
|
||||
* would otherwise vanish from the list entirely until the next load.
|
||||
*/
|
||||
async deleteGroup(id) {
|
||||
const previousGroups = this.groups;
|
||||
const previousProjects = this.projects;
|
||||
|
||||
this.groups = this.groups.filter((g) => g.id !== id);
|
||||
this.projects = this.projects.map((p) => (p.group_id === id ? { ...p, group_id: '' } : p));
|
||||
try {
|
||||
await api.deleteProjectGroup(id);
|
||||
} catch (e) {
|
||||
this.groups = previousGroups;
|
||||
this.projects = previousProjects;
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async reorderGroups(ids) {
|
||||
const previous = this.groups;
|
||||
const rank = new Map(ids.map((id, i) => [id, i]));
|
||||
|
||||
this.groups = this.groups.map((g) =>
|
||||
rank.has(g.id) ? { ...g, sort_order: rank.get(g.id) } : g
|
||||
);
|
||||
try {
|
||||
await api.reorderProjectGroups(ids);
|
||||
} catch (e) {
|
||||
this.groups = previous;
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
// `?? ''` because the server omits the field when there is no group, so
|
||||
// "none" arrives as undefined and is compared against the '' that means the
|
||||
// same thing.
|
||||
if (!previous || (previous.group_id ?? '') === groupId) return;
|
||||
|
||||
this.upsertProject({ ...previous, group_id: groupId });
|
||||
try {
|
||||
this.upsertProject(await api.updateProject(projectId, { group_id: groupId }));
|
||||
} catch (e) {
|
||||
this.upsertProject(previous);
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- toasts -------------------------------------------------------------------
|
||||
|
||||
toast(message) {
|
||||
@@ -338,21 +590,94 @@ class SidebarLayout {
|
||||
|
||||
export const sidebar = new SidebarLayout();
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
export const theme = {
|
||||
get current() {
|
||||
if (typeof document === 'undefined') return 'dark';
|
||||
return document.documentElement.dataset.theme ?? 'dark';
|
||||
},
|
||||
toggle() {
|
||||
const next = this.current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
/**
|
||||
* Whether Kommende shows the next seven days as a list, or the month as a grid.
|
||||
*
|
||||
* In localStorage rather than on the account, and for the same reason as the
|
||||
* sidebar's width: a month grid of seven columns needs room, and the answer on a
|
||||
* phone is not the answer on a wide monitor. A project keeps its `view_mode` on
|
||||
* the row because that is a fact about the project — this is a fact about the
|
||||
* screen.
|
||||
*/
|
||||
class UpcomingView {
|
||||
mode = $state(
|
||||
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:upcoming') === 'calendar'
|
||||
? 'calendar'
|
||||
: 'list'
|
||||
);
|
||||
|
||||
set(next) {
|
||||
if (next !== 'list' && next !== 'calendar') return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:theme', next);
|
||||
localStorage.setItem('verdande:upcoming', next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const upcomingView = new UpcomingView();
|
||||
|
||||
/**
|
||||
* The themes on offer.
|
||||
*
|
||||
* `dark` is the tokens on bare :root; the rest are [data-theme] blocks in
|
||||
* app.css. The list lives here so the picker and the stylesheet cannot drift —
|
||||
* an entry with no CSS behind it renders as the default and looks like a bug in
|
||||
* the picker rather than a missing block.
|
||||
*/
|
||||
export const THEMES = [
|
||||
{ id: 'dark', name: 'Nordlys', note: 'Mørk med grøn accent.', dark: true },
|
||||
{ id: 'dusk', name: 'Skumring', note: 'Mørk og varm, med rav.', dark: true },
|
||||
{ id: 'light', name: 'Dagslys', note: 'Lys og køligt neutral.', dark: false },
|
||||
{ id: 'paper', name: 'Papir', note: 'Lys og varm, som papir.', dark: false },
|
||||
{ id: 'contrast', name: 'Kontrast', note: 'Sort på hvidt, til skarpt lys.', dark: false }
|
||||
];
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
class Theme {
|
||||
/**
|
||||
* Mirrors the attribute on <html>, which app.html sets before first paint —
|
||||
* that is the only way to avoid a white flash on load, and it means the
|
||||
* document, not this object, is where the truth lives.
|
||||
*/
|
||||
current = $state(
|
||||
typeof document === 'undefined' ? 'dark' : (document.documentElement.dataset.theme ?? 'dark')
|
||||
);
|
||||
|
||||
set(id) {
|
||||
if (!THEMES.some((t) => t.id === id)) return;
|
||||
this.current = id;
|
||||
document.documentElement.dataset.theme = id;
|
||||
|
||||
// The browser paints its own chrome from this — the address bar on a
|
||||
// phone, the frame of an installed PWA. Left alone it stays the colour of
|
||||
// whichever theme shipped in the markup.
|
||||
const ground = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', ground);
|
||||
|
||||
try {
|
||||
localStorage.setItem('verdande:theme', id);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The topbar button, which flips between light and dark rather than walking
|
||||
* all five. Somebody who has chosen Skumring wants the light one to be Papir,
|
||||
* not to be marched back to the default — so it moves to the other side of
|
||||
* the list and stays warm or stays cool.
|
||||
*/
|
||||
toggle() {
|
||||
const now = THEMES.find((t) => t.id === this.current) ?? THEMES[0];
|
||||
const opposite = THEMES.filter((t) => t.dark !== now.dark);
|
||||
const index = THEMES.filter((t) => t.dark === now.dark).indexOf(now);
|
||||
this.set((opposite[index] ?? opposite[0]).id);
|
||||
}
|
||||
}
|
||||
|
||||
export const theme = new Theme();
|
||||
|
||||
export { ApiError, humanMessage };
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
let paletteOpen = $state(false);
|
||||
let sidebarOpen = $state(false);
|
||||
|
||||
/**
|
||||
* The two pages that arrive by email, and are for somebody who is not signed in.
|
||||
*
|
||||
* They render on their own rather than inside the shell or behind the sign-in
|
||||
* screen. Without this the invite link showed a login form to a person who had
|
||||
* no account yet, and the reset link showed one to a person who had forgotten
|
||||
* the password it was asking for.
|
||||
*/
|
||||
let standalone = $derived(['/invite', '/reset'].includes($page.url.pathname));
|
||||
|
||||
$effect(() => {
|
||||
app.load();
|
||||
});
|
||||
@@ -63,6 +73,8 @@
|
||||
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
|
||||
flash of anxiety, not feedback. -->
|
||||
<div class="booting"></div>
|
||||
{:else if standalone}
|
||||
{@render children()}
|
||||
{:else if !app.user}
|
||||
<SignIn />
|
||||
{:else}
|
||||
|
||||
@@ -11,17 +11,28 @@
|
||||
* 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/fejl', label: 'Fejl' }
|
||||
]
|
||||
: []),
|
||||
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
||||
];
|
||||
]);
|
||||
|
||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||
</script>
|
||||
@@ -32,7 +43,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||
|
||||
// --- profile ---------------------------------------------------------------
|
||||
|
||||
@@ -144,6 +144,54 @@
|
||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- sessions --------------------------------------------------------------
|
||||
|
||||
let sessions = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
api.listSessions().then((r) => (sessions = r.sessions)).catch(() => {});
|
||||
});
|
||||
|
||||
async function endSession(session) {
|
||||
if (session.current) {
|
||||
if (!confirm('Det er denne enhed. Du bliver logget ud.')) return;
|
||||
await api.endSession(session.id);
|
||||
// A full reload rather than clearing the store by hand: the session is
|
||||
// gone, so every open socket and every cached answer in this tab is now
|
||||
// about an account nobody is signed in to.
|
||||
location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = sessions;
|
||||
sessions = sessions.filter((s) => s.id !== session.id);
|
||||
try {
|
||||
await api.endSession(session.id);
|
||||
} catch (e) {
|
||||
sessions = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "for 2 minutter siden".
|
||||
*
|
||||
* Relative rather than a timestamp, because the question is "was that me, just
|
||||
* now?" and an absolute time makes the reader do the subtraction. Anything
|
||||
* older than a week gets its date instead: "for 23 dage siden" is a number
|
||||
* nobody can place.
|
||||
*/
|
||||
function ago(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' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -204,6 +252,39 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Udseende</h2>
|
||||
<p class="hint">
|
||||
Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved —
|
||||
en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="themes">
|
||||
{#each THEMES as option (option.id)}
|
||||
<button
|
||||
class="theme-card"
|
||||
class:chosen={theme.current === option.id}
|
||||
onclick={() => theme.set(option.id)}
|
||||
aria-pressed={theme.current === option.id}
|
||||
>
|
||||
<!-- data-theme goes on the swatch and not on the card: it redefines
|
||||
--ink too, and a card wearing a dark theme's ink on a light page
|
||||
has a name nobody can read. The swatch shows the colours; the
|
||||
label stays in the page's. -->
|
||||
<span class="swatch" data-theme={option.id} aria-hidden="true">
|
||||
<span class="swatch-line"></span>
|
||||
<span class="swatch-line short"></span>
|
||||
<span class="swatch-dot"></span>
|
||||
</span>
|
||||
<span class="theme-name">{option.name}</span>
|
||||
<span class="theme-note">{option.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Adgangskode</h2>
|
||||
@@ -334,6 +415,35 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Enheder</h2>
|
||||
<p class="hint">
|
||||
Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift
|
||||
adgangskode.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="sessions">
|
||||
{#each sessions as session (session.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="device">
|
||||
{session.device}
|
||||
{#if session.current}<span class="badge">denne enhed</span>{/if}
|
||||
</span>
|
||||
<!-- The address and the time, small: they are what settles "was that
|
||||
me?", and they are not what you read first. -->
|
||||
<span class="when" title={session.user_agent}>
|
||||
{ago(session.last_seen_at)}{#if session.ip}{' · ' + session.ip}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => endSession(session)}>Log ud</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
@@ -341,6 +451,58 @@
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.sessions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.sessions li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s2) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sessions li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.what {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.device {
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0 var(--s2);
|
||||
}
|
||||
|
||||
.when {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -353,6 +515,82 @@
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.themes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s1);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.theme-card.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Painted in the card's own theme rather than the page's — that is the whole
|
||||
point of putting data-theme on the button. */
|
||||
.swatch {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--line);
|
||||
padding: var(--s2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.swatch-line {
|
||||
height: 5px;
|
||||
width: 70%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--ink-muted);
|
||||
}
|
||||
|
||||
.swatch-line.short {
|
||||
width: 45%;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.swatch-dot {
|
||||
position: absolute;
|
||||
right: var(--s2);
|
||||
top: var(--s2);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.theme-note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.secret {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
@@ -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="navn@example.dk"
|
||||
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>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script>
|
||||
/**
|
||||
* Fejl: what has gone wrong lately, kept where a restart cannot take it.
|
||||
*
|
||||
* The panel's watcher already says "HTTP 5xx, twice, at 11:49". That is enough
|
||||
* to know something broke and nothing at all to act on — the line explaining it
|
||||
* is in the container's log, and a Rune replaces the container on every
|
||||
* restart, so it is usually gone before anybody looks. These are the same
|
||||
* failures written somewhere that survives.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let errors = $state([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.listErrors()
|
||||
.then((r) => {
|
||||
errors = r.errors;
|
||||
loaded = true;
|
||||
})
|
||||
.catch((e) => app.toast(humanMessage(e)));
|
||||
});
|
||||
|
||||
function when(iso) {
|
||||
const then = new Date(iso);
|
||||
const seconds = Math.round((Date.now() - then) / 1000);
|
||||
if (seconds < 60) return 'lige nu';
|
||||
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||
return then.toLocaleString('da-DK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Serverfejl</h2>
|
||||
<p class="hint">
|
||||
Hver 500, API'et har svaret, med hvad handleren var i gang med og fejlen den
|
||||
ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra
|
||||
ved hver genstart, så forklaringen er som regel væk, når nogen kigger.
|
||||
Ryddes efter 30 dage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if errors.length}
|
||||
<ul class="rows">
|
||||
{#each errors as error (error.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="primary-line">
|
||||
<span class="status">{error.status}</span>
|
||||
{error.what}
|
||||
</span>
|
||||
<span class="secondary">{error.message}</span>
|
||||
<span class="secondary mono">
|
||||
{error.method}
|
||||
{error.path}{#if error.user_name}{' · '}{error.user_name}{/if}{#if error.request_id}{' · ' +
|
||||
error.request_id}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="when">{when(error.at)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if loaded}
|
||||
<p class="hint">Ingen fejl registreret. Det er den rigtige tilstand.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rows li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rows li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.what {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.primary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 var(--s1);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.when {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
/**
|
||||
* The address an invite link points at.
|
||||
*
|
||||
* It exists as a route because the server has been emailing this URL since
|
||||
* invites were built, and until now nothing answered for it: the SPA fallback
|
||||
* served the app shell, the shell found no session, and the recipient was shown
|
||||
* a plain sign-in form for an account they did not have yet. A 200 the whole
|
||||
* way, which is exactly how that class of bug hides here.
|
||||
*
|
||||
* The form is SignIn's: signing up from an invite is one branch of signing in,
|
||||
* and it reads the token out of the URL itself.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Invitation · verdande</title></svelte:head>
|
||||
|
||||
<SignIn />
|
||||
@@ -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);
|
||||
@@ -63,10 +143,28 @@
|
||||
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
|
||||
let isOwner = $derived(project?.role === 'owner');
|
||||
|
||||
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
|
||||
// Filtered by project as well as by state. The store holds whatever the last
|
||||
// view loaded, and a task can now leave this project while you are looking at
|
||||
// it — dropped on another project in the sidebar, or moved by somebody else —
|
||||
// so "what the page asked for" is not the same as "what is in the store".
|
||||
let open = $derived(
|
||||
app.tasks.filter((t) => !t.completed && !t.parent_id && t.project_id === project?.id)
|
||||
);
|
||||
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) {
|
||||
@@ -101,6 +199,138 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
let renamingSection = $state(null);
|
||||
let sectionName = $state('');
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
if (!name) return;
|
||||
addingSection = false;
|
||||
sectionName = '';
|
||||
try {
|
||||
sections = [...sections, await api.createSection(project.id, name)];
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSection(event, section) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
renamingSection = null;
|
||||
if (!name || name === section.name) return;
|
||||
|
||||
const previous = sections;
|
||||
sections = sections.map((s) => (s.id === section.id ? { ...s, name } : s));
|
||||
try {
|
||||
await api.updateSection(section.id, { name });
|
||||
} catch (e) {
|
||||
sections = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSection(section) {
|
||||
const count = open.filter((t) => t.section_id === section.id).length;
|
||||
const warning = count
|
||||
? `Slet sektionen "${section.name}"? De ${count} opgaver bliver, men mister deres sektion.`
|
||||
: `Slet sektionen "${section.name}"?`;
|
||||
if (!confirm(warning)) return;
|
||||
|
||||
const previous = sections;
|
||||
sections = sections.filter((s) => s.id !== section.id);
|
||||
try {
|
||||
await api.deleteSection(section.id);
|
||||
// The tasks are not deleted with it; they come back unsectioned, and the
|
||||
// list has to be re-read to show them in the right place.
|
||||
await app.loadTasks({ project_id: project.id });
|
||||
} catch (e) {
|
||||
sections = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '';
|
||||
@@ -155,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
|
||||
@@ -171,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">
|
||||
@@ -180,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">
|
||||
@@ -189,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"
|
||||
@@ -240,16 +538,58 @@
|
||||
{#if mode === 'board'}
|
||||
<BoardView {project} {sections} {canEdit} />
|
||||
{:else if mode === 'calendar'}
|
||||
<CalendarView />
|
||||
<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>
|
||||
<h2>{section.name}</h2>
|
||||
<!-- 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)}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={sectionName}
|
||||
autofocus
|
||||
aria-label="Sektionens navn"
|
||||
onblur={() => (renamingSection = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<h2>{section.name}</h2>
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="section-action"
|
||||
onclick={() => {
|
||||
renamingSection = section.id;
|
||||
sectionName = section.name;
|
||||
}}>Omdøb</button
|
||||
>
|
||||
<button class="section-action remove" onclick={() => removeSection(section)}>
|
||||
Slet
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
@@ -257,7 +597,35 @@
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if !open.length}
|
||||
{#if canEdit}
|
||||
<section class="add-section">
|
||||
{#if addingSection}
|
||||
<form onsubmit={addSection}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={sectionName}
|
||||
autofocus
|
||||
placeholder="Sektionens navn"
|
||||
aria-label="Ny sektion"
|
||||
onblur={() => !sectionName.trim() && (addingSection = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingSection = false)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add"
|
||||
onclick={() => {
|
||||
addingSection = true;
|
||||
sectionName = '';
|
||||
}}
|
||||
>
|
||||
+ Tilføj sektion
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if !open.length && !sections.length}
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Ingenting her endnu.
|
||||
@@ -268,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>
|
||||
|
||||
@@ -366,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;
|
||||
@@ -463,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;
|
||||
@@ -510,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);
|
||||
@@ -535,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 {
|
||||
@@ -548,6 +1048,79 @@
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hidden until the section is hovered or something in it has focus: a heading
|
||||
with two buttons permanently beside it stops reading as a heading. */
|
||||
.section-action {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
padding: 0 var(--s1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.section-head:hover .section-action,
|
||||
.section-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-action:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.section-action.remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-head form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-head input,
|
||||
.add-section input {
|
||||
width: 100%;
|
||||
padding: var(--s1) var(--s2);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section-head input:focus,
|
||||
.add-section input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.add-section {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
.add-section .add {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s2);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.add-section .add:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.readonly {
|
||||
margin: 0;
|
||||
@@ -576,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>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
/**
|
||||
* The address a password-reset link points at.
|
||||
*
|
||||
* "Glemt adgangskode?" has always sent one and the API has always accepted the
|
||||
* token — but nothing rendered here, so the link opened the sign-in screen and
|
||||
* a person who had forgotten their password was shown the field they could not
|
||||
* fill in. Same shape as the invite link, and found the same way.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
|
||||
|
||||
<SignIn />
|
||||
@@ -0,0 +1,126 @@
|
||||
<script>
|
||||
/**
|
||||
* Venter på andre: what you have handed to somebody else and are waiting for.
|
||||
*
|
||||
* Grouped by person, because that is the question — "what is Anders sitting
|
||||
* on" — rather than by date. The server groups it and sends the names with it;
|
||||
* a client has no way to ask for the name behind an assignee id it has not met
|
||||
* in a project it happens to have open.
|
||||
*/
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
|
||||
let people = $state([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
api.delegated().then((data) => {
|
||||
people = data.people;
|
||||
app.tasks = data.people.flatMap((p) => p.tasks);
|
||||
loaded = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Read back out of the store, so a task that is closed or reassigned from the
|
||||
// drawer leaves the list at once rather than on the next load. Reassigning to
|
||||
// yourself is the interesting one: it stops being delegated the moment it
|
||||
// happens, which is exactly what this view should show.
|
||||
const live = (person) =>
|
||||
app.tasks.filter((t) => t.assignee_id === person.user_id && !t.completed);
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<header><h1>Venter på andre</h1></header>
|
||||
|
||||
{#each people as person (person.user_id)}
|
||||
{@const tasks = live(person)}
|
||||
{#if tasks.length}
|
||||
<section>
|
||||
<h2>
|
||||
<span class="avatar" style="background: {person.avatar_color}">
|
||||
{person.name[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
{person.name}
|
||||
<span class="count">{tasks.length}</span>
|
||||
</h2>
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if loaded && !people.some((p) => live(p).length)}
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Du venter ikke på nogen.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
/* The person is the heading, so their face is part of it rather than a
|
||||
decoration beside it. */
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 560;
|
||||
color: var(--ink-muted);
|
||||
padding: 0 var(--s2) var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clear {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s8) var(--s4);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rune {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,40 @@
|
||||
<script>
|
||||
/** Kommende: the next seven days, empty ones included. */
|
||||
/**
|
||||
* Kommende: the next seven days as a list, or the month as a grid.
|
||||
*
|
||||
* The two views ask for different things and neither is a subset of the other —
|
||||
* seven days from today, against six whole weeks around a month that can be any
|
||||
* month — so each loads its own, and switching reloads. Loading both up front
|
||||
* would mean the list waiting on a month it is not showing.
|
||||
*/
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, upcomingView } from '$lib/stores.svelte.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||
|
||||
let days = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
if (upcomingView.mode !== 'list') return;
|
||||
api.upcoming().then((data) => {
|
||||
days = data.days;
|
||||
app.tasks = data.days.flatMap((d) => d.tasks);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads the dates the month grid is showing.
|
||||
*
|
||||
* The limit is raised well above the default: a busy month across every project
|
||||
* is more than two hundred rows, and a silently truncated grid is a calendar
|
||||
* that quietly lies about a day being clear.
|
||||
*/
|
||||
function loadMonth({ from, to }) {
|
||||
app.loadTasks({ due_from: from, due_before: to, limit: 500 });
|
||||
}
|
||||
|
||||
function heading(date) {
|
||||
const day = new Date(date + 'T00:00:00');
|
||||
const today = new Date(new Date().toDateString());
|
||||
@@ -25,28 +46,80 @@
|
||||
}
|
||||
|
||||
// Reading tasks back out of the store keeps a completed row disappearing
|
||||
// immediately rather than on the next load.
|
||||
// immediately rather than on the next load. It is also what makes a task
|
||||
// dragged from one day to another land in its new section straight away: the
|
||||
// day it belongs to is decided here, not by which list the server put it in.
|
||||
const live = (date) => app.tasks.filter((t) => t.due_date === date && !t.completed);
|
||||
|
||||
// --- dragging a task onto another day -------------------------------------------
|
||||
|
||||
/** The day section lit up under the pointer. */
|
||||
let over = $state(null);
|
||||
|
||||
function onDragOver(event, date) {
|
||||
if (!carries(event, TASK)) return;
|
||||
accept(event);
|
||||
over = date;
|
||||
}
|
||||
|
||||
async function onDrop(event, date) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
over = null;
|
||||
if (!id) return;
|
||||
await app.reschedule(id, date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<header><h1>Kommende</h1></header>
|
||||
<div class="view" class:wide={upcomingView.mode === 'calendar'}>
|
||||
<header>
|
||||
<h1>Kommende</h1>
|
||||
<div class="views" role="group" aria-label="Visning">
|
||||
{#each [['list', 'Liste'], ['calendar', 'Kalender']] as [value, label]}
|
||||
<button
|
||||
class:active={upcomingView.mode === value}
|
||||
onclick={() => upcomingView.set(value)}
|
||||
aria-pressed={upcomingView.mode === value}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<QuickAdd />
|
||||
|
||||
{#each days as day (day.date)}
|
||||
{@const tasks = live(day.date)}
|
||||
<section>
|
||||
<h2>{heading(day.date)}</h2>
|
||||
{#if tasks.length}
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">—</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{#if upcomingView.mode === 'calendar'}
|
||||
<CalendarView onrange={loadMonth} />
|
||||
{:else}
|
||||
{#each days as day (day.date)}
|
||||
{@const tasks = live(day.date)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class:over={over === day.date}
|
||||
ondragover={(e) => onDragOver(e, day.date)}
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDrop(e, day.date)}
|
||||
>
|
||||
<h2>{heading(day.date)}</h2>
|
||||
{#if tasks.length}
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="row"
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
ondragend={() => (over = null)}
|
||||
>
|
||||
<TaskRow {task} />
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- The empty day is still a section, and still a target: an em dash
|
||||
is a small thing to aim at, so the whole section takes the drop. -->
|
||||
<p class="empty">—</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -56,13 +129,70 @@
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
/* A month grid needs the width a reading column deliberately withholds — the
|
||||
same allowance the project page makes for its board and calendar. */
|
||||
.view.wide {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
margin-bottom: var(--s5);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
flex: none;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.views button {
|
||||
padding: var(--s1) var(--s3);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--ground);
|
||||
color: var(--ink-muted);
|
||||
transition:
|
||||
background var(--fast) var(--ease),
|
||||
color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.views button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.views button.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: var(--s5);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* The day lights up as a whole. Its heading is the label on a box, and the box
|
||||
is what you are dropping into — not the gap between two rows, which is what a
|
||||
line would mean. */
|
||||
section.over {
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.row[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -12,7 +12,25 @@
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user