Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ebd0fa78 | ||
|
|
a8b453843d | ||
|
|
a2f4de02fc | ||
|
|
4276892590 | ||
|
|
6b6b838167 | ||
|
|
34b718529c | ||
|
|
c14559ed0c | ||
|
|
2300f1dc3b | ||
|
|
1529c87231 | ||
|
|
f15c440942 | ||
|
|
d37d240625 | ||
|
|
3b521b463c | ||
|
|
9056c44c9a |
@@ -45,3 +45,8 @@ node_modules/
|
|||||||
# the process is killed, which during a hung build is exactly what happens. One
|
# the process is killed, which during a hung build is exactly what happens. One
|
||||||
# was committed by accident.
|
# was committed by accident.
|
||||||
/web/vite.config.js.timestamp-*.mjs
|
/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,
|
@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.
|
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||||
views.
|
views, and foldable groups over the projects in the sidebar.
|
||||||
- **Sharing.** Internal users are created by an admin; anyone else joins through an
|
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||||
invite link sent to their email. Owner, editor and viewer roles.
|
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.
|
- **Live sync.** A change made by somebody else appears without a refresh.
|
||||||
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
||||||
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
- **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 |
|
| Project roles and permission checks | Done, tested |
|
||||||
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
|
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
|
||||||
| Today, Upcoming, search, activity log | Done, tested |
|
| Today, Upcoming, search, activity log | Done, tested |
|
||||||
|
| Waiting on others: what you have delegated, by person | Done, tested |
|
||||||
| Sharing: invites, roles, members | Done, tested |
|
| Sharing: invites, roles, members | Done, tested |
|
||||||
| Live sync over WebSocket | Done |
|
| Live sync over WebSocket | Done |
|
||||||
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
||||||
| Task detail: description, sub-tasks, comments, files, reminders | Done |
|
| Task detail: description, sub-tasks, comments, files, reminders | Done |
|
||||||
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
||||||
|
| Device list: where you are signed in, and signing one out | Done, tested |
|
||||||
|
| 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 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 |
|
| Recurring tasks (RRULE, Danish + English) | Done, tested |
|
||||||
| Reminders, absolute and relative | Done, tested |
|
| Reminders, absolute and relative | Done, tested |
|
||||||
| Saved filters with a query language | Done, tested |
|
| Saved filters with a query language | Done, tested |
|
||||||
|
|||||||
+3
-1
@@ -21,7 +21,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
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.
|
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
- **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
|
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||||
joins through an invite link.
|
joins through an invite link.
|
||||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||||
|
|||||||
@@ -351,6 +351,44 @@ paths:
|
|||||||
"204": { description: Off. }
|
"204": { description: Off. }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"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:
|
/api/v1/auth/recovery-codes:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
@@ -818,6 +856,223 @@ paths:
|
|||||||
"204": { description: Deleted. }
|
"204": { description: Deleted. }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"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:
|
/api/v1/trash/projects:
|
||||||
get:
|
get:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
@@ -1060,6 +1315,40 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Task" }
|
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:
|
/api/v1/search:
|
||||||
get:
|
get:
|
||||||
tags: [views]
|
tags: [views]
|
||||||
@@ -2007,6 +2296,11 @@ components:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: "#/components/schemas/Project" }
|
schema: { $ref: "#/components/schemas/Project" }
|
||||||
|
ProjectGroup:
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ProjectGroup" }
|
||||||
FeedURL:
|
FeedURL:
|
||||||
description: OK
|
description: OK
|
||||||
content:
|
content:
|
||||||
@@ -2110,6 +2404,69 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items: { type: string }
|
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:
|
Task:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -2202,6 +2559,12 @@ components:
|
|||||||
role: { type: string, enum: [owner, editor, viewer] }
|
role: { type: string, enum: [owner, editor, viewer] }
|
||||||
member_count: { type: integer }
|
member_count: { type: integer }
|
||||||
shared: { type: boolean }
|
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:
|
ProjectInput:
|
||||||
type: object
|
type: object
|
||||||
@@ -2212,6 +2575,27 @@ components:
|
|||||||
view_mode: { type: string, enum: [list, board, calendar] }
|
view_mode: { type: string, enum: [list, board, calendar] }
|
||||||
archived: { type: boolean }
|
archived: { type: boolean }
|
||||||
sort_order: { type: number }
|
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 }
|
||||||
|
collapsed: { type: boolean }
|
||||||
|
sort_order: { type: number }
|
||||||
|
|
||||||
|
ProjectGroupInput:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
collapsed: { type: boolean }
|
||||||
|
|
||||||
Section:
|
Section:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -761,3 +761,137 @@ func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
|
|||||||
t.Errorf("status %d, want 401", resp.StatusCode)
|
t.Errorf("status %d, want 401", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// last_seen_at has been written on every request since sessions existed, for
|
||||||
|
// exactly this list, and nothing read it. A session you cannot see is a session
|
||||||
|
// you cannot end.
|
||||||
|
func TestSessionsCanBeListedAndEnded(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// A second sign-in from "another device": same account, its own cookie jar.
|
||||||
|
other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||||
|
resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("second login: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("list sessions: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
sessions, _ := body["sessions"].([]any)
|
||||||
|
if len(sessions) != 2 {
|
||||||
|
t.Fatalf("want two sessions, got %v", sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one is this one, or the interface cannot tell somebody which row is
|
||||||
|
// the browser they are reading it in.
|
||||||
|
current := ""
|
||||||
|
for _, raw := range sessions {
|
||||||
|
s := raw.(map[string]any)
|
||||||
|
if s["current"] == true {
|
||||||
|
if current != "" {
|
||||||
|
t.Fatal("two sessions both claim to be the current one")
|
||||||
|
}
|
||||||
|
current = s["id"].(string)
|
||||||
|
}
|
||||||
|
if s["device"] == "" {
|
||||||
|
t.Errorf("no device summary: %v", s)
|
||||||
|
}
|
||||||
|
if s["last_seen_at"] == nil {
|
||||||
|
t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
t.Fatal("none of the sessions is marked as the current one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// End the other one.
|
||||||
|
var otherID string
|
||||||
|
for _, raw := range sessions {
|
||||||
|
if s := raw.(map[string]any); s["id"] != current {
|
||||||
|
otherID = s["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete session: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// That device is signed out, and this one is not.
|
||||||
|
if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("the ended session still works: status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("ending another session logged this one out: status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's session is not yours to end, even with its id in hand.
|
||||||
|
func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
|
||||||
|
mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string)
|
||||||
|
|
||||||
|
if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// And it still works.
|
||||||
|
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("somebody else ended my session: status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An API token is accepted almost everywhere, and must not be accepted here: a
|
||||||
|
// leaked token that could end its owner's sessions, or even list their devices,
|
||||||
|
// turns a theft into a lockout.
|
||||||
|
func TestSessionsAreNotReachableWithAToken(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, user.ID)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("status %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ua string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"},
|
||||||
|
// An iPhone's user agent says Mac OS X too, so the order of the tests is
|
||||||
|
// the whole correctness of this function.
|
||||||
|
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"},
|
||||||
|
// And Edge claims to be Chrome, which claims to be Safari.
|
||||||
|
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"},
|
||||||
|
{"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"},
|
||||||
|
{"", "Ukendt enhed"},
|
||||||
|
// Not a browser at all. Its own name beats a word that says the server
|
||||||
|
// gave up: a CalDAV client is a real session.
|
||||||
|
{"curl/8.4.0", "curl/8.4.0"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := describeUserAgent(c.ua); got != c.want {
|
||||||
|
t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,8 +47,16 @@ const (
|
|||||||
CodeAINotConfigured = "ai_not_configured"
|
CodeAINotConfigured = "ai_not_configured"
|
||||||
CodeTOTPNotEnabled = "totp_not_enabled"
|
CodeTOTPNotEnabled = "totp_not_enabled"
|
||||||
CodeInboxProtected = "inbox_protected"
|
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) {
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||||
writeJSON(w, status, APIError{Code: code, Message: message})
|
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)
|
s.internal(w, "create label", err)
|
||||||
return
|
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})
|
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)
|
s.storeError(w, "update label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.publishToOwner(r, "label.changed", nil)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,5 +274,6 @@ func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.storeError(w, "delete label", err)
|
s.storeError(w, "delete label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.publishToOwner(r, "label.changed", nil)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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"`
|
||||||
|
Collapsed bool `json:"collapsed"`
|
||||||
|
SortOrder float64 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||||
|
return projectGroupJSON{ID: g.ID, Name: g.Name, Collapsed: g.Collapsed, SortOrder: g.SortOrder}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
|
||||||
|
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 err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
|
||||||
|
Name: req.Name, 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,221 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAGroupHoldsProjectsAndSurvivesBeingEmptied(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create group: %d %v", resp.StatusCode, group)
|
||||||
|
}
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
if group["collapsed"] != false {
|
||||||
|
t.Errorf("a new group should be open: %v", group)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, filed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID,
|
||||||
|
map[string]any{"group_id": groupID})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("file project: %d %v", resp.StatusCode, filed)
|
||||||
|
}
|
||||||
|
if filed["group_id"] != groupID {
|
||||||
|
t.Errorf("group_id = %v, want %s", filed["group_id"], groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listing is what the sidebar reads, so the filing has to survive it.
|
||||||
|
if got := groupOf(t, ts, projectID); got != groupID {
|
||||||
|
t.Errorf("group_id in the list = %q, want %s", got, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty group is still a group. This is the whole reason it is a table and
|
||||||
|
// not a string repeated on every project row.
|
||||||
|
resp, _ = ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": ""})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ungroup: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := groupOf(t, ts, projectID); got != "" {
|
||||||
|
t.Errorf("group_id = %q after ungrouping, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
if groups, _ := listed["groups"].([]any); len(groups) != 1 {
|
||||||
|
t.Fatalf("the emptied group is gone: %v", listed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An omitted group_id must not clear the field: that is the difference between a
|
||||||
|
// PATCH and a PUT, and renaming a project would otherwise tip it out of its group.
|
||||||
|
func TestRenamingAProjectLeavesItInItsGroup(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||||
|
|
||||||
|
_, renamed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"name": "Bogholderi"})
|
||||||
|
if renamed["group_id"] != groupID {
|
||||||
|
t.Errorf("group_id = %v after a rename, want %s", renamed["group_id"], groupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the heading is not a request to delete the work filed under it.
|
||||||
|
func TestDeletingAGroupKeepsItsProjects(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete group: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
found := false
|
||||||
|
for _, raw := range projects["projects"].([]any) {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if p["id"] != projectID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if p["group_id"] != nil && p["group_id"] != "" {
|
||||||
|
t.Errorf("the project still points at a group that is gone: %v", p["group_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the project went with the group it was filed under")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folding is stored on the account rather than in the browser, so it has to come
|
||||||
|
// back from the server.
|
||||||
|
func TestFoldingAGroupIsRemembered(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
|
||||||
|
resp, folded := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||||
|
map[string]any{"collapsed": true})
|
||||||
|
if resp.StatusCode != http.StatusOK || folded["collapsed"] != true {
|
||||||
|
t.Fatalf("fold: %d %v", resp.StatusCode, folded)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
first := listed["groups"].([]any)[0].(map[string]any)
|
||||||
|
if first["collapsed"] != true {
|
||||||
|
t.Errorf("collapsed = %v after re-reading, want true", first["collapsed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups are per person: somebody else's is not yours to see, rename or delete,
|
||||||
|
// and a group that is not yours is not found rather than forbidden.
|
||||||
|
func TestGroupsArePerPerson(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, listed := other.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
if groups, _ := listed["groups"].([]any); len(groups) != 0 {
|
||||||
|
t.Errorf("somebody else's groups are visible: %v", groups)
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||||
|
map[string]any{"name": "Mit"}); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("renaming somebody else's group: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("deleting somebody else's group: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project cannot be filed into a group that is not the caller's, even though
|
||||||
|
// both the project and the group exist and both requests would be well-formed.
|
||||||
|
func TestAProjectCannotJoinSomebodyElsesGroup(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
_, group := other.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Deres"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404 — a group you cannot see is not one you can file into",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsReorderAsAWholeList(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ids := make([]string, 3)
|
||||||
|
for i, name := range []string{"En", "To", "Tre"} {
|
||||||
|
_, g := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": name})
|
||||||
|
ids[i] = g["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
reversed := []string{ids[2], ids[1], ids[0]}
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/project-groups/reorder", map[string]any{"ids": reversed})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
got := listed["groups"].([]any)
|
||||||
|
for i, want := range reversed {
|
||||||
|
if got[i].(map[string]any)["id"] != want {
|
||||||
|
t.Fatalf("order = %v, want %v", got, reversed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A name is required, the same as a project's. An unnamed heading is a heading
|
||||||
|
// nobody can tell apart from the next one.
|
||||||
|
func TestAGroupNeedsAName(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": " "})
|
||||||
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("status %d, want 422 — body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupOf reads a project's group back out of the listing the sidebar uses.
|
||||||
|
func groupOf(t *testing.T, ts *testServer, projectID string) string {
|
||||||
|
t.Helper()
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
for _, raw := range body["projects"].([]any) {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if p["id"] == projectID {
|
||||||
|
id, _ := p["group_id"].(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("project %s is not in the listing", projectID)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ type projectJSON struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
MemberCount int `json:"member_count"`
|
MemberCount int `json:"member_count"`
|
||||||
Shared bool `json:"shared"`
|
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 {
|
func toProjectJSON(p store.Project) projectJSON {
|
||||||
@@ -31,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
|
|||||||
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
||||||
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
|
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
|
||||||
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
|
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
|
||||||
Shared: p.MemberCount > 1,
|
Shared: p.MemberCount > 1, GroupID: p.GroupID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +71,11 @@ type projectRequest struct {
|
|||||||
Icon *string `json:"icon"`
|
Icon *string `json:"icon"`
|
||||||
ViewMode *string `json:"view_mode"`
|
ViewMode *string `json:"view_mode"`
|
||||||
Archived *bool `json:"archived"`
|
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 {
|
type reorderProjectsRequest struct {
|
||||||
@@ -133,6 +142,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||||
|
s.publishToOwner(r, "project.created", toProjectJSON(*p))
|
||||||
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +171,17 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.storeError(w, "update project", err)
|
s.storeError(w, "update project", err)
|
||||||
return
|
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)
|
s.activity(r, projectID, "", "project.updated", nil)
|
||||||
|
|
||||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||||
@@ -168,6 +189,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.storeError(w, "get project", err)
|
s.storeError(w, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +204,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "project.deleted", nil)
|
s.activity(r, projectID, "", "project.deleted", nil)
|
||||||
|
s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,3 +153,44 @@ func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The member list errored on every project — the ORDER BY named an expression,
|
||||||
|
// which SQLite refuses across a UNION — so the share panel had never shown
|
||||||
|
// anybody, not even the owner.
|
||||||
|
func TestListMembersWorksAtAll(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
members, _ := body["members"].([]any)
|
||||||
|
if len(members) != 1 {
|
||||||
|
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
|
||||||
|
}
|
||||||
|
if m := members[0].(map[string]any); m["role"] != "owner" {
|
||||||
|
t.Errorf("role = %v, want owner", m["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the Inbox, which is the one every task drawer asks about.
|
||||||
|
func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
var inboxID string
|
||||||
|
for _, raw := range list["projects"].([]any) {
|
||||||
|
if p := raw.(map[string]any); p["is_inbox"] == true {
|
||||||
|
inboxID = p["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ func (s *Server) publish(projectID, event string, payload any) {
|
|||||||
s.hub.Publish(projectID, event, payload)
|
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.
|
// handleWebSocket is the live-sync connection.
|
||||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|||||||
@@ -151,6 +151,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||||
|
|
||||||
|
// Behind requireSession, like the API tokens and for the same
|
||||||
|
// reason: a leaked token must not be able to end the sessions of
|
||||||
|
// the person it was stolen from, or list their devices.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.requireSession)
|
||||||
|
r.Get("/sessions", s.handleListSessions)
|
||||||
|
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,6 +263,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||||
})
|
})
|
||||||
r.Get("/upcoming", s.handleUpcoming)
|
r.Get("/upcoming", s.handleUpcoming)
|
||||||
|
r.Get("/delegated", s.handleDelegated)
|
||||||
r.Get("/search", s.handleSearch)
|
r.Get("/search", s.handleSearch)
|
||||||
|
|
||||||
r.Route("/projects", func(r chi.Router) {
|
r.Route("/projects", func(r chi.Router) {
|
||||||
@@ -287,6 +297,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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
|
// The trash is outside the block above: a deleted project is invisible
|
||||||
// to the permission check by design, so these handlers resolve
|
// to the permission check by design, so these handlers resolve
|
||||||
// ownership themselves.
|
// ownership themselves.
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Where a person is signed in, and how to end one of them.
|
||||||
|
//
|
||||||
|
// `last_seen_at` has been written on every request since sessions existed —
|
||||||
|
// throttled to once a minute, precisely so this list could say "this device, 2
|
||||||
|
// minutes ago" — and nothing has ever read it. A session you cannot see is a
|
||||||
|
// session you cannot end.
|
||||||
|
|
||||||
|
type sessionJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Current marks the one making the request, so the interface can say "denne
|
||||||
|
// enhed" rather than making somebody work it out from a user agent.
|
||||||
|
Current bool `json:"current"`
|
||||||
|
// Device is a summary; UserAgent is what it was summarised from. Both, because
|
||||||
|
// the summary is what you read and the original is what settles the question
|
||||||
|
// when the summary is not enough.
|
||||||
|
Device string `json:"device"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastSeenAt string `json:"last_seen_at"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSessions lists the caller's live sessions, most recently used first.
|
||||||
|
//
|
||||||
|
// The id here is the stored hash of the cookie, never the cookie itself: it cannot
|
||||||
|
// be pasted into a browser, and it is what the delete below needs.
|
||||||
|
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
current := sessionFrom(r.Context())
|
||||||
|
|
||||||
|
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "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]) + "…"
|
||||||
|
}
|
||||||
@@ -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": "ny@example.dk"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
link, _ := body["link"].(string)
|
||||||
|
if link == "" {
|
||||||
|
t.Fatal("no link came back — with no mail server it is the only way the invite reaches anybody")
|
||||||
|
}
|
||||||
|
if body["emailed"] != false {
|
||||||
|
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// It shows as pending, with no project attached: this is an invite to the
|
||||||
|
// instance, not to somewhere in it.
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||||
|
invites, _ := listed["invites"].([]any)
|
||||||
|
if len(invites) != 1 {
|
||||||
|
t.Fatalf("want one pending invite, got %v", invites)
|
||||||
|
}
|
||||||
|
invite := invites[0].(map[string]any)
|
||||||
|
if invite["email"] != "ny@example.dk" {
|
||||||
|
t.Errorf("email = %v", invite["email"])
|
||||||
|
}
|
||||||
|
if name, ok := invite["project_name"]; ok && name != "" {
|
||||||
|
t.Errorf("project_name = %v, want none for an instance invite", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the token in the link creates the account, through the same signup the
|
||||||
|
// project invites use.
|
||||||
|
token := link[len(link)-len(link)+indexAfter(link, "token="):]
|
||||||
|
fresh := newSignedOut(t, ts)
|
||||||
|
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
||||||
|
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("signup with the instance invite: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// They are on the instance, and they are not an administrator.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/users", nil)
|
||||||
|
users, _ := after["users"].([]any)
|
||||||
|
if len(users) != 2 {
|
||||||
|
t.Fatalf("want two accounts, got %v", users)
|
||||||
|
}
|
||||||
|
for _, raw := range users {
|
||||||
|
u := raw.(map[string]any)
|
||||||
|
if u["email"] == "ny@example.dk" && u["is_admin"] != false {
|
||||||
|
t.Errorf("a newly invited account is an administrator: %v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The invite is used up rather than left live.
|
||||||
|
if pending, _ := after["invites"].([]any); len(pending) != 0 {
|
||||||
|
t.Errorf("the invite is still pending after being accepted: %v", pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two refusals that make this page safe to have at all.
|
||||||
|
func TestTheLastAdministratorCannotBeRemoved(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demoting yourself as the only administrator locks everybody out: there is no
|
||||||
|
// console, and setup refuses to run once an account exists.
|
||||||
|
resp, body := ts.do(t, "PATCH", "/api/v1/users/"+me.ID, map[string]any{"is_admin": false})
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("demote the last admin: status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["code"] != "last_admin" {
|
||||||
|
t.Errorf("code = %v, want last_admin — the interface has to explain this one", body["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And deleting the account you are signed in as is refused before that check
|
||||||
|
// is even reached.
|
||||||
|
resp, _ = ts.do(t, "DELETE", "/api/v1/users/"+me.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("delete your own account: status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still an administrator, and still there.
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||||
|
first := listed["users"].([]any)[0].(map[string]any)
|
||||||
|
if first["is_admin"] != true || first["self"] != true {
|
||||||
|
t.Errorf("the only administrator is no longer one: %v", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a second administrator the refusal lifts — it is about the last one, not
|
||||||
|
// about administrators.
|
||||||
|
func TestASecondAdministratorCanBeDemoted(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
other, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
|
||||||
|
map[string]any{"is_admin": true}); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("promote: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
|
||||||
|
map[string]any{"is_admin": false}); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("demote a second administrator: status %d, want 204", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting an account takes more than its own projects, and the list says so
|
||||||
|
// before anybody presses the button.
|
||||||
|
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One project of their own, with a task in it.
|
||||||
|
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
|
||||||
|
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "deres egen", "project_id": theirs["id"].(string),
|
||||||
|
})
|
||||||
|
|
||||||
|
// And a task they wrote in a project somebody else owns. tasks.created_by
|
||||||
|
// cascades, so this one goes too — and a count that missed it would understate
|
||||||
|
// the damage in exactly the case that matters.
|
||||||
|
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||||
|
sharedID := mine["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
||||||
|
"email": "anden@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "skrevet i et delt projekt", "project_id": sharedID,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||||
|
var found map[string]any
|
||||||
|
for _, raw := range listed["users"].([]any) {
|
||||||
|
if u := raw.(map[string]any); u["id"] == otherUser.ID {
|
||||||
|
found = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("the second account is not in the list")
|
||||||
|
}
|
||||||
|
if found["project_count"] != float64(1) {
|
||||||
|
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
|
||||||
|
}
|
||||||
|
if found["task_count"] != float64(2) {
|
||||||
|
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
|
||||||
|
found["task_count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the delete really does take it.
|
||||||
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
|
||||||
|
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
|
||||||
|
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything here is administrators only, and not reachable with an API token
|
||||||
|
// even by an administrator: a leaked token must not be able to mint an account.
|
||||||
|
func TestTheUserPageIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
what, method, path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"list", "GET", "/api/v1/users", nil},
|
||||||
|
{"invite", "POST", "/api/v1/users", map[string]any{"email": "x@y.dk"}},
|
||||||
|
{"promote", "PATCH", "/api/v1/users/whoever", map[string]any{"is_admin": true}},
|
||||||
|
{"delete", "DELETE", "/api/v1/users/whoever", nil},
|
||||||
|
{"revoke", "DELETE", "/api/v1/invites/whichever", nil},
|
||||||
|
} {
|
||||||
|
if resp, _ := other.do(t, c.method, c.path, c.body); resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s as an ordinary user: status %d, want 403", c.what, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, admin.ID)
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/users", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("an administrator's API token reached the user list: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pending invite can be withdrawn, which is the only way to stop a link that
|
||||||
|
// went to the wrong address: the token is stored as a hash and cannot be looked up.
|
||||||
|
func TestAPendingInviteCanBeWithdrawn(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, created := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "forkert@example.dk"})
|
||||||
|
link := created["link"].(string)
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
||||||
|
inviteID := listed["invites"].([]any)[0].(map[string]any)["id"].(string)
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/invites/"+inviteID, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("withdraw: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link no longer works.
|
||||||
|
token := link[indexAfter(link, "token="):]
|
||||||
|
fresh := newSignedOut(t, ts)
|
||||||
|
resp, _ := fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
||||||
|
"token": token, "name": "Forkert", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("a withdrawn invite still signs somebody up: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An address that already has an account is a conflict rather than a second
|
||||||
|
// invite: the signup would fail at the end, after somebody had chosen a password.
|
||||||
|
func TestInvitingAnAddressThatAlreadyHasAnAccount(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/users",
|
||||||
|
map[string]any{"email": "kristian@example.dk"})
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("status %d, want 409 — body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSignedOut is a client against the same server with an empty cookie jar.
|
||||||
|
func newSignedOut(t *testing.T, ts *testServer) *testServer {
|
||||||
|
t.Helper()
|
||||||
|
return &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexAfter returns the offset just past `needle` in `s`.
|
||||||
|
func indexAfter(s, needle string) int {
|
||||||
|
for i := 0; i+len(needle) <= len(s); i++ {
|
||||||
|
if s[i:i+len(needle)] == needle {
|
||||||
|
return i + len(needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -92,6 +92,79 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// 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
|
// see, which is the point — the thing you are looking for is usually somewhere you
|
||||||
// were not looking.
|
// 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, "anden@example.dk", "Anden")
|
||||||
|
other.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "deres hemmelighed @regnskab"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||||
|
if tasks, _ := body["tasks"].([]any); len(tasks) != 0 {
|
||||||
|
t.Errorf("found somebody else's task: %v", tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Waiting on others" is the other half of an assignee filter, and the half that
|
||||||
|
// is easy to get backwards: what somebody has handed over, not what they have
|
||||||
|
// been handed.
|
||||||
|
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
|
||||||
|
anders, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "anders@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
|
||||||
|
})
|
||||||
|
// Mine, and unassigned: neither belongs in a view about other people.
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "ingen har den", "project_id": projectID,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
people, _ := body["people"].([]any)
|
||||||
|
if len(people) != 1 {
|
||||||
|
t.Fatalf("want one person, got %v", people)
|
||||||
|
}
|
||||||
|
person := people[0].(map[string]any)
|
||||||
|
if person["name"] != "Anders" {
|
||||||
|
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
|
||||||
|
person["name"])
|
||||||
|
}
|
||||||
|
if person["avatar_color"] == nil || person["avatar_color"] == "" {
|
||||||
|
t.Errorf("no avatar colour: %v", person)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, _ := person["tasks"].([]any)
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Fatalf("want one task, got %v", tasks)
|
||||||
|
}
|
||||||
|
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
|
||||||
|
t.Errorf("content = %v", tasks[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The view is per person: Anders is not waiting on himself, and he cannot see
|
||||||
|
// what somebody has delegated in a project he is not in.
|
||||||
|
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
anders := ts.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
|
||||||
|
andersUser, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project Anders is not a member of.
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "anders@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// It is assigned to him, so it is not something he is waiting on.
|
||||||
|
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
|
||||||
|
if people, _ := body["people"].([]any); len(people) != 0 {
|
||||||
|
t.Errorf("Anders is waiting on himself: %v", people)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And somebody with no access to the project sees none of it.
|
||||||
|
other := ts.newUser(t, "tredje@example.dk", "Tredje")
|
||||||
|
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
|
||||||
|
if people, _ := third["people"].([]any); len(people) != 0 {
|
||||||
|
t.Errorf("a task in a project they cannot see: %v", people)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,70 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
|
|||||||
return &inv, nil
|
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
|
// 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
|
// 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.
|
// 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,205 @@
|
|||||||
|
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
|
||||||
|
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, 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, &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, collapsed, sort_order, created_at, updated_at
|
||||||
|
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
|
||||||
|
Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder, &created, &updated)
|
||||||
|
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.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, collapsed, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
g.ID, g.OwnerID, g.Name, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectGroupUpdate struct {
|
||||||
|
Name *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,
|
||||||
|
"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
|
||||||
|
}
|
||||||
+34
-14
@@ -20,6 +20,11 @@ type Project struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt 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
|
// 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.
|
// the frontend can grey out what they cannot do without a second request.
|
||||||
Role Role
|
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,
|
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.archived, p.sort_order, p.created_at, p.updated_at,
|
||||||
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
|
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
|
FROM projects p
|
||||||
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
||||||
WHERE p.deleted_at IS NULL
|
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`
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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,
|
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.archived, p.sort_order, p.created_at, p.updated_at,
|
||||||
? AS role,
|
? AS role,
|
||||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
|
(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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func scanProject(rows *sql.Rows) (Project, error) {
|
||||||
var p Project
|
var p Project
|
||||||
var icon sql.NullString
|
var icon, groupID sql.NullString
|
||||||
var isInbox, archived int
|
var isInbox, archived int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
var role string
|
var role string
|
||||||
|
|
||||||
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
|
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 {
|
if err != nil {
|
||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
p.Icon = icon.String
|
p.Icon = icon.String
|
||||||
|
p.GroupID = groupID.String
|
||||||
p.IsInbox = isInbox == 1
|
p.IsInbox = isInbox == 1
|
||||||
p.Archived = archived == 1
|
p.Archived = archived == 1
|
||||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
@@ -328,15 +336,27 @@ type Member struct {
|
|||||||
// project_members row — ownership is the projects table — so they are unioned in
|
// 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.
|
// rather than left out of the list of people who can see it.
|
||||||
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
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, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
|
SELECT id, email, name, avatar_color, role, added_at FROM (
|
||||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role,
|
||||||
WHERE p.id = ?
|
p.created_at AS added_at, 0 AS rank
|
||||||
UNION ALL
|
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
|
WHERE p.id = ?
|
||||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
UNION ALL
|
||||||
WHERE m.project_id = ?
|
SELECT u.id, u.email, u.name, u.avatar_color, m.role,
|
||||||
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ func (db *DB) DeleteSession(ctx context.Context, sessionID string) error {
|
|||||||
return err
|
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 {
|
func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
||||||
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
||||||
return err
|
return err
|
||||||
|
|||||||
+29
-4
@@ -48,8 +48,14 @@ type TaskFilter struct {
|
|||||||
// renders them nested under their parents rather than as separate rows.
|
// renders them nested under their parents rather than as separate rows.
|
||||||
TopLevelOnly bool
|
TopLevelOnly bool
|
||||||
AssigneeID string
|
AssigneeID string
|
||||||
LabelID string
|
// DelegatedBy is the other side of AssigneeID: assigned, but not to this
|
||||||
Priority int
|
// 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 and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
|
||||||
DueBefore string
|
DueBefore string
|
||||||
@@ -116,6 +122,10 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
where = append(where, `t.assignee_id = ?`)
|
where = append(where, `t.assignee_id = ?`)
|
||||||
args = append(args, f.AssigneeID)
|
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 {
|
if f.Priority > 0 {
|
||||||
where = append(where, `t.priority = ?`)
|
where = append(where, `t.priority = ?`)
|
||||||
args = append(args, f.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)
|
args = append(args, f.DueFrom)
|
||||||
}
|
}
|
||||||
if expr := MatchExpr(f.Search); expr != "" {
|
if expr := MatchExpr(f.Search); expr != "" {
|
||||||
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
|
// Text or a label. A label is how you find a task whose wording you have
|
||||||
args = append(args, expr)
|
// 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 != "" {
|
if f.FilterSQL != "" {
|
||||||
where = append(where, "("+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
|
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.
|
// UserCount is what decides whether the instance still needs its first admin.
|
||||||
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
|
|||||||
@@ -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.
|
* or the app shell is broken, nothing below runs and the failure names this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const USER = {
|
import { USER } from './user.js';
|
||||||
name: 'Kristian',
|
|
||||||
email: 'test@example.dk',
|
|
||||||
password: 'et langt kodeord til test'
|
|
||||||
};
|
|
||||||
|
|
||||||
const AUTH_FILE = 'e2e/.auth/user.json';
|
const AUTH_FILE = 'e2e/.auth/user.json';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
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
|
* The four flows a broken build must not survive: add a task, tick it off, reach
|
||||||
@@ -152,6 +153,9 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
|||||||
'Integrationer',
|
'Integrationer',
|
||||||
'AI',
|
'AI',
|
||||||
'API-tokens',
|
'API-tokens',
|
||||||
|
// Only an administrator sees this one, and the account these tests run as
|
||||||
|
// is the first account, which is one.
|
||||||
|
'Brugere',
|
||||||
'Data og skabeloner'
|
'Data og skabeloner'
|
||||||
]) {
|
]) {
|
||||||
await tabs.getByRole('link', { name: label, exact: true }).click();
|
await tabs.getByRole('link', { name: label, exact: true }).click();
|
||||||
@@ -227,6 +231,377 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
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');
|
||||||
|
|
||||||
|
const heading = sidebar.getByRole('button', { name: 'Arbejde' });
|
||||||
|
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' })).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'false'
|
||||||
|
);
|
||||||
|
await sidebar.getByRole('button', { name: 'Arbejde' }).click();
|
||||||
|
|
||||||
|
// 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' })).toBeVisible();
|
||||||
|
|
||||||
|
// Deleting the heading must not take the project filed under it. It is
|
||||||
|
// ungrouped here, which is the case that would look identical either way if
|
||||||
|
// the assertion were only "the group is gone".
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await sidebar.getByRole('button', { name: 'Slet' }).click();
|
||||||
|
await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeHidden();
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).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('nabo@example.dk');
|
||||||
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||||
|
|
||||||
|
// With no mail server the link is shown rather than swallowed, which is also
|
||||||
|
// the only way this test can get hold of it.
|
||||||
|
const link = await page.locator('.link-out code').textContent();
|
||||||
|
expect(link).toContain('/invite?token=');
|
||||||
|
|
||||||
|
// Explicitly empty, because a context made through the `browser` fixture picks
|
||||||
|
// up the project's storageState — and an invitee who is already signed in as
|
||||||
|
// the person who sent the invite is the one case that never happens.
|
||||||
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const invitee = await context.newPage();
|
||||||
|
const inviteeTrouble = watchForTrouble(invitee);
|
||||||
|
await invitee.goto(link);
|
||||||
|
|
||||||
|
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
|
||||||
|
await invitee.getByLabel('Navn').fill('Nabo');
|
||||||
|
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
|
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
|
||||||
|
// Signed in, with the project they were invited to.
|
||||||
|
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
invitee.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Fælleshuset/ })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// One failure here is not trouble: the page loads before the account exists, so
|
||||||
|
// the shell's first `me()` is answered 401. That 401 is how the signed-out
|
||||||
|
// screen appears at all.
|
||||||
|
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
||||||
|
await context.close();
|
||||||
|
|
||||||
|
// 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('anders@example.dk');
|
||||||
|
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||||
|
|
||||||
|
const link = await page.locator('.link-out').textContent();
|
||||||
|
expect(link).toContain('/invite?token=');
|
||||||
|
await expect(page.getByText('anders@example.dk')).toBeVisible();
|
||||||
|
await expect(page.getByText('til instansen')).toBeVisible();
|
||||||
|
|
||||||
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const invitee = await context.newPage();
|
||||||
|
await invitee.goto(link);
|
||||||
|
await invitee.getByLabel('Navn').fill('Anders');
|
||||||
|
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
|
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
|
||||||
|
// They are in — with an Inbox of their own, and no project shared with them,
|
||||||
|
// which is the difference between this and a project invite.
|
||||||
|
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
await expect(invitee.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||||
|
await context.close();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
||||||
|
const row = konti.locator('li').filter({ hasText: 'Anders' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
// Invited, not promoted. Asserted through the button that offers to promote
|
||||||
|
// them — which only reads this way for somebody who is not one. Matching the
|
||||||
|
// word "administrator" in the row would match that very button.
|
||||||
|
await expect(row.getByRole('button', { name: 'Gør til administrator' })).toBeVisible();
|
||||||
|
// And the invite is spent rather than left live.
|
||||||
|
await expect(page.getByText('Afventer svar')).toBeHidden();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two refusals that keep an instance administrable. Driven here as well as in
|
||||||
|
* the API tests because the interface has to *say* why, and a 409 nobody explains
|
||||||
|
* is a button that appears broken.
|
||||||
|
*/
|
||||||
|
test('den sidste administrator kan hverken fjernes eller slette sig selv', async ({ page }) => {
|
||||||
|
await page.goto('/indstillinger/brugere');
|
||||||
|
|
||||||
|
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
||||||
|
const mine = konti.locator('li').filter({ hasText: 'dig' });
|
||||||
|
await expect(mine).toBeVisible();
|
||||||
|
|
||||||
|
// No buttons on your own row: the server refuses both, and a button whose only
|
||||||
|
// purpose is to be refused wastes a click to teach a rule.
|
||||||
|
await expect(mine.getByRole('button')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The MCP connector address must not fall through to the app shell.
|
* The MCP connector address must not fall through to the app shell.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* The account every test runs as.
|
||||||
|
*
|
||||||
|
* In its own file rather than exported from auth.setup.js, because Playwright
|
||||||
|
* refuses to let one test file import another — and both the setup that creates
|
||||||
|
* this account and the device-list test that signs in as it a second time need
|
||||||
|
* the same credentials.
|
||||||
|
*/
|
||||||
|
export const USER = {
|
||||||
|
name: 'Kristian',
|
||||||
|
email: 'test@example.dk',
|
||||||
|
password: 'et langt kodeord til test'
|
||||||
|
};
|
||||||
+163
-5
@@ -22,7 +22,10 @@
|
|||||||
|
|
||||||
--ink: #e8ebe9;
|
--ink: #e8ebe9;
|
||||||
--ink-muted: #9aa39e;
|
--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
|
/* 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
|
single primary action on a screen — and for nothing else, which is what
|
||||||
@@ -87,7 +90,160 @@
|
|||||||
--content-max: 720px;
|
--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;
|
--ground: #faf9f7;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-raised: #ffffff;
|
--surface-raised: #ffffff;
|
||||||
@@ -97,10 +253,12 @@
|
|||||||
|
|
||||||
--ink: #1c201e;
|
--ink: #1c201e;
|
||||||
--ink-muted: #5f6763;
|
--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: #257552;
|
||||||
--accent-hover: #277a54;
|
--accent-hover: #1f6a49;
|
||||||
--accent-sunken: #e2f0e9;
|
--accent-sunken: #e2f0e9;
|
||||||
--accent-ink: #ffffff;
|
--accent-ink: #ffffff;
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -46,7 +46,9 @@ const MESSAGES = {
|
|||||||
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
|
'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.',
|
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.',
|
totp_not_enabled: 'To-faktor er ikke slået til.',
|
||||||
inbox_protected: 'Indbakken kan ikke slettes.'
|
inbox_protected: 'Indbakken kan ikke slettes.',
|
||||||
|
last_admin:
|
||||||
|
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +169,16 @@ export const api = {
|
|||||||
totpSetup: () => post('/auth/totp/setup'),
|
totpSetup: () => post('/auth/totp/setup'),
|
||||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
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'),
|
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||||
|
|
||||||
@@ -180,6 +192,12 @@ export const api = {
|
|||||||
listTrashedProjects: () => get('/trash/projects'),
|
listTrashedProjects: () => get('/trash/projects'),
|
||||||
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
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`),
|
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
||||||
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
||||||
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
||||||
@@ -286,5 +304,6 @@ export const api = {
|
|||||||
// --- views
|
// --- views
|
||||||
today: () => get('/today'),
|
today: () => get('/today'),
|
||||||
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
|
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
|
||||||
|
delegated: () => get('/delegated'),
|
||||||
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
|
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
|
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||||
import TaskRow from './TaskRow.svelte';
|
import TaskRow from './TaskRow.svelte';
|
||||||
|
|
||||||
let { project, sections, canEdit } = $props();
|
let { project, sections, canEdit } = $props();
|
||||||
@@ -24,23 +25,30 @@
|
|||||||
...sections.map((s) => ({ id: s.id, name: s.name }))
|
...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) =>
|
const tasksIn = (sectionId) =>
|
||||||
app.tasks
|
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);
|
.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
|
||||||
function onDragStart(event, task) {
|
function onDragStart(event, task) {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
draggingId = task.id;
|
draggingId = task.id;
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
startDrag(event, TASK, task.id);
|
||||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
|
||||||
event.dataTransfer.setData('text/plain', task.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(event, sectionId) {
|
function onDragOver(event, sectionId) {
|
||||||
if (!canEdit || !draggingId) return;
|
if (!canEdit || !carries(event, TASK) || !draggingId) return;
|
||||||
event.preventDefault();
|
accept(event);
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
overColumn = sectionId;
|
overColumn = sectionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,32 @@
|
|||||||
* misread the whole month at a glance.
|
* misread the whole month at a glance.
|
||||||
*/
|
*/
|
||||||
import { app } from '$lib/stores.svelte.js';
|
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()));
|
let cursor = $state(startOfMonth(new Date()));
|
||||||
|
|
||||||
@@ -41,10 +65,23 @@
|
|||||||
return days;
|
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 todayISO = iso(new Date());
|
||||||
|
|
||||||
const tasksOn = (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(
|
const monthName = $derived(
|
||||||
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
|
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
|
||||||
@@ -53,6 +90,25 @@
|
|||||||
function step(months) {
|
function step(months) {
|
||||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="calendar">
|
<div class="calendar">
|
||||||
@@ -73,11 +129,32 @@
|
|||||||
{#each grid as date (date.toISOString())}
|
{#each grid as date (date.toISOString())}
|
||||||
{@const tasks = tasksOn(date)}
|
{@const tasks = tasksOn(date)}
|
||||||
{@const outside = date.getMonth() !== cursor.getMonth()}
|
{@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>
|
<span class="number">{date.getDate()}</span>
|
||||||
|
|
||||||
{#each tasks.slice(0, 3) as task (task.id)}
|
{#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}
|
{task.content}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -120,7 +197,12 @@
|
|||||||
color: var(--ink);
|
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;
|
width: auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 0 var(--s3);
|
padding: 0 var(--s3);
|
||||||
@@ -178,6 +260,13 @@
|
|||||||
color: var(--ink-muted);
|
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 {
|
.day.today .number {
|
||||||
color: var(--accent-ink);
|
color: var(--accent-ink);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app, sidebar } from '$lib/stores.svelte.js';
|
import { app, sidebar } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
|
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
@@ -27,21 +28,87 @@
|
|||||||
let filters = $state([]);
|
let filters = $state([]);
|
||||||
let labels = $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(() => {
|
$effect(() => {
|
||||||
|
app.labelsChanged;
|
||||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
|
// Split by who owns it, not by whether anybody else is in it.
|
||||||
|
//
|
||||||
|
// The two headings say "Projekter" and "Delt med mig", and the split used to be
|
||||||
|
// on `shared`, which only means the project has more than one member. So the
|
||||||
|
// moment you invited somebody to a project of your own it moved under "Delt med
|
||||||
|
// mig" — where it lost its place in the order, stopped being draggable, and
|
||||||
|
// could not be filed under a group. Sharing your own project is not the same
|
||||||
|
// event as somebody sharing theirs with you.
|
||||||
|
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.role !== 'owner'));
|
||||||
// Sorted here rather than relying on the order the server sent, so a drag
|
// 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.
|
// settles the moment it is dropped instead of after the round trip.
|
||||||
let own = $derived(
|
let own = $derived(
|
||||||
app.projects
|
app.projects
|
||||||
.filter((p) => !p.is_inbox && !p.shared)
|
.filter((p) => !p.is_inbox && p.role === 'owner')
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
);
|
);
|
||||||
let current = $derived($page.url.pathname);
|
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('');
|
||||||
|
|
||||||
|
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 -------------------------------------------------------------------
|
// --- resizing -------------------------------------------------------------------
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false);
|
||||||
@@ -73,40 +140,157 @@
|
|||||||
let draggingId = $state(null);
|
let draggingId = $state(null);
|
||||||
let overId = $state(null);
|
let overId = $state(null);
|
||||||
let overBelow = $state(false);
|
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) {
|
function onDragStart(event, project) {
|
||||||
draggingId = project.id;
|
draggingId = project.id;
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
startDrag(event, PROJECT, project.id);
|
||||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
|
||||||
event.dataTransfer.setData('text/plain', project.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(event, project) {
|
function onDragOver(event, project) {
|
||||||
if (!draggingId || draggingId === project.id) return;
|
if (!carries(event, PROJECT) || draggingId === project.id) return;
|
||||||
event.preventDefault();
|
accept(event);
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
|
|
||||||
const box = event.currentTarget.getBoundingClientRect();
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
overId = project.id;
|
overId = project.id;
|
||||||
|
overGroup = null;
|
||||||
overBelow = event.clientY > box.top + box.height / 2;
|
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) {
|
async function onDrop(event, target) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const id = draggingId;
|
const id = dragged(event, PROJECT) || draggingId;
|
||||||
const below = overBelow;
|
const below = overBelow;
|
||||||
draggingId = null;
|
clearDrag();
|
||||||
overId = null;
|
|
||||||
if (!id || id === target.id) return;
|
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 without = own.filter((p) => p.id !== id);
|
||||||
const at = without.findIndex((p) => p.id === target.id);
|
const at = without.findIndex((p) => p.id === target.id);
|
||||||
if (at < 0) return;
|
if (at < 0) return;
|
||||||
|
|
||||||
const ordered = [...without];
|
const ordered = [...without];
|
||||||
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
|
ordered.splice(below ? at + 1 : at, 0, moved);
|
||||||
|
|
||||||
|
await app.setProjectGroup(id, bucketOf(target));
|
||||||
await app.reorderProjects(ordered.map((p) => p.id));
|
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>
|
</script>
|
||||||
|
|
||||||
<nav class="sidebar" aria-label="Hovedmenu">
|
<nav class="sidebar" aria-label="Hovedmenu">
|
||||||
@@ -127,22 +311,96 @@
|
|||||||
<span class="dot" aria-hidden="true"></span>
|
<span class="dot" aria-hidden="true"></span>
|
||||||
Kommende
|
Kommende
|
||||||
</a>
|
</a>
|
||||||
{#if app.inbox}
|
{#if app.projects.some((p) => p.shared)}
|
||||||
<a
|
<!-- Only where delegating is possible at all. On an instance with one
|
||||||
href="/projekt/{app.inbox.id}"
|
person this view can never have anything in it, and a permanent empty
|
||||||
class:active={current === `/projekt/${app.inbox.id}`}
|
entry is a question the sidebar keeps asking and answering "no". -->
|
||||||
onclick={onnavigate}
|
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
||||||
>
|
|
||||||
<span class="dot" aria-hidden="true"></span>
|
<span class="dot" aria-hidden="true"></span>
|
||||||
{app.inbox.name}
|
Venter på andre
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if app.inbox}
|
||||||
|
{@render projectRow(app.inbox, false)}
|
||||||
|
{/if}
|
||||||
</div>
|
</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" 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">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{#if adding}
|
{#if adding}
|
||||||
@@ -159,28 +417,22 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each own as project (project.id)}
|
{#if addingGroup}
|
||||||
<a
|
<form onsubmit={createGroup}>
|
||||||
href="/projekt/{project.id}"
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
class="sortable"
|
<input
|
||||||
class:active={current === `/projekt/${project.id}`}
|
bind:value={newGroupName}
|
||||||
class:dragging={draggingId === project.id}
|
autofocus
|
||||||
class:drop-above={overId === project.id && !overBelow}
|
placeholder="Gruppens navn"
|
||||||
class:drop-below={overId === project.id && overBelow}
|
aria-label="Gruppens navn"
|
||||||
onclick={onnavigate}
|
onblur={() => !newGroupName.trim() && (addingGroup = false)}
|
||||||
draggable="true"
|
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
|
||||||
ondragstart={(e) => onDragStart(e, project)}
|
/>
|
||||||
ondragend={() => {
|
</form>
|
||||||
draggingId = null;
|
{/if}
|
||||||
overId = null;
|
|
||||||
}}
|
{#each ungrouped as project (project.id)}
|
||||||
ondragover={(e) => onDragOver(e, project)}
|
{@render projectRow(project, true)}
|
||||||
ondragleave={() => (overId = null)}
|
|
||||||
ondrop={(e) => onDrop(e, project)}
|
|
||||||
>
|
|
||||||
<span class="dot" aria-hidden="true"></span>
|
|
||||||
{project.name}
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if own.length === 0 && !adding}
|
{#if own.length === 0 && !adding}
|
||||||
@@ -188,19 +440,87 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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. -->
|
||||||
|
<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 !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}
|
{#if shared.length}
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="group-head"><h2>Delt med mig</h2></div>
|
<div class="group-head"><h2>Delt med mig</h2></div>
|
||||||
{#each shared as project (project.id)}
|
{#each shared as project (project.id)}
|
||||||
<a
|
{@render projectRow(project, false)}
|
||||||
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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -386,7 +706,12 @@
|
|||||||
color: var(--ink-faint);
|
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;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -395,13 +720,143 @@
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-head button:hover {
|
.group-head > button.icon:hover {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--surface-raised);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head form {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -460,6 +915,15 @@
|
|||||||
opacity: 1;
|
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 {
|
.dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
<script>
|
<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
|
* 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
|
* 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
|
* 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
|
* mean redirecting somebody mid-authentication, which is where sessions get
|
||||||
* lost.
|
* 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 { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.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 email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
@@ -20,7 +29,23 @@
|
|||||||
let fields = $state({});
|
let fields = $state({});
|
||||||
let busy = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
|
if (linkMode) {
|
||||||
|
mode = linkMode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
api
|
api
|
||||||
.setupState()
|
.setupState()
|
||||||
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
||||||
@@ -78,6 +103,31 @@
|
|||||||
await api.forgotPassword(email);
|
await api.forgotPassword(email);
|
||||||
mode = 'sent';
|
mode = 'sent';
|
||||||
break;
|
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="submit" disabled={busy}>Send link</button>
|
||||||
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</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'}
|
{:else if mode === 'sent'}
|
||||||
<!-- Deliberately not "we sent an email": that would confirm the address
|
<!-- Deliberately not "we sent an email": that would confirm the address
|
||||||
has an account here to anybody who tried it. -->
|
has an account here to anybody who tried it. -->
|
||||||
@@ -280,6 +384,7 @@
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover {
|
.link:hover {
|
||||||
|
|||||||
@@ -20,6 +20,23 @@
|
|||||||
let priority = $state(4);
|
let priority = $state(4);
|
||||||
let dueDate = $state('');
|
let dueDate = $state('');
|
||||||
let labels = $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 subtasks = $state([]);
|
||||||
let comments = $state([]);
|
let comments = $state([]);
|
||||||
@@ -46,6 +63,17 @@
|
|||||||
priority = task.priority;
|
priority = task.priority;
|
||||||
dueDate = task.due_date ?? '';
|
dueDate = task.due_date ?? '';
|
||||||
labels = (task.labels ?? []).join(', ');
|
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 = [];
|
subtasks = [];
|
||||||
comments = [];
|
comments = [];
|
||||||
@@ -73,13 +101,83 @@
|
|||||||
await app.update(task.id, patch);
|
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();
|
const trimmed = content.trim();
|
||||||
if (!trimmed || trimmed === task.content) {
|
if (!trimmed || trimmed === task.content) {
|
||||||
content = task.content;
|
content = task.content;
|
||||||
return;
|
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() {
|
function saveDescription() {
|
||||||
@@ -316,8 +414,42 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if task.recurrence_text}
|
<div class="grid">
|
||||||
<p class="repeats">Gentages {task.recurrence_text}.</p>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -567,12 +699,6 @@
|
|||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.repeats {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--ink-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
|
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||||
import TaskRow from './TaskRow.svelte';
|
import TaskRow from './TaskRow.svelte';
|
||||||
|
|
||||||
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
||||||
@@ -25,15 +26,15 @@
|
|||||||
function onDragStart(event, task) {
|
function onDragStart(event, task) {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
draggingId = task.id;
|
draggingId = task.id;
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
// Typed, because the same task can now be dropped somewhere else entirely —
|
||||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
// a project in the sidebar, a day in Kommende — and those targets have to be
|
||||||
event.dataTransfer.setData('text/plain', task.id);
|
// able to tell what is coming while the drag is still in the air.
|
||||||
|
startDrag(event, TASK, task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(event, task) {
|
function onDragOver(event, task) {
|
||||||
if (!canEdit || !draggingId || draggingId === task.id) return;
|
if (!canEdit || !carries(event, TASK) || !draggingId || draggingId === task.id) return;
|
||||||
event.preventDefault();
|
accept(event);
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
|
|
||||||
// Which half of the row the pointer is in decides the gap. Comparing against
|
// 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
|
// the row's own midpoint rather than counting rows is what keeps the line
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
+295
-15
@@ -16,6 +16,8 @@ import { api, ApiError, humanMessage } from './api.js';
|
|||||||
class AppState {
|
class AppState {
|
||||||
user = $state(null);
|
user = $state(null);
|
||||||
projects = $state([]);
|
projects = $state([]);
|
||||||
|
/** The foldable headings over the projects in the sidebar. */
|
||||||
|
groups = $state([]);
|
||||||
tasks = $state([]);
|
tasks = $state([]);
|
||||||
loading = $state(true);
|
loading = $state(true);
|
||||||
/** Transient messages: a failed save, a rolled-back change. */
|
/** Transient messages: a failed save, a rolled-back change. */
|
||||||
@@ -29,6 +31,16 @@ class AppState {
|
|||||||
*/
|
*/
|
||||||
detailId = $state(null);
|
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;
|
#socket = null;
|
||||||
#reconnectDelay = 1000;
|
#reconnectDelay = 1000;
|
||||||
|
|
||||||
@@ -56,8 +68,12 @@ class AppState {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
this.user = await api.me();
|
this.user = await api.me();
|
||||||
const { projects } = await api.listProjects();
|
const [{ projects }, { groups }] = await Promise.all([
|
||||||
|
api.listProjects(),
|
||||||
|
api.listProjectGroups()
|
||||||
|
]);
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
|
this.groups = groups;
|
||||||
this.connect();
|
this.connect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
@@ -110,6 +126,36 @@ class AppState {
|
|||||||
case 'task.deleted':
|
case 'task.deleted':
|
||||||
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
|
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
|
||||||
break;
|
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.
|
* copy is not worth a moment's thought next to the class of bug it removes.
|
||||||
*/
|
*/
|
||||||
replace(id, next) {
|
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));
|
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,14 +204,33 @@ class AppState {
|
|||||||
*/
|
*/
|
||||||
upsert(task) {
|
upsert(task) {
|
||||||
if (!task?.id) return;
|
if (!task?.id) return;
|
||||||
if (this.tasks.some((t) => t.id === task.id)) this.replace(task.id, task);
|
if (this.tasks.some((t) => t.id === task.id)) {
|
||||||
else this.tasks = [...this.tasks, task];
|
this.replace(task.id, task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.labels?.length) this.labelsChanged++;
|
||||||
|
this.tasks = [...this.tasks, task];
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id) {
|
get(id) {
|
||||||
return this.tasks.find((t) => t.id === 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
|
* 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
|
* 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) {
|
async remove(id) {
|
||||||
const previous = [...this.tasks];
|
const previous = [...this.tasks];
|
||||||
this.tasks = this.tasks.filter((t) => t.id !== id);
|
this.tasks = this.tasks.filter((t) => t.id !== id);
|
||||||
@@ -233,7 +334,7 @@ class AppState {
|
|||||||
async createProject(name) {
|
async createProject(name) {
|
||||||
try {
|
try {
|
||||||
const project = await api.createProject({ name });
|
const project = await api.createProject({ name });
|
||||||
this.projects = [...this.projects, project];
|
this.upsertProject(project);
|
||||||
return project;
|
return project;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.toast(humanMessage(e));
|
this.toast(humanMessage(e));
|
||||||
@@ -269,6 +370,112 @@ class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- project groups -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** The same new-array upsert the projects need, and for the same reason. */
|
||||||
|
upsertGroup(group) {
|
||||||
|
if (!group?.id) return;
|
||||||
|
this.groups = this.groups.some((g) => g.id === group.id)
|
||||||
|
? this.groups.map((g) => (g.id === group.id ? group : g))
|
||||||
|
: [...this.groups, group];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGroup(name) {
|
||||||
|
try {
|
||||||
|
const group = await api.createProjectGroup(name);
|
||||||
|
this.upsertGroup(group);
|
||||||
|
return group;
|
||||||
|
} catch (e) {
|
||||||
|
this.toast(humanMessage(e));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds or unfolds a group.
|
||||||
|
*
|
||||||
|
* Applied first and saved after, like everything else here — a fold that waits
|
||||||
|
* for a round trip feels like a click that did not land. The rollback matters
|
||||||
|
* more than usual: this is stored on the account rather than in the browser, so
|
||||||
|
* a failed save that left the arrow turned would be a lie that survives a
|
||||||
|
* reload.
|
||||||
|
*/
|
||||||
|
async toggleGroup(id) {
|
||||||
|
const group = this.groups.find((g) => g.id === id);
|
||||||
|
if (!group) return;
|
||||||
|
await this.#patchGroup(id, { collapsed: !group.collapsed });
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameGroup(id, name) {
|
||||||
|
await this.#patchGroup(id, { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
async #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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 -------------------------------------------------------------------
|
// --- toasts -------------------------------------------------------------------
|
||||||
|
|
||||||
toast(message) {
|
toast(message) {
|
||||||
@@ -338,21 +545,94 @@ class SidebarLayout {
|
|||||||
|
|
||||||
export const sidebar = new SidebarLayout();
|
export const sidebar = new SidebarLayout();
|
||||||
|
|
||||||
/** Theme, kept in localStorage and applied to the document element. */
|
/**
|
||||||
export const theme = {
|
* Whether Kommende shows the next seven days as a list, or the month as a grid.
|
||||||
get current() {
|
*
|
||||||
if (typeof document === 'undefined') return 'dark';
|
* In localStorage rather than on the account, and for the same reason as the
|
||||||
return document.documentElement.dataset.theme ?? 'dark';
|
* 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
|
||||||
toggle() {
|
* the row because that is a fact about the project — this is a fact about the
|
||||||
const next = this.current === 'dark' ? 'light' : 'dark';
|
* screen.
|
||||||
document.documentElement.dataset.theme = next;
|
*/
|
||||||
|
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 {
|
try {
|
||||||
localStorage.setItem('verdande:theme', next);
|
localStorage.setItem('verdande:upcoming', next);
|
||||||
} catch {
|
} catch {
|
||||||
// Private browsing; the choice simply will not persist.
|
// 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 };
|
export { ApiError, humanMessage };
|
||||||
|
|||||||
@@ -13,6 +13,16 @@
|
|||||||
let paletteOpen = $state(false);
|
let paletteOpen = $state(false);
|
||||||
let sidebarOpen = $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(() => {
|
$effect(() => {
|
||||||
app.load();
|
app.load();
|
||||||
});
|
});
|
||||||
@@ -63,6 +73,8 @@
|
|||||||
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
|
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
|
||||||
flash of anxiety, not feedback. -->
|
flash of anxiety, not feedback. -->
|
||||||
<div class="booting"></div>
|
<div class="booting"></div>
|
||||||
|
{:else if standalone}
|
||||||
|
{@render children()}
|
||||||
{:else if !app.user}
|
{:else if !app.user}
|
||||||
<SignIn />
|
<SignIn />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -11,17 +11,23 @@
|
|||||||
* is six places for them to drift apart.
|
* is six places for them to drift apart.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
|
||||||
let { children } = $props();
|
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', label: 'Konto' },
|
||||||
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
|
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
|
||||||
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
|
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
|
||||||
{ href: '/indstillinger/ai', label: 'AI' },
|
{ href: '/indstillinger/ai', label: 'AI' },
|
||||||
{ href: '/indstillinger/tokens', label: 'API-tokens' },
|
{ href: '/indstillinger/tokens', label: 'API-tokens' },
|
||||||
|
...(app.user?.is_admin ? [{ href: '/indstillinger/brugere', label: 'Brugere' }] : []),
|
||||||
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||||
</script>
|
</script>
|
||||||
@@ -32,7 +38,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav aria-label="Indstillinger">
|
<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>
|
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
/** Konto: who you are, your password, and the second factor. */
|
/** Konto: who you are, your password, and the second factor. */
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||||
|
|
||||||
// --- profile ---------------------------------------------------------------
|
// --- profile ---------------------------------------------------------------
|
||||||
|
|
||||||
@@ -144,6 +144,54 @@
|
|||||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -204,6 +252,39 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>Adgangskode</h2>
|
<h2>Adgangskode</h2>
|
||||||
@@ -334,6 +415,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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>
|
<style>
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -341,6 +451,58 @@
|
|||||||
gap: var(--s4);
|
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 {
|
.codes {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -353,6 +515,82 @@
|
|||||||
border-radius: var(--radius);
|
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 {
|
.secret {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--s3);
|
padding: var(--s3);
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Brugere: who is on this instance, and how somebody else gets on it.
|
||||||
|
*
|
||||||
|
* There is no open registration and no account created with a password
|
||||||
|
* somebody else chose. Adding a person means issuing an invite with no project
|
||||||
|
* attached; the link ends with a password only its owner has seen. So this page
|
||||||
|
* has two lists — the accounts, and the invites that have gone out and not been
|
||||||
|
* used yet.
|
||||||
|
*/
|
||||||
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
|
||||||
|
let users = $state([]);
|
||||||
|
let invites = $state([]);
|
||||||
|
let email = $state('');
|
||||||
|
let link = $state('');
|
||||||
|
let errors = $state({});
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await api.listUsers();
|
||||||
|
users = r.users;
|
||||||
|
invites = r.invites;
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invite(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
errors = {};
|
||||||
|
link = '';
|
||||||
|
try {
|
||||||
|
const result = await api.inviteUser(email);
|
||||||
|
email = '';
|
||||||
|
// Shown whether or not it was emailed. With no mail server this is the
|
||||||
|
// only way it reaches anybody, and with one it saves waiting on
|
||||||
|
// delivery to find out whether it worked.
|
||||||
|
link = result.link;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
errors = e.fields ?? {};
|
||||||
|
if (!Object.keys(errors).length) app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAdmin(user, isAdmin) {
|
||||||
|
const previous = users;
|
||||||
|
users = users.map((u) => (u.id === user.id ? { ...u, is_admin: isAdmin } : u));
|
||||||
|
try {
|
||||||
|
await api.setUserAdmin(user.id, isAdmin);
|
||||||
|
} catch (e) {
|
||||||
|
users = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting an account is the only thing in this app that cannot be undone.
|
||||||
|
*
|
||||||
|
* Everything else is soft-deleted with a trash behind it. This is not, and it
|
||||||
|
* takes more than the person's own work: `tasks.created_by` cascades, so what
|
||||||
|
* they wrote in projects somebody else owns goes too. The confirmation says the
|
||||||
|
* numbers out loud, which is why the list carries them.
|
||||||
|
*/
|
||||||
|
async function remove(user) {
|
||||||
|
const parts = [`Slet ${user.name} <${user.email}> for altid?`, ''];
|
||||||
|
if (user.project_count || user.task_count) {
|
||||||
|
parts.push(
|
||||||
|
`Det sletter ${count(user.project_count, 'projekt', 'projekter')} og ` +
|
||||||
|
`${count(user.task_count, 'opgave', 'opgaver')} — også opgaver, personen har ` +
|
||||||
|
`skrevet i projekter, andre ejer.`,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parts.push('Det kan ikke fortrydes. Der er ingen papirkurv for konti.');
|
||||||
|
if (!confirm(parts.join('\n'))) return;
|
||||||
|
|
||||||
|
const previous = users;
|
||||||
|
users = users.filter((u) => u.id !== user.id);
|
||||||
|
try {
|
||||||
|
await api.deleteUser(user.id);
|
||||||
|
} catch (e) {
|
||||||
|
users = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(pending) {
|
||||||
|
if (!confirm(`Træk invitationen til ${pending.email} tilbage? Linket holder op med at virke.`))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const previous = invites;
|
||||||
|
invites = invites.filter((i) => i.id !== pending.id);
|
||||||
|
try {
|
||||||
|
await api.revokeInvite(pending.id);
|
||||||
|
} catch (e) {
|
||||||
|
invites = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
||||||
|
|
||||||
|
function ago(iso) {
|
||||||
|
if (!iso) return 'aldrig logget ind';
|
||||||
|
const then = new Date(iso);
|
||||||
|
const seconds = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (seconds < 60) return 'lige nu';
|
||||||
|
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||||
|
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||||
|
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
|
||||||
|
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires = (iso) =>
|
||||||
|
new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>Inviter en bruger</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Der er ingen åben registrering. Du sender et link, og personen vælger selv navn
|
||||||
|
og adgangskode — så er der ikke en adgangskode, to mennesker kender.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onsubmit={invite}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="ny-email">E-mailadresse</label>
|
||||||
|
<input
|
||||||
|
id="ny-email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
placeholder="navn@example.dk"
|
||||||
|
required
|
||||||
|
aria-invalid={errors.email ? 'true' : undefined}
|
||||||
|
/>
|
||||||
|
{#if errors.email}<p class="error">Skal være en e-mailadresse.</p>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" type="submit" disabled={busy}>Send invitation</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if link}
|
||||||
|
<div class="field">
|
||||||
|
<p class="hint">Invitationen er oprettet. Send linket til personen:</p>
|
||||||
|
<code class="link-out">{link}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if invites.length}
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>Afventer svar</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Invitationer, der er sendt og ikke brugt endnu. Kun linkets hash er gemt, så
|
||||||
|
det kan ikke vises igen — er det gået til den forkerte, så træk det tilbage.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="rows">
|
||||||
|
{#each invites as pending (pending.id)}
|
||||||
|
<li>
|
||||||
|
<div class="what">
|
||||||
|
<span class="primary-line">{pending.email}</span>
|
||||||
|
<span class="secondary">
|
||||||
|
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
|
||||||
|
· udløber {expires(pending.expires_at)}
|
||||||
|
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="secondary" onclick={() => revoke(pending)}>Træk tilbage</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>Konti</h2>
|
||||||
|
<p class="hint">Alle på denne instans.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="rows">
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<li>
|
||||||
|
<span class="avatar" style="background: {user.avatar_color}">
|
||||||
|
{user.name[0]?.toUpperCase() ?? '?'}
|
||||||
|
</span>
|
||||||
|
<div class="what">
|
||||||
|
<span class="primary-line">
|
||||||
|
{user.name}
|
||||||
|
{#if user.is_admin}<span class="badge">administrator</span>{/if}
|
||||||
|
{#if user.self}<span class="badge quiet">dig</span>{/if}
|
||||||
|
</span>
|
||||||
|
<span class="secondary">
|
||||||
|
{user.email} · {ago(user.last_seen_at)}
|
||||||
|
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nobody demotes or deletes themselves from here. The server refuses
|
||||||
|
both, and a button that exists only to be refused is a button that
|
||||||
|
wastes a click to teach you a rule. -->
|
||||||
|
{#if !user.self}
|
||||||
|
<button class="secondary" onclick={() => setAdmin(user, !user.is_admin)}>
|
||||||
|
{user.is_admin ? 'Fjern administrator' : 'Gør til administrator'}
|
||||||
|
</button>
|
||||||
|
<button class="danger" onclick={() => remove(user)}>Slet</button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 560;
|
||||||
|
color: #fff;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0 var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.quiet {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-out {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--s1);
|
||||||
|
padding: var(--s2);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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 />
|
||||||
@@ -63,7 +63,13 @@
|
|||||||
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
|
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
|
||||||
let isOwner = $derived(project?.role === 'owner');
|
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 unsectioned = $derived(open.filter((t) => !t.section_id));
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
@@ -101,6 +107,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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) {
|
async function invite(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
inviteError = '';
|
inviteError = '';
|
||||||
@@ -240,7 +301,7 @@
|
|||||||
{#if mode === 'board'}
|
{#if mode === 'board'}
|
||||||
<BoardView {project} {sections} {canEdit} />
|
<BoardView {project} {sections} {canEdit} />
|
||||||
{:else if mode === 'calendar'}
|
{:else if mode === 'calendar'}
|
||||||
<CalendarView />
|
<CalendarView projectId={project.id} />
|
||||||
{:else}
|
{:else}
|
||||||
<section>
|
<section>
|
||||||
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
||||||
@@ -249,7 +310,34 @@
|
|||||||
{#each sections as section (section.id)}
|
{#each sections as section (section.id)}
|
||||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||||
<section>
|
<section>
|
||||||
<h2>{section.name}</h2>
|
<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} />
|
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
|
||||||
{#if !tasks.length}
|
{#if !tasks.length}
|
||||||
<p class="empty">Tom</p>
|
<p class="empty">Tom</p>
|
||||||
@@ -257,7 +345,35 @@
|
|||||||
</section>
|
</section>
|
||||||
{/each}
|
{/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">
|
<p class="clear">
|
||||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||||
Ingenting her endnu.
|
Ingenting her endnu.
|
||||||
@@ -548,6 +664,79 @@
|
|||||||
margin-bottom: var(--s2);
|
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,
|
.empty,
|
||||||
.readonly {
|
.readonly {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -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>
|
<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 { 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 TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||||
|
|
||||||
let days = $state([]);
|
let days = $state([]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if (upcomingView.mode !== 'list') return;
|
||||||
api.upcoming().then((data) => {
|
api.upcoming().then((data) => {
|
||||||
days = data.days;
|
days = data.days;
|
||||||
app.tasks = data.days.flatMap((d) => d.tasks);
|
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) {
|
function heading(date) {
|
||||||
const day = new Date(date + 'T00:00:00');
|
const day = new Date(date + 'T00:00:00');
|
||||||
const today = new Date(new Date().toDateString());
|
const today = new Date(new Date().toDateString());
|
||||||
@@ -25,28 +46,80 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reading tasks back out of the store keeps a completed row disappearing
|
// 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);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="view">
|
<div class="view" class:wide={upcomingView.mode === 'calendar'}>
|
||||||
<header><h1>Kommende</h1></header>
|
<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 />
|
<QuickAdd />
|
||||||
|
|
||||||
{#each days as day (day.date)}
|
{#if upcomingView.mode === 'calendar'}
|
||||||
{@const tasks = live(day.date)}
|
<CalendarView onrange={loadMonth} />
|
||||||
<section>
|
{:else}
|
||||||
<h2>{heading(day.date)}</h2>
|
{#each days as day (day.date)}
|
||||||
{#if tasks.length}
|
{@const tasks = live(day.date)}
|
||||||
{#each tasks as task (task.id)}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<TaskRow {task} />
|
<section
|
||||||
{/each}
|
class:over={over === day.date}
|
||||||
{:else}
|
ondragover={(e) => onDragOver(e, day.date)}
|
||||||
<p class="empty">—</p>
|
ondragleave={() => (over = null)}
|
||||||
{/if}
|
ondrop={(e) => onDrop(e, day.date)}
|
||||||
</section>
|
>
|
||||||
{/each}
|
<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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -56,13 +129,70 @@
|
|||||||
padding: var(--s6) var(--s4) var(--s8);
|
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 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
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 {
|
section {
|
||||||
margin-top: var(--s5);
|
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 {
|
h2 {
|
||||||
|
|||||||
Reference in New Issue
Block a user