Compare commits
@@ -6,7 +6,9 @@ on:
|
||||
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# write rather than read: the job creates the GitHub Release the update notice
|
||||
# reads. packages: write is for pushing the image.
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
@@ -50,3 +52,15 @@ jobs:
|
||||
VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# A tag is not a release. The in-app update notice reads GitHub's
|
||||
# releases/latest, which answers 404 for a repository that only has tags —
|
||||
# so without this step the check can never find anything, however correctly
|
||||
# it is switched on.
|
||||
#
|
||||
# Created after the image, so a release never announces a version that
|
||||
# failed to build.
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
|
||||
+10
@@ -40,3 +40,13 @@ node_modules/
|
||||
|
||||
# Built documentation
|
||||
/site_build/
|
||||
|
||||
# Vite writes these while bundling vite.config.js and removes them again — unless
|
||||
# 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,19 @@ 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 |
|
||||
| Sharing: invites, roles, members | Done, tested |
|
||||
| Live sync over WebSocket | Done |
|
||||
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
||||
| Task detail: description, sub-tasks, comments, files, reminders | Done |
|
||||
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
||||
| Device list: where you are signed in, and signing one out | Done, tested |
|
||||
| User administration: invite to the instance, promote, remove | Done, tested |
|
||||
| Invite and password-reset links that open the right page | Done, smoke-tested |
|
||||
| Drag to reorder, in both the list and the board | Done |
|
||||
| Drag a task to another project or another day | Done, smoke-tested |
|
||||
| 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 |
|
||||
@@ -126,14 +137,6 @@ and the manifest ships in this repository. It runs anywhere Docker does as well.
|
||||
|
||||
### With Docker directly
|
||||
|
||||
The package is private, and an anonymous pull fails with `No such image` rather
|
||||
than with a permission error. Log the host's Docker daemon in once, with a GitHub
|
||||
token carrying the `read:packages` scope:
|
||||
|
||||
```bash
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run -d --name verdande \
|
||||
-p 8080:8080 \
|
||||
|
||||
@@ -77,7 +77,11 @@ func run() error {
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("listening", "addr", cfg.Addr)
|
||||
// The version is repeated here rather than only on the "starting" line
|
||||
// above: this is the line the Rune's done_regex matches, so it is the one
|
||||
// sitting at the bottom of the panel console after a deploy — which is
|
||||
// exactly when somebody wants to know what actually started.
|
||||
log.Info("listening", "addr", cfg.Addr, "version", version)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
|
||||
| `forbidden` | 403 | Cross-site request, or admin needed. |
|
||||
| `not_found` | 404 | Does not exist, or you cannot see it. |
|
||||
| `conflict` | 409 | Already exists, or the state does not allow it. |
|
||||
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
|
||||
| `ai_not_configured` | 409 | No AI provider has been set up. |
|
||||
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
|
||||
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
|
||||
| `validation_failed` | 422 | Check `fields`. |
|
||||
| `rate_limited` | 429 | Too many attempts. |
|
||||
| `internal_error` | 500 | Our fault. |
|
||||
@@ -96,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||
| `GET` | `/projects/{id}/activity` | |
|
||||
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||
|
||||
### Views, filters and labels
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
+27
-4
@@ -12,17 +12,40 @@ conversation.
|
||||
|
||||
## Connecting it
|
||||
|
||||
**Make a token.** **Settings → API tokens → New token**. It is shown once.
|
||||
**Make a token** under **Settings → API tokens**. The page then shows the
|
||||
connector address to go with it — token and all — which is the thing to copy:
|
||||
|
||||
**Add the connector.** In Claude, add a custom connector with:
|
||||
```
|
||||
https://todo.example.dk/mcp?key=vrd_…
|
||||
```
|
||||
|
||||
- **URL**: `https://todo.example.dk/api/v1/mcp`
|
||||
- **Authentication**: Bearer token, using the token you just made
|
||||
**Add the connector** in Claude with that address, and nothing else. The dialog
|
||||
asks for a name and a URL; leave the OAuth fields empty.
|
||||
|
||||
!!! note "Why the key is in the address"
|
||||
The dialog has no field for a bearer token — only OAuth client credentials —
|
||||
so a header cannot be configured from it. The address *is* the credential
|
||||
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
|
||||
Treat it like a password: anyone holding it reaches what you reach. The
|
||||
server logs request paths without their query strings, so it does not end up
|
||||
in the log.
|
||||
|
||||
!!! warning "It has to be reachable from the internet"
|
||||
Claude connects from Anthropic's side, not from your browser. An instance only
|
||||
reachable on your LAN or over a VPN cannot be used this way.
|
||||
|
||||
### With a header instead
|
||||
|
||||
Anything that *can* send a header should, and use the API path:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer vrd_…" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
||||
https://todo.example.dk/api/v1/mcp
|
||||
```
|
||||
|
||||
Both endpoints run the same server and reach the same things.
|
||||
|
||||
## What Claude can do
|
||||
|
||||
| Tool | What it does |
|
||||
|
||||
+561
-5
@@ -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]
|
||||
@@ -533,7 +571,11 @@ paths:
|
||||
type: string
|
||||
description: Where it goes when the text names no project.
|
||||
responses:
|
||||
"201": { $ref: "#/components/responses/Task" }
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/QuickAddResult" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/tasks/quick-add/preview:
|
||||
@@ -756,6 +798,35 @@ paths:
|
||||
"201": { $ref: "#/components/responses/Project" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/reorder:
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Set the order projects appear in.
|
||||
description: |
|
||||
The whole list at once rather than one move at a time. A person has a
|
||||
handful of projects, so sending the order you want is simpler than
|
||||
midpoints and cannot land half-applied.
|
||||
|
||||
Projects you do not own are skipped: `sort_order` is a column on the
|
||||
project rather than a preference per viewer, so a shared one stays where
|
||||
its owner put it.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items: { type: string }
|
||||
description: Project ids, top to bottom.
|
||||
responses:
|
||||
"204": { description: Reordered. }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/{projectID}:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
@@ -785,6 +856,259 @@ paths:
|
||||
"204": { description: Deleted. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/users:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Everybody on the instance, and the invites not yet accepted.
|
||||
description: |
|
||||
Administrators only. Each user carries what deleting them would destroy,
|
||||
so the confirmation can name real numbers without a second request in the
|
||||
middle of a decision.
|
||||
|
||||
Session only: an API token cannot enumerate the instance's membership.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AdminUser" }
|
||||
invites:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/PendingInvite" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Invite somebody to the instance.
|
||||
description: |
|
||||
There is no open registration and no account created with a password
|
||||
somebody else chose: this issues an invite with no project attached, and
|
||||
the link ends with a password only its owner has seen. The same link and
|
||||
the same `/auth/signup` as a project invite — an invite whose project is
|
||||
empty simply grants no membership when it is accepted.
|
||||
|
||||
No name is asked for. The signup form asks the person themselves, and a
|
||||
name an administrator guesses is a name somebody has to correct.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
link:
|
||||
type: string
|
||||
description: |
|
||||
Returned so the interface can offer to copy it. On an
|
||||
instance with no mail server that is the only way the invite
|
||||
reaches anybody.
|
||||
emailed: { type: boolean }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"409":
|
||||
description: That address already has an account.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/users/{userID}:
|
||||
parameters:
|
||||
- { name: userID, in: path, required: true, schema: { type: string } }
|
||||
patch:
|
||||
tags: [auth]
|
||||
summary: Make somebody an administrator, or stop them being one.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
is_admin: { type: boolean }
|
||||
responses:
|
||||
"204": { description: Changed. }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409":
|
||||
description: |
|
||||
The last administrator cannot be demoted — `code` is `last_admin`.
|
||||
An instance with no administrator has no way back: there is no
|
||||
console, and setup refuses to run once an account exists.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
delete:
|
||||
tags: [auth]
|
||||
summary: Remove an account and everything the foreign keys take with it.
|
||||
description: |
|
||||
The only hard delete in this API. Everything else is soft-deleted with a
|
||||
trash behind it; this is not recoverable.
|
||||
|
||||
What goes is wider than it looks: their projects, every task in those
|
||||
projects, **and every task they wrote anywhere** — `tasks.created_by`
|
||||
cascades, so retiring a colleague's account removes their contributions
|
||||
from shared projects too. Tasks merely assigned to them are unassigned
|
||||
and survive.
|
||||
|
||||
Refused for your own account, and for the last administrator.
|
||||
responses:
|
||||
"204": { description: Deleted. }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409":
|
||||
description: Your own account, or the last administrator.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
|
||||
/api/v1/invites/{inviteID}:
|
||||
parameters:
|
||||
- { name: inviteID, in: path, required: true, schema: { type: string } }
|
||||
delete:
|
||||
tags: [auth]
|
||||
summary: Withdraw an invite that has not been accepted.
|
||||
description: |
|
||||
Only the token's hash is stored, so a link sent to the wrong address
|
||||
cannot be looked up and resent — withdrawing it is the only way to stop
|
||||
it working before it expires.
|
||||
responses:
|
||||
"204": { description: Withdrawn. }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/project-groups:
|
||||
get:
|
||||
tags: [projects]
|
||||
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]
|
||||
summary: Projects you have deleted and can still bring back.
|
||||
description: |
|
||||
Only your own. Restoring has been possible since the trash existed, but
|
||||
the endpoint takes an id — and the id is what you no longer have once the
|
||||
project has gone from the interface.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
projects:
|
||||
type: array
|
||||
items:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Project"
|
||||
- type: object
|
||||
properties:
|
||||
deleted_at: { type: string, format: date-time }
|
||||
purge_after:
|
||||
type: string
|
||||
format: date-time
|
||||
description: |
|
||||
When it stops being recoverable. Sent rather than
|
||||
left for the client to work out, because the
|
||||
retention window is the server's and a second
|
||||
copy of it will one day disagree.
|
||||
task_count:
|
||||
type: integer
|
||||
description: Tasks that went with it, and would come back with it.
|
||||
|
||||
/api/v1/trash/projects/{projectID}/restore:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
@@ -991,6 +1315,40 @@ paths:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Task" }
|
||||
|
||||
/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]
|
||||
@@ -1718,7 +2076,15 @@ paths:
|
||||
broken volume reports unhealthy instead of pretending to work.
|
||||
security: []
|
||||
responses:
|
||||
"200": { description: Healthy. }
|
||||
"200":
|
||||
description: Healthy. Carries the running version — the one thing you can ask without a session.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, const: ok }
|
||||
version: { type: string, example: v0.3.1 }
|
||||
"503": { description: Unhealthy. }
|
||||
|
||||
/ics/{token}:
|
||||
@@ -1787,6 +2153,53 @@ paths:
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: The MCP endpoint, with the token in the query string.
|
||||
description: |
|
||||
The same server as `/api/v1/mcp`, for a client that can only be given a
|
||||
URL. Claude's custom-connector dialog asks for a name, an address and
|
||||
OAuth client credentials — there is no field for a bearer token — so the
|
||||
API path cannot be configured from it.
|
||||
|
||||
The key is the only credential accepted here. A session cookie is
|
||||
ignored: this route sits outside the CSRF check, and honouring a cookie
|
||||
would make it a cross-site request that acts as whoever is signed in.
|
||||
security: []
|
||||
parameters:
|
||||
- name: key
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string, example: vrd_… }
|
||||
description: A personal API token. Treat the whole address as a password.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [jsonrpc, method]
|
||||
properties:
|
||||
jsonrpc: { type: string, const: "2.0" }
|
||||
id: { oneOf: [{ type: string }, { type: integer }] }
|
||||
method: { type: string }
|
||||
params: { type: object }
|
||||
responses:
|
||||
"200":
|
||||
description: A JSON-RPC response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
jsonrpc: { type: string, const: "2.0" }
|
||||
id: { oneOf: [{ type: string }, { type: integer }] }
|
||||
result: { type: object }
|
||||
error: { type: object }
|
||||
"202": { description: A notification. No body. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/.well-known/caldav:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -1883,6 +2296,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:
|
||||
@@ -1899,6 +2317,13 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
address: { type: string, example: [email protected] }
|
||||
configured:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether a mail server is set up at all. The address is real
|
||||
either way, but with no mail server nothing on the internet
|
||||
routes to it — and an interface that does not say so is
|
||||
promising something that will not happen.
|
||||
Gmail:
|
||||
description: OK
|
||||
content:
|
||||
@@ -1948,6 +2373,10 @@ components:
|
||||
- rate_limited
|
||||
- internal_error
|
||||
- payload_too_large
|
||||
- gmail_not_configured
|
||||
- ai_not_configured
|
||||
- totp_not_enabled
|
||||
- inbox_protected
|
||||
error: { type: string }
|
||||
fields:
|
||||
type: object
|
||||
@@ -1975,6 +2404,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:
|
||||
@@ -2004,7 +2496,12 @@ components:
|
||||
TaskInput:
|
||||
type: object
|
||||
properties:
|
||||
project_id: { type: string, description: "Omitted on create, the task goes to the Inbox." }
|
||||
project_id:
|
||||
type: string
|
||||
description: |
|
||||
Omitted on create, the task goes to the Inbox. On update it moves the
|
||||
task — which needs editor rights on the destination.
|
||||
|
||||
section_id: { type: string }
|
||||
parent_id: { type: string }
|
||||
content: { type: string }
|
||||
@@ -2020,6 +2517,19 @@ components:
|
||||
items: { type: string }
|
||||
sort_order: { type: number }
|
||||
|
||||
QuickAddResult:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Task"
|
||||
- type: object
|
||||
properties:
|
||||
unknown_project:
|
||||
type: string
|
||||
description: |
|
||||
Present when the text named a `#project` that does not exist. The
|
||||
task is still created, in the Inbox — a thought is not thrown away
|
||||
over a typo — but the caller is told, because the alternative is
|
||||
that the name vanishes from the title and nothing says why.
|
||||
|
||||
ParsedTask:
|
||||
type: object
|
||||
description: What the quick-add parser made of a line.
|
||||
@@ -2039,7 +2549,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 }
|
||||
@@ -2049,16 +2559,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
|
||||
|
||||
+46
-16
@@ -3,33 +3,53 @@
|
||||
verdande ships with a Rune manifest for
|
||||
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
|
||||
|
||||
## Before you start: the registry is private
|
||||
|
||||
The image lives at `ghcr.io/kristianwind/verdande` and the package is **private**,
|
||||
so an anonymous pull fails with a message that reads as though the image does not
|
||||
exist at all:
|
||||
## If the image will not pull
|
||||
|
||||
```
|
||||
create container: Error response from daemon:
|
||||
No such image: ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
That is a permission error wearing the wrong hat. The Docker daemon **on the
|
||||
Yggdrasil host** has to be logged in once:
|
||||
This message is a liar. It does not mean the tag is missing from the registry —
|
||||
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||
without saying so.
|
||||
|
||||
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||
looks locally — then reports the image as missing.
|
||||
|
||||
!!! warning "`docker login` on the host does not help"
|
||||
`docker login` is client-side. The daemon stores no credentials; the CLI
|
||||
sends them itself with each request. Yggdrasil is a different client and
|
||||
sends none, so logging in changes nothing for the panel.
|
||||
|
||||
**Make the package public.** Under
|
||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||
readable, which is what the manifest and these pages have always assumed.
|
||||
|
||||
Once it is public, press **Start** on the server you already have. There is no
|
||||
need to delete it and make another: the image reference is read from the rune at
|
||||
container-create time, and both Start and Restart recreate the container.
|
||||
|
||||
To keep the package private you have to put the image on the host yourself,
|
||||
before creating the server, and again after every release:
|
||||
|
||||
```bash
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
||||
sudo docker login ghcr.io -u kristianwind
|
||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
`GHCR_TOKEN` is a GitHub personal access token with the **`read:packages`** scope
|
||||
and nothing else. The login is stored in `~/.docker/config.json` for whichever
|
||||
user runs the daemon — if Yggdrasil runs as root or through systemd, log in as
|
||||
that user, not as yourself.
|
||||
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||
panel's own pull still fails silently; create simply finds the image already
|
||||
there.
|
||||
|
||||
!!! tip "Or make the package public"
|
||||
Under **Packages → verdande → Package settings → Change visibility**. The
|
||||
repository can stay private; only the built image becomes readable. Then no
|
||||
login is needed anywhere and this section stops applying.
|
||||
!!! danger "This hides updates"
|
||||
The panel re-pulls on every recreate, fails quietly, and carries on with the
|
||||
local image. A new release then *looks* like it rolled out and did not. For
|
||||
something you intend to update, public is the right answer and host-pull is
|
||||
the stopgap.
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -40,6 +60,16 @@ that user, not as yourself.
|
||||
3. Fill in the settings below.
|
||||
4. Start it, open the URL, create the first account.
|
||||
|
||||
!!! note "Updating the rune is a manual step"
|
||||
A rune does not update itself when this manifest changes. The panel writes
|
||||
the rune row from a UI action only — neither a restart nor a timer does it —
|
||||
so pick up a new manifest with **Runes → verdande → Update**, or install it
|
||||
again from Browse GitHub.
|
||||
|
||||
The catalogue list is cached in memory for ten minutes, so two attempts in a
|
||||
row can both hand you the old version. Recent panel versions show the list's
|
||||
age with a *fetch again* link.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | What to put |
|
||||
|
||||
@@ -5,15 +5,12 @@ cache, no queue and no second process.
|
||||
|
||||
## Docker
|
||||
|
||||
!!! warning "Log in to the registry first"
|
||||
The package is private, and an anonymous pull fails with `No such image`
|
||||
rather than with a permission error. Once per host:
|
||||
|
||||
```bash
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
||||
```
|
||||
|
||||
`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
|
||||
!!! note "If the pull is refused"
|
||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||
back to private, `docker login ghcr.io` with a token carrying the
|
||||
`read:packages` scope before pulling — and see
|
||||
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||
fails differently and much more confusingly under Yggdrasil.
|
||||
|
||||
```bash
|
||||
docker run -d --name verdande \
|
||||
|
||||
@@ -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": "[email protected]", "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, "[email protected]", "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(), "[email protected]")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The comment above the code list in errors.go says they are kept in one place
|
||||
// "so the frontend's mapping can be checked against it rather than discovered
|
||||
// from whatever a handler happened to write". Nothing checked it, and the
|
||||
// frontend was quietly missing entries — which is not a cosmetic gap: a code
|
||||
// with no message falls through to the server's English log prose, and a code
|
||||
// mapped to the wrong sentence tells the person something untrue. Connecting
|
||||
// Gmail without an OAuth client said "Det er der allerede."
|
||||
|
||||
var (
|
||||
codeConst = regexp.MustCompile(`(?m)^\s*Code\w+\s*=\s*"([a-z_]+)"`)
|
||||
messageKey = regexp.MustCompile(`(?m)^\s*([a-z_]+):\s*$|^\s*([a-z_]+):\s*'`)
|
||||
messagesTop = "const MESSAGES = {"
|
||||
)
|
||||
|
||||
func TestEveryErrorCodeHasAMessageInTheFrontend(t *testing.T) {
|
||||
codes := serverErrorCodes(t)
|
||||
messages := frontendMessageKeys(t)
|
||||
|
||||
var missing []string
|
||||
for _, code := range codes {
|
||||
if !messages[code] {
|
||||
missing = append(missing, code)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
for _, code := range missing {
|
||||
t.Errorf("%q is returned by the API and has no message in web/src/lib/api.js — "+
|
||||
"a person would be shown the server's English log prose", code)
|
||||
}
|
||||
}
|
||||
|
||||
func serverErrorCodes(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile("errors.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read errors.go: %v", err)
|
||||
}
|
||||
var codes []string
|
||||
for _, m := range codeConst.FindAllStringSubmatch(string(source), -1) {
|
||||
codes = append(codes, m[1])
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
t.Fatal("no codes were read out of errors.go — the scanner and the file disagree")
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// frontendMessageKeys reads the keys of the MESSAGES object. A small scanner
|
||||
// rather than a JS parser, for the same reason the OpenAPI check uses one: it
|
||||
// needs one thing out of a file this test also owns.
|
||||
func frontendMessageKeys(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile(filepath.Join("..", "..", "web", "src", "lib", "api.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("read api.js: %v", err)
|
||||
}
|
||||
body := string(source)
|
||||
|
||||
start := strings.Index(body, messagesTop)
|
||||
if start < 0 {
|
||||
t.Fatalf("could not find %q in api.js", messagesTop)
|
||||
}
|
||||
body = body[start+len(messagesTop):]
|
||||
end := strings.Index(body, "\n};")
|
||||
if end < 0 {
|
||||
t.Fatal("the MESSAGES object does not end where this scanner expects")
|
||||
}
|
||||
|
||||
keys := map[string]bool{}
|
||||
for _, line := range strings.Split(body[:end], "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
name, _, ok := strings.Cut(line, ":")
|
||||
if ok {
|
||||
keys[strings.TrimSpace(name)] = true
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("no message keys were read out of api.js")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// The four that used to be indistinguishable from "that already exists".
|
||||
func TestTheMisleadingConflictsHaveTheirOwnCodes(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
CodeGmailNotConfigured,
|
||||
CodeAINotConfigured,
|
||||
CodeTOTPNotEnabled,
|
||||
CodeInboxProtected,
|
||||
} {
|
||||
if code == CodeConflict {
|
||||
t.Errorf("%q is still plain conflict", code)
|
||||
}
|
||||
}
|
||||
|
||||
messages := frontendMessageKeys(t)
|
||||
for _, code := range []string{"gmail_not_configured", "ai_not_configured",
|
||||
"totp_not_enabled", "inbox_protected"} {
|
||||
if !messages[code] {
|
||||
t.Errorf("%q has no Danish message", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Inbox is the one project that cannot be deleted, and saying "that already
|
||||
// exists" about it helps nobody.
|
||||
func TestDeletingTheInboxSaysWhy(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
list, _ := projects["projects"].([]any)
|
||||
|
||||
var inboxID string
|
||||
for _, raw := range list {
|
||||
p := raw.(map[string]any)
|
||||
if p["is_inbox"] == true {
|
||||
inboxID = p["id"].(string)
|
||||
}
|
||||
}
|
||||
if inboxID == "" {
|
||||
t.Fatal("no inbox was found")
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "DELETE", "/api/v1/projects/"+inboxID, nil)
|
||||
if resp.StatusCode != 409 {
|
||||
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeInboxProtected {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeInboxProtected)
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,27 @@ const (
|
||||
CodeRateLimited = "rate_limited"
|
||||
CodeInternal = "internal_error"
|
||||
CodePayloadTooLarge = "payload_too_large"
|
||||
|
||||
// Four situations that used to answer plain `conflict`, which the frontend
|
||||
// renders as "that already exists". For these that is not merely vague, it is
|
||||
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
|
||||
// second factor that is off is not one that is on. A code exists so the
|
||||
// interface can say the true thing — the server's own prose is English for a
|
||||
// log, and deciding what a person reads is the frontend's job.
|
||||
CodeGmailNotConfigured = "gmail_not_configured"
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, "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})
|
||||
}
|
||||
|
||||
@@ -263,6 +264,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "update label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -272,5 +274,6 @@ func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "delete label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.gmailConfig()
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"Gmail er ikke sat op på denne server — administratoren skal registrere en OAuth-klient hos Google")
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
"no Google OAuth client is registered on this server")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
|
||||
type mailAddressResponse struct {
|
||||
Address string `json:"address"`
|
||||
// Configured says whether a mail server has been set up at all. Without one
|
||||
// the address is still minted and still valid — the token is real, and the
|
||||
// endpoint that receives mail works — but nothing on the internet routes to
|
||||
// it yet, and an interface that shows the address without saying so is
|
||||
// promising something that will not happen.
|
||||
Configured bool `json:"configured"`
|
||||
}
|
||||
|
||||
// handleGetMailAddress returns the personal address that turns an email into a
|
||||
@@ -34,7 +40,10 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, "mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
Address: s.mailAddress(token),
|
||||
Configured: s.cfg.SMTP.Host != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -47,7 +56,10 @@ func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request)
|
||||
s.internal(w, "set mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
Address: s.mailAddress(token),
|
||||
Configured: s.cfg.SMTP.Host != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) mailAddress(token string) string {
|
||||
@@ -55,7 +67,15 @@ func (s *Server) mailAddress(token string) string {
|
||||
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
|
||||
// The mail server's own domain, which is the one that can actually route
|
||||
// this — the web address may well be a tunnel hostname that receives no mail.
|
||||
domain = from[strings.Index(from, "@")+1:]
|
||||
//
|
||||
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
|
||||
// verdande@localhost, so an instance with no mail server at all was
|
||||
// handing out todo+…@localhost: an address that looks real, cannot
|
||||
// possibly receive anything, and gives no hint which of the two it is.
|
||||
// The public hostname is at least a domain that exists.
|
||||
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
|
||||
domain = d
|
||||
}
|
||||
}
|
||||
return "todo+" + token + "@" + domain
|
||||
}
|
||||
@@ -251,8 +271,8 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
// Not an error the user did anything to cause: the feature is simply off.
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||
"no AI provider is configured")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,8 +319,8 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||
"no AI provider is configured")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -771,3 +771,385 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
|
||||
t.Error("the current version is hidden from members")
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP with the key in the URL --------------------------------------------------
|
||||
|
||||
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
|
||||
// token — so this is the only shape that can be configured from it.
|
||||
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var userID string
|
||||
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ts.apiToken(t, userID)
|
||||
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
t.Fatalf("the response was not JSON-RPC: %v", err)
|
||||
}
|
||||
result, _ := decoded["result"].(map[string]any)
|
||||
if result == nil || result["protocolVersion"] == nil {
|
||||
t.Errorf("no protocol version was announced: %v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
|
||||
// SPA fallback and answered 200 with the app shell. A connector pointed at it
|
||||
// reported success and then failed to parse a page of HTML.
|
||||
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A revoked key stops working, like any other.
|
||||
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var userID string
|
||||
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ts.apiToken(t, userID)
|
||||
|
||||
var id string
|
||||
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The session cookie must NOT work here. This route sits outside the CSRF check,
|
||||
// so a cookie-authenticated POST would be a cross-site request that acts as
|
||||
// whoever is signed in — the exact hole the key-only rule exists to close.
|
||||
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// ts.client holds the session from bootstrap. No key in the URL.
|
||||
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
|
||||
resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- mail to task ------------------------------------------------------------------
|
||||
|
||||
// The address used to come out as todo+…@localhost on any instance without a
|
||||
// mail server, because VERDANDE_SMTP_FROM defaults to verdande@localhost and the
|
||||
// domain was taken from it unconditionally. That address looks real, cannot
|
||||
// receive anything, and gives no hint which of the two it is.
|
||||
func TestMailAddressDoesNotClaimToBeLocalhost(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://todo.example.dk"
|
||||
// Left at the default, as an instance with no mail server has it.
|
||||
c.SMTP.From = "verdande@localhost"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
address, _ := body["address"].(string)
|
||||
|
||||
if strings.HasSuffix(address, "@localhost") {
|
||||
t.Errorf("address = %q — that can never receive mail", address)
|
||||
}
|
||||
if !strings.HasSuffix(address, "@todo.example.dk") {
|
||||
t.Errorf("address = %q, want the public hostname", address)
|
||||
}
|
||||
if body["configured"] != false {
|
||||
t.Error("no SMTP host is set, so configured should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// A real sender address still wins: the mail server's own domain is the one that
|
||||
// can actually route this, and the web address may be a tunnel hostname.
|
||||
func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://tunnel-abc123.example.net"
|
||||
c.SMTP.Host = "mail.firma.dk"
|
||||
c.SMTP.From = "[email protected]"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
address, _ := body["address"].(string)
|
||||
|
||||
if !strings.HasSuffix(address, "@firma.dk") {
|
||||
t.Errorf("address = %q, want the sender's domain", address)
|
||||
}
|
||||
if body["configured"] != true {
|
||||
t.Error("an SMTP host is set, so configured should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// A 200 with the app shell is an answer that says "yes, that exists" to anything
|
||||
// that asks. For /.well-known/ that is actively harmful: it is a reserved
|
||||
// namespace, and a client probing it is asking a yes-or-no question.
|
||||
//
|
||||
// Claude asks /.well-known/oauth-authorization-server before connecting to an
|
||||
// MCP server. It read the shell as "there is an authorization server here" and
|
||||
// then failed trying to register with it, reporting a broken sign-in service on
|
||||
// a server that has none and needs none.
|
||||
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, path := range []string{
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/oauth-protected-resource",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/anything-at-all",
|
||||
} {
|
||||
resp, err := ts.client.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(string(body), "<!doctype html") {
|
||||
t.Errorf("%s: answered with the app shell", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one well-known path that is real still works — it is a route, not a
|
||||
// fallthrough, so the rule above must not have swallowed it.
|
||||
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
resp, err := client.Get(ts.URL + "/.well-known/caldav")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
t.Error("CalDAV discovery was caught by the well-known rule")
|
||||
}
|
||||
}
|
||||
|
||||
// --- what the API accepts, it must actually do -------------------------------------
|
||||
|
||||
// project_id on an update used to be validated and then dropped: the destination
|
||||
// was permission-checked, which reads as "understood", and the task never moved
|
||||
// while the answer said OK. Both the REST path and the MCP tool did it.
|
||||
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
|
||||
taskID := task["id"].(string)
|
||||
if task["project_id"] == projectID {
|
||||
t.Fatal("the task started in the destination; the test proves nothing")
|
||||
}
|
||||
|
||||
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"project_id": projectID})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
|
||||
}
|
||||
if updated["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
|
||||
updated["project_id"], projectID)
|
||||
}
|
||||
|
||||
// And it really is there, not merely reported as there.
|
||||
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if reread["project_id"] != projectID {
|
||||
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
|
||||
taskID := created["id"].(string)
|
||||
|
||||
moved := ts.callTool(t, "update_task", map[string]any{
|
||||
"task_id": taskID, "project_id": projectID,
|
||||
})
|
||||
if moved["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
|
||||
}
|
||||
}
|
||||
|
||||
// A project you cannot write to is refused rather than quietly ignored.
|
||||
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
|
||||
taskID := task["id"].(string)
|
||||
mine := task["project_id"]
|
||||
|
||||
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"project_id": theirs["id"]})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if reread["project_id"] != mine {
|
||||
t.Error("the task moved into somebody else's project")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown #project is still not an error — the thought is kept rather than
|
||||
// thrown away over a typo — but it is no longer silent. It used to vanish from
|
||||
// the title while the task went to the Inbox, with nothing saying why.
|
||||
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
|
||||
}
|
||||
if body["unknown_project"] != "3Dekoration" {
|
||||
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
|
||||
}
|
||||
if body["content"] != "Lav plakat" {
|
||||
t.Errorf("content = %v", body["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Lav plakat #Dekoration"})
|
||||
if _, present := body["unknown_project"]; present {
|
||||
t.Errorf("reported an unknown project for one that exists: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||
note, _ := created["note"].(string)
|
||||
if !strings.Contains(note, "3Dekoration") {
|
||||
t.Errorf("note = %q, want it to name the project that does not exist", note)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
|
||||
// with seven children arrived as eight unrelated tasks.
|
||||
func TestMCPCreateMakesSubtasks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
parent := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Runen v2", "project_id": project["id"],
|
||||
})
|
||||
parentID := parent["id"].(string)
|
||||
|
||||
child := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Tegn ikonet", "parent_id": parentID,
|
||||
})
|
||||
if child["parent_id"] != parentID {
|
||||
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
|
||||
}
|
||||
// A sub-task joins its parent's project whatever else was asked for.
|
||||
if child["project_id"] != project["id"] {
|
||||
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
|
||||
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
|
||||
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
|
||||
child := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Tegn ikonet", "parent_id": parent["id"],
|
||||
})
|
||||
|
||||
// A tool that refuses reports it inside the result with isError, not as a
|
||||
// JSON-RPC error — the call itself was well formed, the arguments were not.
|
||||
response := ts.rpc(t, "tools/call", map[string]any{
|
||||
"name": "create_task",
|
||||
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
|
||||
})
|
||||
result, _ := response["result"].(map[string]any)
|
||||
if result == nil || result["isError"] != true {
|
||||
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/mcp"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
@@ -55,7 +58,8 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
@@ -124,6 +128,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleMCPWithKey is the same endpoint for a client that can only be given a
|
||||
// URL: `POST /mcp?key=vrd_…`.
|
||||
//
|
||||
// Claude's custom-connector dialog asks for a name, an address and — under
|
||||
// advanced settings — an OAuth client id and secret. There is no field for a
|
||||
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
|
||||
// address is. The calendar feed already carries its credential in the URL for
|
||||
// the same reason, and the request logger records the path without the query, so
|
||||
// the key does not end up in the log.
|
||||
//
|
||||
// The key is the *only* thing accepted here. Reading a session cookie instead
|
||||
// would make this a state-changing POST outside the CSRF check — which is to say
|
||||
// a cross-site request that acts as whoever is signed in.
|
||||
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" || !auth.IsAPIToken(key) {
|
||||
// 401 rather than 404: unlike a task, the existence of this endpoint is
|
||||
// not a secret — it is written in the documentation.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"add ?key=<api token> to the URL")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "mcp key", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
|
||||
}
|
||||
|
||||
// --- tool implementations ------------------------------------------------------------
|
||||
|
||||
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
@@ -215,6 +255,7 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
Text string `json:"text"`
|
||||
Content string `json:"content"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
DueDate string `json:"due_date"`
|
||||
Priority int `json:"priority"`
|
||||
Description string `json:"description"`
|
||||
@@ -230,6 +271,11 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
task := &store.Task{CreatedBy: userID, Priority: 4}
|
||||
labels := params.Labels
|
||||
|
||||
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
|
||||
// title while the task went to the Inbox, with nothing anywhere saying so —
|
||||
// invisible in a chat, where nobody sees the row that was written.
|
||||
unknownProject := ""
|
||||
|
||||
if text := strings.TrimSpace(params.Text); text != "" {
|
||||
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
task.Content = parsed.Content
|
||||
@@ -242,6 +288,8 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
if parsed.Project != "" && params.ProjectID == "" {
|
||||
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
||||
task.ProjectID = id
|
||||
} else {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +315,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
if params.ProjectID != "" {
|
||||
task.ProjectID = params.ProjectID
|
||||
}
|
||||
|
||||
// A sub-task belongs to its parent's project, whatever else was asked for:
|
||||
// the two cannot disagree, and the parent is the more specific instruction.
|
||||
// Without this the tools could not build a tree at all — an import of a
|
||||
// parent with seven children arrived as eight unrelated tasks.
|
||||
if params.ParentID != "" {
|
||||
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
|
||||
if err != nil {
|
||||
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
|
||||
}
|
||||
if parent.ParentID != "" {
|
||||
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
|
||||
}
|
||||
task.ParentID = parent.ID
|
||||
task.ProjectID = parent.ProjectID
|
||||
}
|
||||
|
||||
if task.ProjectID == "" {
|
||||
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
@@ -281,10 +346,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
}
|
||||
|
||||
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
||||
return map[string]any{
|
||||
|
||||
out := map[string]any{
|
||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||
"due_date": task.DueDate, "project_id": task.ProjectID,
|
||||
}, nil
|
||||
}
|
||||
if task.ParentID != "" {
|
||||
out["parent_id"] = task.ParentID
|
||||
}
|
||||
if unknownProject != "" {
|
||||
// Phrased for a model to relay: it says what happened and what to do,
|
||||
// so the person is told rather than left with a task in the wrong place.
|
||||
out["note"] = fmt.Sprintf(
|
||||
"There is no project called %q, so this went to the Inbox. "+
|
||||
"Create the project and move it, or say which project you meant.",
|
||||
unknownProject)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
@@ -326,8 +404,11 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
||||
if params.Labels != nil {
|
||||
update.SetLabels, update.Labels = true, *params.Labels
|
||||
}
|
||||
// Moved before the field update, and through the same helper the REST handler
|
||||
// uses. This used to check the caller's rights on the destination and then
|
||||
// drop the argument, so the task never moved and the answer still said OK.
|
||||
if params.ProjectID != nil {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
|
||||
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
||||
}
|
||||
}
|
||||
@@ -341,9 +422,13 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
||||
}
|
||||
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
||||
|
||||
// project_id is in the answer because it is one of the things that can be
|
||||
// changed here: a caller that asked for a move can see whether it happened
|
||||
// rather than taking "OK" on trust.
|
||||
return map[string]any{
|
||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||
"due_date": task.DueDate, "labels": task.Labels,
|
||||
"project_id": task.ProjectID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "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, "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, "update project group", err)
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, "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, "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, "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, "[email protected]", "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, "[email protected]", "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"])
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -23,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 {
|
||||
@@ -30,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +71,44 @@ 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 {
|
||||
// IDs in the order they should appear, top to bottom.
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// handleReorderProjects sets the sidebar order.
|
||||
//
|
||||
// The whole list rather than one move: a person has a handful of projects, and
|
||||
// sending the order you want is both simpler to reason about and impossible to
|
||||
// get into the half-ordered state a sequence of individual moves can produce if
|
||||
// one of them fails.
|
||||
//
|
||||
// Projects the caller does not own are skipped — sort_order is a column on the
|
||||
// project, so a shared one sits where its owner put it.
|
||||
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// A ceiling rather than a guess at what is reasonable: this is a bound on
|
||||
// the transaction, not an opinion about how many projects somebody may have.
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder projects", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -79,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
|
||||
}
|
||||
@@ -99,6 +142,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -114,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
|
||||
}
|
||||
@@ -127,6 +177,17 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "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, "set project group", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.activity(r, projectID, "", "project.updated", nil)
|
||||
|
||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||
@@ -134,6 +195,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "get project", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||
}
|
||||
|
||||
@@ -141,16 +203,49 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
|
||||
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "the Inbox cannot be deleted")
|
||||
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "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)
|
||||
}
|
||||
|
||||
type trashedProjectJSON struct {
|
||||
projectJSON
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
// PurgeAfter is when it stops being recoverable. Sent rather than left for
|
||||
// the frontend to work out, because the retention window is the server's and
|
||||
// a copy of it in the interface is a copy that will one day disagree.
|
||||
PurgeAfter string `json:"purge_after"`
|
||||
TaskCount int `json:"task_count"`
|
||||
}
|
||||
|
||||
// handleListTrashedProjects is what makes restoring reachable. The restore
|
||||
// endpoint has always existed; without this, using it meant knowing the id of
|
||||
// something that had already vanished from the interface.
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]trashedProjectJSON, 0, len(trashed))
|
||||
for _, t := range trashed {
|
||||
out = append(out, trashedProjectJSON{
|
||||
projectJSON: toProjectJSON(t.Project),
|
||||
DeletedAt: t.DeletedAt.Format(time.RFC3339),
|
||||
PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339),
|
||||
TaskCount: t.TaskCount,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
// Permission cannot be checked through ProjectRole here: a trashed project is
|
||||
@@ -171,7 +266,12 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateProject(name string, viewMode *string) map[string]string {
|
||||
// colorFieldError names the closed set rather than listing it. The list is ten
|
||||
// long and lives in three places already; a caller that got it wrong needs to be
|
||||
// pointed at the documentation, not handed a copy that will one day be stale.
|
||||
const colorFieldError = "must be one of the colours in the palette — see the Color schema"
|
||||
|
||||
func validateProject(name string, viewMode, color *string) map[string]string {
|
||||
fields := map[string]string{}
|
||||
switch {
|
||||
case name == "":
|
||||
@@ -186,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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Restoring has been possible since the trash existed. Finding what to restore
|
||||
// has not: the endpoint takes an id, and the id is exactly what you no longer
|
||||
// have once the project has gone from the interface.
|
||||
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
|
||||
id := created["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Gone from the ordinary listing.
|
||||
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
for _, raw := range live["projects"].([]any) {
|
||||
if raw.(map[string]any)["id"] == id {
|
||||
t.Fatal("a deleted project is still in the project list")
|
||||
}
|
||||
}
|
||||
|
||||
// And findable in the trash, with enough to decide whether to bring it back.
|
||||
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||
list, _ := trash["projects"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("want one project in the trash, got %v", list)
|
||||
}
|
||||
entry := list[0].(map[string]any)
|
||||
if entry["name"] != "Ferie" {
|
||||
t.Errorf("name = %v", entry["name"])
|
||||
}
|
||||
if entry["task_count"] != float64(1) {
|
||||
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
|
||||
entry["task_count"])
|
||||
}
|
||||
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
|
||||
t.Errorf("no deadline was reported: %v", entry)
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("restore: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Back, with its task.
|
||||
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
|
||||
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
|
||||
t.Errorf("the task did not come back with the project: %v", got)
|
||||
}
|
||||
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||
t.Errorf("still in the trash after restoring: %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's deleted project is not in your trash, and you cannot restore it.
|
||||
func TestTheTrashIsPerOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||
id := created["id"].(string)
|
||||
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||
t.Errorf("somebody else's deleted project is visible: %v", list)
|
||||
}
|
||||
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var ids []string
|
||||
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
|
||||
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
|
||||
ids = append(ids, body["id"].(string))
|
||||
}
|
||||
|
||||
// Back to front.
|
||||
reversed := []string{ids[2], ids[1], ids[0]}
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
projects, _ := list["projects"].([]any)
|
||||
|
||||
var got []string
|
||||
for _, raw := range projects {
|
||||
p := raw.(map[string]any)
|
||||
// The Inbox sorts ahead of everything by its own rule; it is not part of
|
||||
// what was reordered.
|
||||
if p["is_inbox"] == true {
|
||||
continue
|
||||
}
|
||||
got = append(got, p["id"].(string))
|
||||
}
|
||||
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
|
||||
t.Errorf("order = %v, want %v", got, reversed)
|
||||
}
|
||||
}
|
||||
|
||||
// sort_order is a column on the project, not a per-viewer preference, so
|
||||
// reordering must not rearrange a project somebody else owns.
|
||||
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||
theirID := theirs["id"].(string)
|
||||
|
||||
_, before := other.do(t, "GET", "/api/v1/projects", nil)
|
||||
var wanted float64
|
||||
for _, raw := range before["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == theirID {
|
||||
wanted = p["sort_order"].(float64)
|
||||
}
|
||||
}
|
||||
|
||||
// Ask, as somebody else, to put their project first.
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
|
||||
"ids": []string{theirID},
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := other.do(t, "GET", "/api/v1/projects", nil)
|
||||
for _, raw := range after["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == theirID {
|
||||
if p["sort_order"] != wanted {
|
||||
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
|
||||
wanted, p["sort_order"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -90,6 +90,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// authenticated by the token in the recipient address.
|
||||
r.Post("/inbound/mail", s.handleInboundMail)
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
||||
// it at all. Same reasoning as the calendar feed above: a client that cannot
|
||||
// be told to send a header means the token in the URL is the credential.
|
||||
//
|
||||
// Outside /api/v1 deliberately, because it must not be reachable with a
|
||||
// session cookie. A cookie-authenticated POST with no CSRF check is a
|
||||
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
|
||||
// nothing else.
|
||||
r.Post("/mcp", s.handleMCPWithKey)
|
||||
|
||||
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
||||
// top-level navigation rather than a fetch, but still behind the session — the
|
||||
// tokens it exchanges belong to whoever is signed in.
|
||||
@@ -139,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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,11 +263,14 @@ 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("/search", s.handleSearch)
|
||||
|
||||
r.Route("/projects", func(r chi.Router) {
|
||||
r.Get("/", s.handleListProjects)
|
||||
r.Post("/", s.handleCreateProject)
|
||||
// Above /{projectID}, or chi reads "reorder" as an id.
|
||||
r.Post("/reorder", s.handleReorderProjects)
|
||||
|
||||
r.Route("/{projectID}", func(r chi.Router) {
|
||||
// Read access is the floor for the whole subtree; the writes
|
||||
@@ -273,9 +297,35 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
})
|
||||
|
||||
// Restoring is outside the block above: a trashed project is invisible
|
||||
// to the permission check by design, so the handler resolves ownership
|
||||
// itself.
|
||||
// The instance's membership. Administrators only, and behind
|
||||
// requireSession as well as requireAdmin: an API token belongs to a
|
||||
// person, and a leaked one must not be able to mint an account or
|
||||
// delete somebody else's.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession, s.requireAdmin)
|
||||
r.Get("/users", s.handleListUsers)
|
||||
r.Post("/users", s.handleCreateUser)
|
||||
r.Patch("/users/{userID}", s.handleUpdateUser)
|
||||
r.Delete("/users/{userID}", s.handleDeleteUser)
|
||||
r.Delete("/invites/{inviteID}", s.handleDeleteInvite)
|
||||
})
|
||||
|
||||
// Groups are a person's own headings over their projects, so there is
|
||||
// no project permission to check — every query here is scoped to the
|
||||
// caller.
|
||||
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.
|
||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
@@ -336,7 +386,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// The version travels with the health check because this is the one endpoint
|
||||
// that answers without a session. "Which version is actually running?" is
|
||||
// otherwise a question you cannot ask from outside — and after a deploy it is
|
||||
// the first question there is.
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": Version})
|
||||
}
|
||||
|
||||
// serveWeb serves the PWA, falling back to index.html so that client-side routes
|
||||
@@ -348,6 +402,21 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
|
||||
// Handing back the shell there does not merely waste a response: it tells
|
||||
// whoever asked that the thing they were looking for exists.
|
||||
//
|
||||
// That is not hypothetical. Claude probes
|
||||
// /.well-known/oauth-authorization-server before connecting to an MCP server,
|
||||
// read 200 and a page of HTML as "there is an authorization server here", and
|
||||
// then failed trying to register with it — reporting a broken sign-in service
|
||||
// on a server that has none and needs none. A 404 means "no OAuth here", the
|
||||
// client stops asking, and the key in the URL is used as intended.
|
||||
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -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, "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, "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]) + "…"
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -249,6 +251,16 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A change of project is a move, not a field: it has to renumber the task
|
||||
// among its new neighbours. Done before the rest so a request that both moves
|
||||
// and edits ends up entirely applied or entirely refused.
|
||||
if req.ProjectID != nil {
|
||||
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
u := store.TaskUpdate{
|
||||
Description: req.Description, Priority: req.Priority,
|
||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||
@@ -473,11 +485,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
projectID := req.ProjectID
|
||||
// Set when the text named a project that does not exist. The task is still
|
||||
// created — see below — but the caller is told, because the alternative is
|
||||
// what it used to be: "#Dekoration" disappears from the title, the task lands
|
||||
// somewhere else, and nothing anywhere says why.
|
||||
unknownProject := ""
|
||||
if parsed.Project != "" {
|
||||
// An unknown "#name" is not an error: the task still gets created, in the
|
||||
// Inbox, rather than a thought being thrown away over a typo.
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
} else {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
@@ -510,6 +529,16 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
t.Labels = parsed.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -525,6 +554,42 @@ func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- helpers --------------------------------------------------------------------
|
||||
|
||||
// moveTaskToProject moves a task to another project, if that is really what was
|
||||
// asked for.
|
||||
//
|
||||
// Shared by the REST handler and the MCP tool, because they had drifted: both
|
||||
// accepted project_id on an update, both checked the caller's rights on the
|
||||
// destination — which reads as "understood" — and then neither passed it on,
|
||||
// because store.TaskUpdate has no such field. The task stayed where it was and
|
||||
// the response said OK. An argument that is validated and then dropped is worse
|
||||
// than one that is refused.
|
||||
//
|
||||
// A move rather than a field assignment: sort_order is per project, so landing
|
||||
// in a new one means being given a position among its tasks.
|
||||
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
|
||||
if projectID == "" {
|
||||
return nil
|
||||
}
|
||||
current, err := s.db.GetTask(ctx, taskID, userID)
|
||||
if err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
if current.ProjectID == projectID {
|
||||
return nil // already there; not worth a write or a renumber
|
||||
}
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
|
||||
// No neighbours: it lands at the start of the destination, which is the only
|
||||
// answer available when the caller did not say where. Section is cleared —
|
||||
// a section belongs to the project being left behind.
|
||||
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTaskContent(content string, priority *int) map[string]string {
|
||||
fields := map[string]string{}
|
||||
switch {
|
||||
|
||||
@@ -116,7 +116,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
writeError(w, http.StatusConflict, CodeTOTPNotEnabled,
|
||||
"two-factor authentication is not switched on")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Who is on the instance, and how somebody else gets on it.
|
||||
//
|
||||
// There is no open registration and there never was: an account comes into
|
||||
// existence in exactly two ways — the first admin at setup, and somebody accepting
|
||||
// an invite. This surface is the second one made reachable without first sharing a
|
||||
// project, which is what "create a user" turns out to mean here.
|
||||
//
|
||||
// The whole file is administrators only.
|
||||
|
||||
type adminUserJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
// Self marks the caller, so the interface can grey out the two things nobody
|
||||
// should do to their own account from here.
|
||||
Self bool `json:"self"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
// ProjectCount and TaskCount are what a delete would destroy. Sent with the
|
||||
// list rather than fetched when the button is pressed, so the confirmation can
|
||||
// name real numbers without a round trip in the middle of a decision.
|
||||
ProjectCount int `json:"project_count"`
|
||||
TaskCount int `json:"task_count"`
|
||||
}
|
||||
|
||||
type pendingInviteJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
// ProjectName is empty for an invite to the instance itself, which is what
|
||||
// this page issues. The two read very differently to whoever is looking.
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
me := userFrom(r.Context())
|
||||
|
||||
users, err := s.db.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "list users", err)
|
||||
return
|
||||
}
|
||||
invites, err := s.db.ListPendingInvites(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "list pending invites", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]adminUserJSON, 0, len(users))
|
||||
for _, u := range users {
|
||||
j := adminUserJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
|
||||
}
|
||||
// Zero means never signed in, which is not a time and must not be sent as
|
||||
// one — 1970 in an interface reads as a bug rather than as "never".
|
||||
if !u.LastSeenAt.IsZero() {
|
||||
j.LastSeenAt = u.LastSeenAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
|
||||
pending := make([]pendingInviteJSON, 0, len(invites))
|
||||
for _, i := range invites {
|
||||
pending = append(pending, pendingInviteJSON{
|
||||
ID: i.ID, Email: i.Email, ProjectName: i.ProjectName, Role: string(i.Role),
|
||||
InvitedBy: i.InvitedBy,
|
||||
CreatedAt: i.CreatedAt.Format(time.RFC3339),
|
||||
ExpiresAt: i.ExpiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out, "invites": pending})
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// handleCreateUser invites somebody to the instance.
|
||||
//
|
||||
// An invite rather than an account with a password the administrator chooses. A
|
||||
// password typed here would be known to two people before it was ever used, and
|
||||
// would have to travel to the second one by some channel nobody has thought about.
|
||||
// The link does the same job and ends with a password only its owner has seen.
|
||||
//
|
||||
// No name is asked for, because the signup form asks the person themselves — a
|
||||
// name an administrator guesses is a name somebody has to correct.
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req createUserRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
inviter := userFrom(r.Context())
|
||||
|
||||
email := store.NormalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||
return
|
||||
}
|
||||
if _, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
||||
return
|
||||
}
|
||||
|
||||
// RoleEditor is carried on the row because the column requires a role, and it
|
||||
// is never read: AcceptInvite returns early when there is no project to grant
|
||||
// anything on.
|
||||
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
|
||||
inviter.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create instance invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
|
||||
emailed := false
|
||||
if s.mail.Configured() {
|
||||
if err := s.mail.SendInvite(r.Context(), email, inviter.Name, "", link, s.cfg.InviteTTL); err != nil {
|
||||
s.log.Error("send instance invite", "err", err, "to", email)
|
||||
} else {
|
||||
emailed = true
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// handleUpdateUser promotes or demotes an administrator.
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req updateUserRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
userID := chi.URLParam(r, "userID")
|
||||
me := userFrom(r.Context())
|
||||
|
||||
if req.IsAdmin == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// Standing down as the last administrator locks everybody out of this page,
|
||||
// and there is no console and no second setup run to undo it with.
|
||||
if !*req.IsAdmin {
|
||||
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be demoted"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
|
||||
s.storeError(w, "set user admin", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDeleteUser removes an account and everything the foreign keys take with
|
||||
// it. See store.DeleteUser for what that is; it is more than it looks.
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
me := userFrom(r.Context())
|
||||
|
||||
// Not yourself. Deleting the account you are signed in as would take your own
|
||||
// projects while you were looking at them, and end the session mid-request.
|
||||
// Somebody who really means it can be demoted first and removed by another
|
||||
// administrator.
|
||||
if userID == me.ID {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"you cannot delete the account you are signed in as")
|
||||
return
|
||||
}
|
||||
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be deleted"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
|
||||
s.storeError(w, "delete user", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("user deleted", "by", me.ID, "user", userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
|
||||
s.storeError(w, "delete invite", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// refuseIfLastAdmin writes a 409 and returns an error when the named user is the
|
||||
// only administrator left. The two callers both need it, and getting it wrong in
|
||||
// one of them is an instance nobody can administer again.
|
||||
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
|
||||
target, err := s.db.UserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get user", err)
|
||||
return err
|
||||
}
|
||||
if !target.IsAdmin {
|
||||
return nil
|
||||
}
|
||||
admins, err := s.db.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "count admins", err)
|
||||
return err
|
||||
}
|
||||
if admins <= 1 {
|
||||
writeError(w, http.StatusConflict, CodeLastAdmin, message)
|
||||
return errLastAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The instance's membership is an administrator's list, and creating an account
|
||||
// means issuing an invite — there is no other door, and no password chosen by
|
||||
// somebody other than its owner.
|
||||
func TestAnAdminCanInviteSomebodyToTheInstance(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "[email protected]"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
link, _ := body["link"].(string)
|
||||
if link == "" {
|
||||
t.Fatal("no link came back — with no mail server it is the only way the invite reaches anybody")
|
||||
}
|
||||
if body["emailed"] != false {
|
||||
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
|
||||
}
|
||||
|
||||
// It shows as pending, with no project attached: this is an invite to the
|
||||
// instance, not to somewhere in it.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||
invites, _ := listed["invites"].([]any)
|
||||
if len(invites) != 1 {
|
||||
t.Fatalf("want one pending invite, got %v", invites)
|
||||
}
|
||||
invite := invites[0].(map[string]any)
|
||||
if invite["email"] != "[email protected]" {
|
||||
t.Errorf("email = %v", invite["email"])
|
||||
}
|
||||
if name, ok := invite["project_name"]; ok && name != "" {
|
||||
t.Errorf("project_name = %v, want none for an instance invite", name)
|
||||
}
|
||||
|
||||
// And the token in the link creates the account, through the same signup the
|
||||
// project invites use.
|
||||
token := link[len(link)-len(link)+indexAfter(link, "token="):]
|
||||
fresh := newSignedOut(t, ts)
|
||||
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
||||
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("signup with the instance invite: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// They are on the instance, and they are not an administrator.
|
||||
_, after := ts.do(t, "GET", "/api/v1/users", nil)
|
||||
users, _ := after["users"].([]any)
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("want two accounts, got %v", users)
|
||||
}
|
||||
for _, raw := range users {
|
||||
u := raw.(map[string]any)
|
||||
if u["email"] == "[email protected]" && u["is_admin"] != false {
|
||||
t.Errorf("a newly invited account is an administrator: %v", u)
|
||||
}
|
||||
}
|
||||
// The invite is used up rather than left live.
|
||||
if pending, _ := after["invites"].([]any); len(pending) != 0 {
|
||||
t.Errorf("the invite is still pending after being accepted: %v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// The two refusals that make this page safe to have at all.
|
||||
func TestTheLastAdministratorCannotBeRemoved(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Demoting yourself as the only administrator locks everybody out: there is no
|
||||
// console, and setup refuses to run once an account exists.
|
||||
resp, body := ts.do(t, "PATCH", "/api/v1/users/"+me.ID, map[string]any{"is_admin": false})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("demote the last admin: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != "last_admin" {
|
||||
t.Errorf("code = %v, want last_admin — the interface has to explain this one", body["code"])
|
||||
}
|
||||
|
||||
// And deleting the account you are signed in as is refused before that check
|
||||
// is even reached.
|
||||
resp, _ = ts.do(t, "DELETE", "/api/v1/users/"+me.ID, nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("delete your own account: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Still an administrator, and still there.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||
first := listed["users"].([]any)[0].(map[string]any)
|
||||
if first["is_admin"] != true || first["self"] != true {
|
||||
t.Errorf("the only administrator is no longer one: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// With a second administrator the refusal lifts — it is about the last one, not
|
||||
// about administrators.
|
||||
func TestASecondAdministratorCanBeDemoted(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
other, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
|
||||
map[string]any{"is_admin": true}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("promote: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
|
||||
map[string]any{"is_admin": false}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("demote a second administrator: status %d, want 204", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an account takes more than its own projects, and the list says so
|
||||
// before anybody presses the button.
|
||||
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// One project of their own, with a task in it.
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "deres egen", "project_id": theirs["id"].(string),
|
||||
})
|
||||
|
||||
// And a task they wrote in a project somebody else owns. tasks.created_by
|
||||
// cascades, so this one goes too — and a count that missed it would understate
|
||||
// the damage in exactly the case that matters.
|
||||
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||
sharedID := mine["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "editor",
|
||||
})
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "skrevet i et delt projekt", "project_id": sharedID,
|
||||
})
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||
var found map[string]any
|
||||
for _, raw := range listed["users"].([]any) {
|
||||
if u := raw.(map[string]any); u["id"] == otherUser.ID {
|
||||
found = u
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the second account is not in the list")
|
||||
}
|
||||
if found["project_count"] != float64(1) {
|
||||
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
|
||||
}
|
||||
if found["task_count"] != float64(2) {
|
||||
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
|
||||
found["task_count"])
|
||||
}
|
||||
|
||||
// And the delete really does take it.
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||
}
|
||||
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
|
||||
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
|
||||
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Everything here is administrators only, and not reachable with an API token
|
||||
// even by an administrator: a leaked token must not be able to mint an account.
|
||||
func TestTheUserPageIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, c := range []struct {
|
||||
what, method, path string
|
||||
body any
|
||||
}{
|
||||
{"list", "GET", "/api/v1/users", nil},
|
||||
{"invite", "POST", "/api/v1/users", map[string]any{"email": "[email protected]"}},
|
||||
{"promote", "PATCH", "/api/v1/users/whoever", map[string]any{"is_admin": true}},
|
||||
{"delete", "DELETE", "/api/v1/users/whoever", nil},
|
||||
{"revoke", "DELETE", "/api/v1/invites/whichever", nil},
|
||||
} {
|
||||
if resp, _ := other.do(t, c.method, c.path, c.body); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", c.what, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("an administrator's API token reached the user list: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending invite can be withdrawn, which is the only way to stop a link that
|
||||
// went to the wrong address: the token is stored as a hash and cannot be looked up.
|
||||
func TestAPendingInviteCanBeWithdrawn(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, created := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "[email protected]"})
|
||||
link := created["link"].(string)
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||
inviteID := listed["invites"].([]any)[0].(map[string]any)["id"].(string)
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/invites/"+inviteID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("withdraw: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The link no longer works.
|
||||
token := link[indexAfter(link, "token="):]
|
||||
fresh := newSignedOut(t, ts)
|
||||
resp, _ := fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
||||
"token": token, "name": "Forkert", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a withdrawn invite still signs somebody up: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An address that already has an account is a conflict rather than a second
|
||||
// invite: the signup would fail at the end, after somebody had chosen a password.
|
||||
func TestInvitingAnAddressThatAlreadyHasAnAccount(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/users",
|
||||
map[string]any{"email": "[email protected]"})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status %d, want 409 — body %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// newSignedOut is a client against the same server with an empty cookie jar.
|
||||
func newSignedOut(t *testing.T, ts *testServer) *testServer {
|
||||
t.Helper()
|
||||
return &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
}
|
||||
|
||||
// indexAfter returns the offset just past `needle` in `s`.
|
||||
func indexAfter(s, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(s); i++ {
|
||||
if s[i:i+len(needle)] == needle {
|
||||
return i + len(needle)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -92,6 +92,79 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
||||
}
|
||||
|
||||
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, "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, "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.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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, "[email protected]", "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, "[email protected]", "Anders")
|
||||
|
||||
anders, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "editor",
|
||||
})
|
||||
|
||||
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, "[email protected]", "Anders")
|
||||
|
||||
andersUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
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": "[email protected]", "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, "[email protected]", "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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
+110
-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()
|
||||
@@ -234,6 +242,55 @@ func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// TrashedProject is a deleted project, with when it went and when it goes for good.
|
||||
type TrashedProject struct {
|
||||
Project
|
||||
DeletedAt time.Time
|
||||
TaskCount int
|
||||
}
|
||||
|
||||
// ListTrashedProjects returns what the caller owns and has deleted.
|
||||
//
|
||||
// Restoring has been possible since the trash existed, but only if you already
|
||||
// knew the project's id — which is exactly what you no longer have once it has
|
||||
// gone from the interface. A recovery window nobody can reach is not one.
|
||||
//
|
||||
// Owned, not shared: a member of a project somebody else deleted has no business
|
||||
// bringing it back.
|
||||
func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
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, p.deleted_at,
|
||||
(SELECT count(*) FROM tasks t
|
||||
WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count
|
||||
FROM projects p
|
||||
WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL
|
||||
ORDER BY p.deleted_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []TrashedProject{}
|
||||
for rows.Next() {
|
||||
var t TrashedProject
|
||||
var created, updated, deleted int64
|
||||
var icon sql.NullString
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID,
|
||||
&t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted,
|
||||
&t.TaskCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Icon = icon.String
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
t.DeletedAt = time.Unix(deleted, 0).UTC()
|
||||
t.Role = RoleOwner
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RestoreProject brings a project back, along with the tasks that were deleted with
|
||||
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
||||
// and separately, and restoring a project should not undo that too.
|
||||
@@ -279,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
|
||||
}
|
||||
@@ -507,3 +576,30 @@ func joinComma(parts []string) string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReorderProjects writes the given order, and only for projects the user owns.
|
||||
//
|
||||
// The whole list at once, rather than the midpoint-between-neighbours dance the
|
||||
// tasks do. A person has a handful of projects, not a thousand, so writing
|
||||
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
|
||||
// failure the task ordering has to respace a section to recover from.
|
||||
//
|
||||
// Scoped to ownership because sort_order lives on the project row rather than
|
||||
// per viewer: a shared project sits where its owner put it, and a member
|
||||
// reordering their sidebar must not rearrange somebody else's. Ids that are not
|
||||
// the caller's are skipped rather than refused, so one shared project in the
|
||||
// list does not fail the whole drag.
|
||||
func (db *DB) ReorderProjects(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 projects SET sort_order = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
|
||||
float64(i), now, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,158 @@ 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
|
||||
}
|
||||
|
||||
// UserCount is what decides whether the instance still needs its first admin.
|
||||
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
|
||||
+9
-1
@@ -11,7 +11,7 @@ gameskill:
|
||||
invites and reminders, then start it and open the URL to create the first
|
||||
admin account.
|
||||
author: "kristianwind"
|
||||
version: 1
|
||||
version: 2
|
||||
icon: "app"
|
||||
|
||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||
@@ -70,6 +70,14 @@ gameskill:
|
||||
pattern: '^[0-9]+(ns|us|ms|s|m|h)$'
|
||||
hint: "must be a duration like 720h, 30m or 45s"
|
||||
|
||||
# Off by default because it is an outbound request nobody asked for. It sends
|
||||
# no identifier and reads one public URL — GitHub's releases API — but that is
|
||||
# a choice to make rather than a default to inherit.
|
||||
- key: VERDANDE_UPDATE_CHECK
|
||||
name: "Look for new releases (asks GitHub once a day; sends nothing about you)"
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
install:
|
||||
# The image has no shell, so the install step borrows one. It only has to make
|
||||
# the directory layout exist; verdande creates and migrates its own database on
|
||||
|
||||
@@ -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: '[email protected]',
|
||||
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
|
||||
@@ -82,6 +83,12 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
for (const href of hrefs) {
|
||||
await sidebar.locator(`a[href="${href}"]`).first().click();
|
||||
|
||||
// Wait for the navigation before looking at the page. Without this the
|
||||
// assertion can be satisfied by the heading of the page you just left —
|
||||
// which is not a hypothetical: a change that removed a project page's
|
||||
// heading entirely passed here on a fast machine and failed on CI.
|
||||
await page.waitForURL(`**${href}`);
|
||||
|
||||
// Something with a heading rendered. A route written outside the routes
|
||||
// tree lands on the SPA shell and renders nothing — which is exactly how
|
||||
// the label route broke, and it answered 200 the whole time.
|
||||
@@ -146,6 +153,9 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
'Integrationer',
|
||||
'AI',
|
||||
'API-tokens',
|
||||
// Only an administrator sees this one, and the account these tests run as
|
||||
// is the first account, which is one.
|
||||
'Brugere',
|
||||
'Data og skabeloner'
|
||||
]) {
|
||||
await tabs.getByRole('link', { name: label, exact: true }).click();
|
||||
@@ -179,3 +189,536 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
||||
expect(worker.ok()).toBeTruthy();
|
||||
expect(await worker.text()).toContain("addEventListener('push'");
|
||||
});
|
||||
|
||||
/**
|
||||
* A project can be renamed, deleted and brought back.
|
||||
*
|
||||
* The round trip matters more than the three steps: deleting used to be a
|
||||
* one-way door in the interface — the API could restore, but only from an id
|
||||
* that had already disappeared from the screen.
|
||||
*/
|
||||
test('et projekt kan omdøbes, slettes og hentes tilbage', 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('Ferie');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
|
||||
|
||||
// The heading is the rename affordance: click it, and it becomes a field.
|
||||
await page.getByRole('button', { name: 'Ferie' }).click();
|
||||
const title = page.getByLabel('Projektets navn');
|
||||
await title.fill('Sommerferie');
|
||||
await title.press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||
|
||||
// And it is reachable again, which is the half that did not exist.
|
||||
await page.goto('/indstillinger/data');
|
||||
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
|
||||
await expect(trash.getByText('Sommerferie')).toBeVisible();
|
||||
|
||||
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
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();
|
||||
|
||||
// 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('[email protected]');
|
||||
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('[email protected]');
|
||||
await member.getByLabel('Adgangskode').fill('et langt kodeord til test');
|
||||
await member.getByRole('button', { name: 'Log ind' }).click();
|
||||
|
||||
const theirs = member.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await theirs.getByRole('link', { name: /Fælleshuset/ }).click();
|
||||
await expect(member.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||
await expect(member.getByText('findes ikke')).toHaveCount(0);
|
||||
expect(memberTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
||||
await inviteePage.close();
|
||||
|
||||
// Now that there is somebody to delegate to, the inviter can — and the view
|
||||
// for it appears. It is hidden on an instance with one person, where it could
|
||||
// never have anything in it.
|
||||
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();
|
||||
await expect(page.getByText('males inden fredag')).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('[email protected]');
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
expect(link).toContain('/invite?token=');
|
||||
await expect(page.getByText('[email protected]')).toBeVisible();
|
||||
await expect(page.getByText('til instansen')).toBeVisible();
|
||||
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const invitee = await context.newPage();
|
||||
await invitee.goto(link);
|
||||
await invitee.getByLabel('Navn').fill('Anders');
|
||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
// They are in — with an Inbox of their own, and no project shared with them,
|
||||
// which is the difference between this and a project invite.
|
||||
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(invitee.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||
await context.close();
|
||||
|
||||
await page.reload();
|
||||
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
||||
const row = konti.locator('li').filter({ hasText: 'Anders' });
|
||||
await expect(row).toBeVisible();
|
||||
// Invited, not promoted. Asserted through the button that offers to promote
|
||||
// them — which only reads this way for somebody who is not one. Matching the
|
||||
// word "administrator" in the row would match that very button.
|
||||
await expect(row.getByRole('button', { name: 'Gør til administrator' })).toBeVisible();
|
||||
// And the invite is spent rather than left live.
|
||||
await expect(page.getByText('Afventer svar')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The two refusals that keep an instance administrable. Driven here as well as in
|
||||
* the API tests because the interface has to *say* why, and a 409 nobody explains
|
||||
* is a button that appears broken.
|
||||
*/
|
||||
test('den sidste administrator kan hverken fjernes eller slette sig selv', async ({ page }) => {
|
||||
await page.goto('/indstillinger/brugere');
|
||||
|
||||
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
||||
const mine = konti.locator('li').filter({ hasText: 'dig' });
|
||||
await expect(mine).toBeVisible();
|
||||
|
||||
// No buttons on your own row: the server refuses both, and a button whose only
|
||||
// purpose is to be refused wastes a click to teach a rule.
|
||||
await expect(mine.getByRole('button')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
* It did: `/mcp` was not a route, so the SPA fallback answered 200 with a page
|
||||
* of HTML. A connector pointed at it reported a successful connection and then
|
||||
* failed to parse the response — which looks like a broken client rather than a
|
||||
* missing route. A 401 in JSON is the honest answer.
|
||||
*/
|
||||
test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
|
||||
const response = await request.post('/mcp', {
|
||||
data: { jsonrpc: '2.0', id: 1, method: 'initialize' }
|
||||
});
|
||||
|
||||
expect(response.status(), 'uden nøgle skal den afvise, ikke servere siden').toBe(401);
|
||||
expect(response.headers()['content-type']).toContain('application/json');
|
||||
expect((await response.json()).code).toBe('unauthorized');
|
||||
});
|
||||
|
||||
@@ -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: '[email protected]',
|
||||
password: 'et langt kodeord til test'
|
||||
};
|
||||
+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;
|
||||
|
||||
|
||||
+42
-1
@@ -34,9 +34,31 @@ const MESSAGES = {
|
||||
validation_failed: 'Tjek felterne herunder.',
|
||||
payload_too_large: 'Det er for stort.',
|
||||
internal_error: 'Noget gik galt. Prøv igen.',
|
||||
offline: 'Ingen forbindelse til serveren.'
|
||||
bad_request: 'Anmodningen kunne ikke læses.',
|
||||
// Client-only: a network failure is not an HTTP status, so the server has no
|
||||
// code for it.
|
||||
offline: 'Ingen forbindelse til serveren.',
|
||||
|
||||
// Situations that used to arrive as a plain `conflict` and were therefore
|
||||
// shown as "Det er der allerede." — which was not merely vague but wrong. An
|
||||
// unconfigured Gmail is not a Gmail that is already connected.
|
||||
gmail_not_configured:
|
||||
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
|
||||
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
|
||||
totp_not_enabled: 'To-faktor er ikke slået til.',
|
||||
inbox_protected: 'Indbakken kan ikke slettes.',
|
||||
last_admin:
|
||||
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.'
|
||||
};
|
||||
|
||||
/**
|
||||
* What to show a person for a failed request.
|
||||
*
|
||||
* A code with no message here falls back to the server's prose, which is English
|
||||
* and written for a log — not good, but better than swallowing the only
|
||||
* explanation there is. A code that turns up in that fallback is a code this
|
||||
* table is missing.
|
||||
*/
|
||||
export function humanMessage(err) {
|
||||
if (!(err instanceof ApiError)) return MESSAGES.offline;
|
||||
return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error;
|
||||
@@ -147,6 +169,16 @@ 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}`),
|
||||
|
||||
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||
|
||||
@@ -156,8 +188,16 @@ export const api = {
|
||||
createProject: (data) => post('/projects', data),
|
||||
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
||||
deleteProject: (id) => del(`/projects/${id}`),
|
||||
reorderProjects: (ids) => post('/projects/reorder', { ids }),
|
||||
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),
|
||||
@@ -264,5 +304,6 @@ export const api = {
|
||||
// --- views
|
||||
today: () => get('/today'),
|
||||
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
|
||||
delegated: () => get('/delegated'),
|
||||
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 } from '$lib/stores.svelte.js';
|
||||
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,14 +29,271 @@
|
||||
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));
|
||||
let own = $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.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);
|
||||
|
||||
/**
|
||||
* Pointer events rather than mouse events, so a trackpad, a mouse and a stylus
|
||||
* all work. Capturing the pointer means the drag keeps following even when it
|
||||
* leaves the handle, which at 4px wide it does constantly.
|
||||
*/
|
||||
function startResize(event) {
|
||||
event.preventDefault();
|
||||
resizing = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onResize(event) {
|
||||
if (!resizing) return;
|
||||
sidebar.setWidth(event.clientX);
|
||||
}
|
||||
|
||||
function endResize(event) {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
// --- reordering ---------------------------------------------------------------
|
||||
|
||||
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;
|
||||
startDrag(event, PROJECT, project.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, project) {
|
||||
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 = dragged(event, PROJECT) || draggingId;
|
||||
const below = overBelow;
|
||||
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, 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 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">
|
||||
@@ -55,22 +314,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}
|
||||
@@ -87,15 +420,22 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#each own 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}
|
||||
</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}
|
||||
@@ -103,19 +443,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}
|
||||
@@ -182,6 +623,29 @@
|
||||
<span class="signout">Log ud</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||
A drag handle with no keyboard equivalent is a setting some people simply
|
||||
cannot reach. -->
|
||||
<div
|
||||
class="resize"
|
||||
class:resizing
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Bredde på sidebjælken"
|
||||
tabindex="0"
|
||||
onpointerdown={startResize}
|
||||
onpointermove={onResize}
|
||||
onpointerup={endResize}
|
||||
onpointercancel={endResize}
|
||||
ondblclick={() => sidebar.reset()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowLeft') sidebar.setWidth(sidebar.width - 16);
|
||||
else if (e.key === 'ArrowRight') sidebar.setWidth(sidebar.width + 16);
|
||||
else if (e.key === 'Home') sidebar.reset();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
></div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
@@ -196,6 +660,45 @@
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
|
||||
The visible line stays the border; this only widens what the pointer hits. */
|
||||
.resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 3px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.resize:hover::after,
|
||||
.resize:focus-visible::after,
|
||||
.resize.resizing::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
/* The drawer has a fixed width and no room to drag against. */
|
||||
.resize {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -239,7 +742,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;
|
||||
@@ -248,13 +756,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;
|
||||
@@ -280,6 +984,48 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||
sort_order is a column on the project, not a preference per viewer. */
|
||||
a.sortable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a.sortable.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A line in the gap rather than a highlighted row: the gap is the target. */
|
||||
a.sortable::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
a.sortable.drop-above::before {
|
||||
top: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a.sortable.drop-below::before {
|
||||
bottom: -1px;
|
||||
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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+387
-14
@@ -16,6 +16,8 @@ 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([]);
|
||||
tasks = $state([]);
|
||||
loading = $state(true);
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
@@ -29,6 +31,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;
|
||||
|
||||
@@ -56,8 +68,12 @@ class AppState {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.user = await api.me();
|
||||
const { projects } = await api.listProjects();
|
||||
const [{ projects }, { groups }] = await Promise.all([
|
||||
api.listProjects(),
|
||||
api.listProjectGroups()
|
||||
]);
|
||||
this.projects = projects;
|
||||
this.groups = groups;
|
||||
this.connect();
|
||||
} catch (e) {
|
||||
this.user = null;
|
||||
@@ -110,6 +126,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 +182,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 +204,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 +269,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 +334,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));
|
||||
@@ -246,6 +347,153 @@ class AppState {
|
||||
this.projects = projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the projects in the given order.
|
||||
*
|
||||
* The whole list rather than one moved item: it is a handful of rows, and
|
||||
* sending the order you want cannot land in the half-applied state a sequence
|
||||
* of individual moves can. `sort_order` is set here as well as on the server,
|
||||
* so the sidebar settles immediately rather than after a round trip.
|
||||
*/
|
||||
async reorderProjects(ids) {
|
||||
const previous = this.projects;
|
||||
const rank = new Map(ids.map((id, i) => [id, i]));
|
||||
|
||||
this.projects = this.projects.map((p) =>
|
||||
rank.has(p.id) ? { ...p, sort_order: rank.get(p.id) } : p
|
||||
);
|
||||
try {
|
||||
await api.reorderProjects(ids);
|
||||
} catch (e) {
|
||||
this.projects = previous;
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
@@ -263,21 +511,146 @@ class AppState {
|
||||
|
||||
export const app = new AppState();
|
||||
|
||||
/** 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';
|
||||
},
|
||||
/**
|
||||
* How wide the sidebar is, and whether it is showing at all.
|
||||
*
|
||||
* Kept in localStorage rather than on the account: it is a property of the
|
||||
* screen you are sitting at, not of you. The same person on a laptop and on a
|
||||
* wide monitor wants different answers, and syncing it would make one of those
|
||||
* two wrong every time they switch.
|
||||
*/
|
||||
const SIDEBAR_MIN = 200;
|
||||
const SIDEBAR_MAX = 480;
|
||||
const SIDEBAR_DEFAULT = 268;
|
||||
|
||||
function storedNumber(key, fallback) {
|
||||
if (typeof localStorage === 'undefined') return fallback;
|
||||
const raw = Number(localStorage.getItem(key));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
class SidebarLayout {
|
||||
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
|
||||
collapsed = $state(
|
||||
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
|
||||
);
|
||||
|
||||
setWidth(px) {
|
||||
// Clamped here rather than in the drag handler, so every caller gets the
|
||||
// same rule and a stored value from an older build cannot make the
|
||||
// sidebar unusably narrow.
|
||||
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
|
||||
this.#save('verdande:sidebar-width', String(this.width));
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const next = this.current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
this.collapsed = !this.collapsed;
|
||||
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setWidth(SIDEBAR_DEFAULT);
|
||||
}
|
||||
|
||||
#save(key, value) {
|
||||
try {
|
||||
localStorage.setItem('verdande:theme', next);
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const sidebar = new SidebarLayout();
|
||||
|
||||
/**
|
||||
* 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: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 };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { app, theme } from '$lib/stores.svelte.js';
|
||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
@@ -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,14 +73,25 @@
|
||||
<!-- 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}
|
||||
<div class="shell" class:sidebar-open={sidebarOpen}>
|
||||
<div
|
||||
class="shell"
|
||||
class:sidebar-open={sidebarOpen}
|
||||
class:sidebar-collapsed={sidebar.collapsed}
|
||||
style="--sidebar-width: {sidebar.width}px"
|
||||
>
|
||||
<Sidebar onnavigate={() => (sidebarOpen = false)} />
|
||||
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<!-- Two controls, not one: on a phone the sidebar is a drawer that
|
||||
opens over the page, on a desktop it is a column that folds away.
|
||||
Same words, different actions, so each is shown only where it
|
||||
applies. -->
|
||||
<button
|
||||
class="menu"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
@@ -82,6 +103,19 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="fold"
|
||||
onclick={() => sidebar.toggle()}
|
||||
aria-label={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
aria-pressed={sidebar.collapsed}
|
||||
title={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M9 4v16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="search" onclick={() => (paletteOpen = true)}>
|
||||
<span>Søg</span>
|
||||
<kbd>⌘K</kbd>
|
||||
@@ -173,7 +207,32 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.fold {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.fold:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Folded away rather than merely narrow: width 0 with the border gone, so
|
||||
nothing of it is left to catch the eye. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu svg,
|
||||
.fold svg,
|
||||
.theme svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -277,6 +336,11 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/* The drawer is the whole answer here; folding a drawer means nothing. */
|
||||
.fold {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
display: block;
|
||||
}
|
||||
@@ -292,5 +356,15 @@
|
||||
.shell.sidebar-open :global(.sidebar) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* A collapsed desktop sidebar must not follow you to a phone, where the
|
||||
drawer would then open to nothing. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: var(--sidebar-width);
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
padding-right: var(--s3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,17 +11,23 @@
|
||||
* is six places for them to drift apart.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const SECTIONS = [
|
||||
// Brugere is only shown to administrators — the API refuses everybody else, and
|
||||
// a tab that answers 403 is a promise the interface cannot keep. Hidden rather
|
||||
// than disabled: this is not a feature to upsell, it is one that does not
|
||||
// apply to you.
|
||||
let sections = $derived([
|
||||
{ href: '/indstillinger', label: 'Konto' },
|
||||
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
|
||||
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
|
||||
{ href: '/indstillinger/ai', label: 'AI' },
|
||||
{ href: '/indstillinger/tokens', label: 'API-tokens' },
|
||||
...(app.user?.is_admin ? [{ href: '/indstillinger/brugere', label: 'Brugere' }] : []),
|
||||
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
||||
];
|
||||
]);
|
||||
|
||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||
</script>
|
||||
@@ -32,7 +38,7 @@
|
||||
</header>
|
||||
|
||||
<nav aria-label="Indstillinger">
|
||||
{#each SECTIONS as section (section.href)}
|
||||
{#each sections as section (section.href)}
|
||||
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -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="[email protected]"
|
||||
required
|
||||
aria-invalid={errors.email ? 'true' : undefined}
|
||||
/>
|
||||
{#if errors.email}<p class="error">Skal være en e-mailadresse.</p>{/if}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={busy}>Send invitation</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if link}
|
||||
<div class="field">
|
||||
<p class="hint">Invitationen er oprettet. Send linket til personen:</p>
|
||||
<code class="link-out">{link}</code>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if invites.length}
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Afventer svar</h2>
|
||||
<p class="hint">
|
||||
Invitationer, der er sendt og ikke brugt endnu. Kun linkets hash er gemt, så
|
||||
det kan ikke vises igen — er det gået til den forkerte, så træk det tilbage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="rows">
|
||||
{#each invites as pending (pending.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="primary-line">{pending.email}</span>
|
||||
<span class="secondary">
|
||||
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
|
||||
· udløber {expires(pending.expires_at)}
|
||||
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => revoke(pending)}>Træk tilbage</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Konti</h2>
|
||||
<p class="hint">Alle på denne instans.</p>
|
||||
</header>
|
||||
|
||||
<ul class="rows">
|
||||
{#each users as user (user.id)}
|
||||
<li>
|
||||
<span class="avatar" style="background: {user.avatar_color}">
|
||||
{user.name[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<div class="what">
|
||||
<span class="primary-line">
|
||||
{user.name}
|
||||
{#if user.is_admin}<span class="badge">administrator</span>{/if}
|
||||
{#if user.self}<span class="badge quiet">dig</span>{/if}
|
||||
</span>
|
||||
<span class="secondary">
|
||||
{user.email} · {ago(user.last_seen_at)}
|
||||
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nobody demotes or deletes themselves from here. The server refuses
|
||||
both, and a button that exists only to be refused is a button that
|
||||
wastes a click to teach you a rule. -->
|
||||
{#if !user.self}
|
||||
<button class="secondary" onclick={() => setAdmin(user, !user.is_admin)}>
|
||||
{user.is_admin ? 'Fjern administrator' : 'Gør til administrator'}
|
||||
</button>
|
||||
<button class="danger" onclick={() => remove(user)}>Slet</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rows li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rows li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.what {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.primary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0 var(--s2);
|
||||
}
|
||||
|
||||
.badge.quiet {
|
||||
color: var(--ink-faint);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.link-out {
|
||||
display: block;
|
||||
margin-top: var(--s1);
|
||||
padding: var(--s2);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -101,6 +101,36 @@
|
||||
}
|
||||
|
||||
let exportProject = $state('');
|
||||
|
||||
// --- the trash -------------------------------------------------------------
|
||||
|
||||
let trashed = $state([]);
|
||||
let loadingTrash = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.listTrashedProjects()
|
||||
.then((r) => (trashed = r.projects))
|
||||
.catch((e) => app.toast(humanMessage(e)))
|
||||
.finally(() => (loadingTrash = false));
|
||||
});
|
||||
|
||||
async function restore(project) {
|
||||
const previous = trashed;
|
||||
trashed = trashed.filter((p) => p.id !== project.id);
|
||||
try {
|
||||
await api.restoreProject(project.id);
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
trashed = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
|
||||
function daysLeft(iso) {
|
||||
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -146,6 +176,38 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Papirkurv</h2>
|
||||
<p class="hint">
|
||||
Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som
|
||||
de var — bortset fra opgaver, du havde slettet hver for sig først; dem var
|
||||
der en grund til.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loadingTrash}
|
||||
<p class="empty">…</p>
|
||||
{:else if trashed.length === 0}
|
||||
<p class="empty">Papirkurven er tom.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each trashed as project (project.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="title">{project.name}</span>
|
||||
<span class="meta">
|
||||
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
|
||||
{daysLeft(project.purge_after)} dage tilbage
|
||||
</span>
|
||||
</div>
|
||||
<button class="use" onclick={() => restore(project)}>Hent tilbage</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Eksport</h2>
|
||||
|
||||
@@ -96,10 +96,18 @@
|
||||
|
||||
let feedURL = $state('');
|
||||
let mailAddress = $state('');
|
||||
/** Null until loaded, so the warning does not flash before the answer is in. */
|
||||
let mailConfigured = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
api.getMailAddress().then((r) => (mailAddress = r.address)).catch(() => {});
|
||||
api
|
||||
.getMailAddress()
|
||||
.then((r) => {
|
||||
mailAddress = r.address;
|
||||
mailConfigured = r.configured;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
async function rotateFeed() {
|
||||
@@ -114,7 +122,9 @@
|
||||
async function rotateMail() {
|
||||
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
|
||||
try {
|
||||
mailAddress = (await api.rotateMailAddress()).address;
|
||||
const r = await api.rotateMailAddress();
|
||||
mailAddress = r.address;
|
||||
mailConfigured = r.configured;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
@@ -218,6 +228,16 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if mailConfigured === false}
|
||||
<p class="callback" data-tone="bad">
|
||||
Der er ingen mailserver sat op på denne server, så adressen herunder kan
|
||||
ikke modtage noget endnu. Sæt <span class="mono">VERDANDE_SMTP_HOST</span> og
|
||||
de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til
|
||||
<span class="mono">todo+*</span> videre til
|
||||
<span class="mono">/inbound/mail</span>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="mail">Din adresse</label>
|
||||
<input id="mail" class="mono" value={mailAddress} readonly />
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
/** The plaintext of the token just minted. Held only in this variable. */
|
||||
let fresh = $state(null);
|
||||
let copied = $state(false);
|
||||
let connectorCopied = $state(false);
|
||||
|
||||
/** What Claude's connector dialog needs, which is a URL and nothing else. */
|
||||
let connectorURL = $derived(fresh ? `${location.origin}/mcp?key=${fresh.token}` : '');
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
@@ -59,6 +63,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConnector() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(connectorURL);
|
||||
connectorCopied = true;
|
||||
} catch {
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token) {
|
||||
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
||||
return;
|
||||
@@ -100,6 +113,22 @@
|
||||
{#if copied}<span class="saved">Kopieret.</span>{/if}
|
||||
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
|
||||
</div>
|
||||
|
||||
<!-- Shown here and nowhere else, because it contains the token, and the
|
||||
token exists only in this moment. Claude's connector dialog takes a
|
||||
URL and nothing else — there is no field for a header — so this is
|
||||
the form it has to be in. -->
|
||||
<div class="also">
|
||||
<p class="hint">
|
||||
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
|
||||
i Claude? Så er det denne adresse, dialogen skal have:
|
||||
</p>
|
||||
<p class="mono value">{connectorURL}</p>
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
|
||||
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -194,6 +223,18 @@
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.also {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.also a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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 />
|
||||
@@ -1,14 +1,28 @@
|
||||
<script>
|
||||
/** One project: its sections, its tasks, and who it is shared with. */
|
||||
import { page } from '$app/stores';
|
||||
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);
|
||||
@@ -20,23 +34,31 @@
|
||||
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));
|
||||
@@ -62,9 +84,167 @@
|
||||
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) {
|
||||
const name = input.value.trim();
|
||||
editing = false;
|
||||
if (!name || name === project.name) return;
|
||||
|
||||
try {
|
||||
project = await api.updateProject(project.id, { name });
|
||||
// The sidebar reads its own copy, so it has to be told.
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const days = 30;
|
||||
const confirmed = confirm(
|
||||
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
||||
`Det havner i papirkurven og kan hentes tilbage under ` +
|
||||
`Indstillinger → Data og skabeloner i ${days} dage.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.deleteProject(project.id);
|
||||
await app.refreshProjects();
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
inviteError = '';
|
||||
@@ -87,7 +267,49 @@
|
||||
<div class="view">
|
||||
{#if project}
|
||||
<header>
|
||||
<h1>{project.name}</h1>
|
||||
<!-- A heading first, a field second. Swapping the h1 out for an input
|
||||
outright cost the page its heading — which the sidebar smoke test
|
||||
caught, and which is a real loss: every view here has one, and it is
|
||||
what a screen reader announces on arrival. So the heading stays and
|
||||
becomes editable on click. -->
|
||||
{#if editing}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
class="title"
|
||||
autofocus
|
||||
value={project.name}
|
||||
aria-label="Projektets navn"
|
||||
onblur={(e) => rename(e.currentTarget)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') {
|
||||
e.currentTarget.value = project.name;
|
||||
editing = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<h1 class:renameable={isOwner}>
|
||||
{#if isOwner}
|
||||
<button onclick={() => (editing = true)} title="Klik for at omdøbe">
|
||||
{project.name}
|
||||
</button>
|
||||
{:else}
|
||||
{project.name}
|
||||
{/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
|
||||
@@ -103,8 +325,32 @@
|
||||
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
||||
</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">
|
||||
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#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">
|
||||
@@ -165,16 +411,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>
|
||||
@@ -182,7 +470,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.
|
||||
@@ -193,8 +509,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>
|
||||
|
||||
@@ -225,6 +551,122 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The heading's button carries no button-ness: it is the heading, and the only
|
||||
hint that it does anything is the underline on hover. */
|
||||
h1.renameable button {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
h1.renameable button:hover {
|
||||
border-bottom-color: var(--line-strong);
|
||||
}
|
||||
|
||||
/* Matches the heading it stands in for, so the swap does not move the page. */
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.015em;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s1) var(--s2);
|
||||
margin-left: calc(var(--s2) * -1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.title:focus {
|
||||
background: var(--surface);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition:
|
||||
color var(--fast) var(--ease),
|
||||
background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
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;
|
||||
@@ -394,6 +836,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 {
|
||||
@@ -407,6 +857,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;
|
||||
@@ -435,4 +958,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 {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// vite.config.js
|
||||
import { sveltekit } from "file:///Users/kw/Documents/Code/ToDoApp/web/node_modules/@sveltejs/kit/src/exports/vite/index.js";
|
||||
var vite_config_default = {
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// In development the app runs on 5173 and the API on 8080. Proxying rather
|
||||
// than pointing fetch at another origin keeps the session cookie
|
||||
// first-party, which is what production looks like — otherwise every
|
||||
// cookie and CSRF behaviour would differ between dev and the real thing.
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: false,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9rdy9Eb2N1bWVudHMvQ29kZS9Ub0RvQXBwL3dlYi92aXRlLmNvbmZpZy5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDUxNzMsXG5cdFx0Ly8gSW4gZGV2ZWxvcG1lbnQgdGhlIGFwcCBydW5zIG9uIDUxNzMgYW5kIHRoZSBBUEkgb24gODA4MC4gUHJveHlpbmcgcmF0aGVyXG5cdFx0Ly8gdGhhbiBwb2ludGluZyBmZXRjaCBhdCBhbm90aGVyIG9yaWdpbiBrZWVwcyB0aGUgc2Vzc2lvbiBjb29raWVcblx0XHQvLyBmaXJzdC1wYXJ0eSwgd2hpY2ggaXMgd2hhdCBwcm9kdWN0aW9uIGxvb2tzIGxpa2UgXHUyMDE0IG90aGVyd2lzZSBldmVyeVxuXHRcdC8vIGNvb2tpZSBhbmQgQ1NSRiBiZWhhdmlvdXIgd291bGQgZGlmZmVyIGJldHdlZW4gZGV2IGFuZCB0aGUgcmVhbCB0aGluZy5cblx0XHRwcm94eToge1xuXHRcdFx0Jy9hcGknOiB7XG5cdFx0XHRcdHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MCcsXG5cdFx0XHRcdGNoYW5nZU9yaWdpbjogZmFsc2UsXG5cdFx0XHRcdHdzOiB0cnVlXG5cdFx0XHR9XG5cdFx0fVxuXHR9XG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4UixTQUFTLGlCQUFpQjtBQUV4VCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUEsSUFLTixPQUFPO0FBQUEsTUFDTixRQUFRO0FBQUEsUUFDUCxRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||
Reference in New Issue
Block a user