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