Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494640c1a8 | ||
|
|
6d27d730d2 | ||
|
|
b0ab6ee5c0 | ||
|
|
c2e2d66e76 | ||
|
|
4be934ccc1 | ||
|
|
7094e05ddd | ||
|
|
35d9e3bdac | ||
|
|
b6ea87ac19 | ||
|
|
fc32fea607 | ||
|
|
8b1f374855 | ||
|
|
550f420c59 |
+20
-3
@@ -5,7 +5,17 @@
|
|||||||
# manager and no libc — the attack surface of the running container is the binary.
|
# manager and no libc — the attack surface of the running container is the binary.
|
||||||
|
|
||||||
# --- 1. the web interface ----------------------------------------------------
|
# --- 1. the web interface ----------------------------------------------------
|
||||||
FROM node:22-alpine AS web
|
#
|
||||||
|
# Pinned to the *build* platform, not the target. What this stage produces is
|
||||||
|
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
|
||||||
|
# on — so building it once and copying it into both architectures is not an
|
||||||
|
# optimisation, it is the correct thing.
|
||||||
|
#
|
||||||
|
# Without the pin, buildx runs this stage once per target, which means running
|
||||||
|
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
|
||||||
|
# instruction the emulator does not implement, roughly one release in three. It
|
||||||
|
# looks like a broken build and is a broken emulator.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 2. the binary -----------------------------------------------------------
|
# --- 2. the binary -----------------------------------------------------------
|
||||||
FROM golang:1.26-alpine AS build
|
#
|
||||||
|
# Also pinned to the build platform, and cross-compiled instead. Go does that
|
||||||
|
# natively and CGO is already off, so there is nothing to emulate — where
|
||||||
|
# building *in* the target architecture means running the whole Go toolchain
|
||||||
|
# under QEMU for one of the two.
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -37,11 +52,13 @@ COPY . .
|
|||||||
COPY --from=web /src/build ./cmd/verdande/webbuild
|
COPY --from=web /src/build ./cmd/verdande/webbuild
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
# Supplied by buildx per target. The default keeps a plain `docker build` working.
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
|
||||||
# CGO off is what makes this binary static, and it is only possible because the
|
# CGO off is what makes this binary static, and it is only possible because the
|
||||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||||
-tags embedweb \
|
-tags embedweb \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
|
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
|
||||||
| Today, Upcoming, search, activity log | Done, tested |
|
| Today, Upcoming, search, activity log | Done, tested |
|
||||||
| Waiting on others: what you have delegated, by person | Done, tested |
|
| 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 |
|
| Sharing: invites, roles, members | Done, tested |
|
||||||
| Live sync over WebSocket | Done |
|
| Live sync over WebSocket | Done |
|
||||||
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
||||||
@@ -72,6 +75,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
||||||
| Device list: where you are signed in, and signing one out | Done, tested |
|
| Device list: where you are signed in, and signing one out | Done, tested |
|
||||||
| User administration: invite to the instance, promote, remove | Done, tested |
|
| User administration: invite to the instance, promote, remove | Done, tested |
|
||||||
|
| Server errors kept in the database, readable after a restart | Done, tested |
|
||||||
|
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
|
||||||
| Invite and password-reset links that open the right page | Done, smoke-tested |
|
| Invite and password-reset links that open the right page | Done, smoke-tested |
|
||||||
| Drag to reorder, in both the list and the board | Done |
|
| Drag to reorder, in both the list and the board | Done |
|
||||||
| Drag a task to another project or another day | Done, smoke-tested |
|
| Drag a task to another project or another day | Done, smoke-tested |
|
||||||
|
|||||||
+142
-2
@@ -856,6 +856,60 @@ paths:
|
|||||||
"204": { description: Deleted. }
|
"204": { description: Deleted. }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/errors:
|
||||||
|
get:
|
||||||
|
tags: [auth]
|
||||||
|
summary: What has gone wrong lately. Administrators only.
|
||||||
|
description: |
|
||||||
|
Every 500 the API has answered, newest first, with what the handler was
|
||||||
|
attempting and the error it hit.
|
||||||
|
|
||||||
|
These are written to the database rather than only to the log because the
|
||||||
|
log does not survive. On a Rune the log belongs to the container, and the
|
||||||
|
container is replaced on every restart — so a panel watcher reports "HTTP
|
||||||
|
5xx, twice, at 11:49" long after the line explaining it has gone, which
|
||||||
|
tells an operator that something broke and gives them no way to find out
|
||||||
|
what.
|
||||||
|
|
||||||
|
Kept for thirty days by the nightly sweep: long enough to answer "what
|
||||||
|
happened last week", short enough that a fault loop cannot fill the volume
|
||||||
|
the tasks live on.
|
||||||
|
parameters:
|
||||||
|
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
at: { type: string, format: date-time }
|
||||||
|
method: { type: string }
|
||||||
|
path: { type: string }
|
||||||
|
status: { type: integer }
|
||||||
|
what:
|
||||||
|
type: string
|
||||||
|
description: What the handler was attempting, in its own words.
|
||||||
|
example: list projects
|
||||||
|
message: { type: string }
|
||||||
|
user_name:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Who was asking, when there was somebody. A fault only
|
||||||
|
one account hits is a different problem from one
|
||||||
|
everybody hits.
|
||||||
|
request_id:
|
||||||
|
type: string
|
||||||
|
description: Ties the row back to the log line, for whoever still has it.
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
/api/v1/users:
|
/api/v1/users:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
@@ -1211,9 +1265,46 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
- { name: userID, 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:
|
delete:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
summary: Remove somebody from a project. Its owner only.
|
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:
|
responses:
|
||||||
"204": { description: Removed. }
|
"204": { description: Removed. }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
@@ -1315,6 +1406,36 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Task" }
|
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:
|
/api/v1/delegated:
|
||||||
get:
|
get:
|
||||||
tags: [views]
|
tags: [views]
|
||||||
@@ -2549,7 +2670,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
color: { type: string }
|
color: { $ref: "#/components/schemas/Color" }
|
||||||
icon: { type: string }
|
icon: { type: string }
|
||||||
view_mode: { type: string, enum: [list, board, calendar] }
|
view_mode: { type: string, enum: [list, board, calendar] }
|
||||||
owner_id: { type: string }
|
owner_id: { type: string }
|
||||||
@@ -2570,7 +2691,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
color: { type: string }
|
color: { $ref: "#/components/schemas/Color" }
|
||||||
icon: { type: string }
|
icon: { type: string }
|
||||||
view_mode: { type: string, enum: [list, board, calendar] }
|
view_mode: { type: string, enum: [list, board, calendar] }
|
||||||
archived: { type: boolean }
|
archived: { type: boolean }
|
||||||
@@ -2588,6 +2709,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
|
color: { $ref: "#/components/schemas/Color" }
|
||||||
collapsed: { type: boolean }
|
collapsed: { type: boolean }
|
||||||
sort_order: { type: number }
|
sort_order: { type: number }
|
||||||
|
|
||||||
@@ -2595,8 +2717,26 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
|
color: { $ref: "#/components/schemas/Color" }
|
||||||
collapsed: { type: boolean }
|
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:
|
Section:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
@@ -88,7 +90,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create session", err)
|
s.internal(w, r, "create session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setSessionCookie(w, token, ttl)
|
s.setSessionCookie(w, token, ttl)
|
||||||
@@ -130,7 +132,7 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||||
s.internal(w, "promote session", err)
|
s.internal(w, r, "promote session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The cookie is reissued so its lifetime matches the session's new one.
|
// The cookie is reissued so its lifetime matches the session's new one.
|
||||||
@@ -210,13 +212,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
|
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
|
||||||
s.storeError(w, "update profile", err)
|
s.storeError(w, r, "update profile", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := s.db.UserByID(r.Context(), user.ID)
|
updated, err := s.db.UserByID(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "reload user", err)
|
s.internal(w, r, "reload user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
||||||
@@ -263,7 +265,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,14 +279,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
"an account already exists for that email address")
|
"an account already exists for that email address")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.internal(w, "create user", err)
|
s.internal(w, r, "create user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepting the invite is what adds the membership and burns the token, in one
|
// Accepting the invite is what adds the membership and burns the token, in one
|
||||||
// transaction: a signup that half-succeeded would leave a usable invite behind.
|
// transaction: a signup that half-succeeded would leave a usable invite behind.
|
||||||
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
|
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
|
||||||
s.internal(w, "accept invite", err)
|
s.internal(w, r, "accept invite", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +330,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +343,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.internal(w, "create first admin", err)
|
s.internal(w, r, "create first admin", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +357,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||||
n, err := s.db.UserCount(r.Context())
|
n, err := s.db.UserCount(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "count users", err)
|
s.internal(w, r, "count users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||||
@@ -364,7 +366,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
|
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create session", err)
|
s.internal(w, r, "create session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
||||||
@@ -393,7 +395,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create password reset", err)
|
s.internal(w, r, "create password reset", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
link := s.cfg.BaseURL + "/reset?token=" + token
|
link := s.cfg.BaseURL + "/reset?token=" + token
|
||||||
@@ -431,13 +433,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Every session ends: a password reset is what somebody does when they think
|
// Every session ends: a password reset is what somebody does when they think
|
||||||
// another person has been in their account.
|
// another person has been in their account.
|
||||||
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
|
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
|
||||||
s.internal(w, "update password", err)
|
s.internal(w, r, "update password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +471,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.NewPassword)
|
hash, err := auth.HashPassword(req.NewPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keep := ""
|
keep := ""
|
||||||
@@ -477,7 +479,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
keep = session.ID
|
keep = session.ID
|
||||||
}
|
}
|
||||||
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
|
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
|
||||||
s.internal(w, "update password", err)
|
s.internal(w, r, "update password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -520,7 +522,27 @@ func inboxName(locale string) string {
|
|||||||
// internal logs the real error and tells the client nothing about it. The detail
|
// internal logs the real error and tells the client nothing about it. The detail
|
||||||
// belongs in the log, where the operator can see it; in the response it would be a
|
// belongs in the log, where the operator can see it; in the response it would be a
|
||||||
// description of the server's internals handed to whoever asked.
|
// description of the server's internals handed to whoever asked.
|
||||||
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
|
//
|
||||||
|
// It is also written to the database. On a Rune the log is the container's, and
|
||||||
|
// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at
|
||||||
|
// 11:49" long after the line explaining it has gone, which tells an operator that
|
||||||
|
// something broke and gives them no way to find out what. The row outlives the
|
||||||
|
// container; Indstillinger → Fejl is where it is read.
|
||||||
|
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
s.log.Error(what, "err", err)
|
s.log.Error(what, "err", err)
|
||||||
|
|
||||||
|
userID := ""
|
||||||
|
if u := userFrom(r.Context()); u != nil {
|
||||||
|
userID = u.ID
|
||||||
|
}
|
||||||
|
// Best effort, and deliberately not checked: this is the path that is already
|
||||||
|
// reporting a failure, and a diagnostic that can fail the request would turn
|
||||||
|
// one broken screen into two.
|
||||||
|
s.db.RecordError(r.Context(), store.ServerError{
|
||||||
|
Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError,
|
||||||
|
What: what, Message: err.Error(), UserID: userID,
|
||||||
|
RequestID: middleware.GetReqID(r.Context()),
|
||||||
|
})
|
||||||
|
|
||||||
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
|
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "caldav home", err)
|
s.internal(w, r, "caldav home", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
if r.Header.Get("Depth") != "0" {
|
if r.Header.Get("Depth") != "0" {
|
||||||
tasks, err := s.caldavTasks(r, user, projectID)
|
tasks, err := s.caldavTasks(r, user, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "caldav tasks", err)
|
s.internal(w, r, "caldav tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, t := range tasks {
|
for _, t := range tasks {
|
||||||
@@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tasks, err := s.caldavTasks(r, user, projectID)
|
tasks, err := s.caldavTasks(r, user, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "caldav report", err)
|
s.internal(w, r, "caldav report", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
byID := map[string]store.Task{}
|
byID := map[string]store.Task{}
|
||||||
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
update.DueDatetime = parsed.DueDatetime
|
update.DueDatetime = parsed.DueDatetime
|
||||||
|
|
||||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
|
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
|
||||||
s.internal(w, "caldav update", err)
|
s.internal(w, r, "caldav update", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if parsed.Completed && !existing.Completed() {
|
if parsed.Completed && !existing.Completed() {
|
||||||
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
task.Content = "(uden titel)"
|
task.Content = "(uden titel)"
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
|
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
|
||||||
s.internal(w, "caldav create", err)
|
s.internal(w, r, "caldav create", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||||
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||||
s.internal(w, "caldav delete", err)
|
s.internal(w, r, "caldav delete", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
comments, err := s.db.ListComments(r.Context(), taskID)
|
comments, err := s.db.ListComments(r.Context(), taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list comments", err)
|
s.internal(w, r, "list comments", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]commentJSON, 0, len(comments))
|
out := make([]commentJSON, 0, len(comments))
|
||||||
@@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
|
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list attachments", err)
|
s.internal(w, r, "list attachments", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
direct := make([]attachmentJSON, 0, len(files))
|
direct := make([]attachmentJSON, 0, len(files))
|
||||||
@@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
|
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create comment", err)
|
s.internal(w, r, "create comment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
|
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
|
||||||
@@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
|
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "update comment", err)
|
s.storeError(w, r, "update comment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
taskID, err := s.db.CommentTask(r.Context(), commentID)
|
taskID, err := s.db.CommentTask(r.Context(), commentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "comment task", err)
|
s.storeError(w, r, "comment task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
@@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
|
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
|
||||||
s.storeError(w, "delete comment", err)
|
s.storeError(w, r, "delete comment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
stored, size, err := s.storeUpload(file)
|
stored, size, err := s.storeUpload(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "store upload", err)
|
s.internal(w, r, "store upload", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||||
s.internal(w, "record attachment", err)
|
s.internal(w, r, "record attachment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +306,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "attachment task", err)
|
s.storeError(w, r, "attachment task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
|
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
|
||||||
@@ -316,7 +316,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get attachment", err)
|
s.storeError(w, r, "get attachment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "attachment task", err)
|
s.storeError(w, r, "attachment task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
@@ -369,7 +369,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete attachment", err)
|
s.storeError(w, r, "delete attachment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What has gone wrong lately, kept where a restart cannot take it.
|
||||||
|
//
|
||||||
|
// The panel's watcher reports "HTTP 5xx, twice, at 11:49" — which is enough to
|
||||||
|
// know something broke and nothing at all to act on. The line that explains it is
|
||||||
|
// in the container's log, and a Rune's container is replaced on every restart, so
|
||||||
|
// by the time anybody looks the explanation is gone. These rows are the same
|
||||||
|
// information written somewhere that survives.
|
||||||
|
|
||||||
|
type serverErrorJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
At string `json:"at"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// What the handler was doing, in its own words — "list projects", "move task".
|
||||||
|
What string `json:"what"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
// Who was asking, when there was somebody: a fault only one account hits is a
|
||||||
|
// different problem from one everybody hits.
|
||||||
|
UserName string `json:"user_name,omitempty"`
|
||||||
|
// RequestID ties the row back to the log line, for whoever still has it.
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListErrors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
errs, err := s.db.ListErrors(r.Context(), parseLimit(r.URL.Query().Get("limit"), 100, 500))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list errors", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]serverErrorJSON, 0, len(errs))
|
||||||
|
for _, e := range errs {
|
||||||
|
out = append(out, serverErrorJSON{
|
||||||
|
ID: e.ID, At: e.At.Format(time.RFC3339), Method: e.Method, Path: e.Path,
|
||||||
|
Status: e.Status, What: e.What, Message: e.Message,
|
||||||
|
UserName: e.UserName, RequestID: e.RequestID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"errors": out})
|
||||||
|
}
|
||||||
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: 2000,
|
Limit: 2000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ics feed", err)
|
s.internal(w, r, "ics feed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ics feed projects", err)
|
s.internal(w, r, "ics feed projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
names := map[string]string{}
|
names := map[string]string{}
|
||||||
@@ -89,7 +89,7 @@ func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ics token", err)
|
s.internal(w, r, "ics token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||||
@@ -103,11 +103,11 @@ func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, err := auth.NewToken()
|
token, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate feed token", err)
|
s.internal(w, r, "generate feed token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
||||||
s.internal(w, "set feed token", err)
|
s.internal(w, r, "set feed token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||||
@@ -151,7 +151,7 @@ func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list reminders", err)
|
s.internal(w, r, "list reminders", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]reminderJSON, 0, len(reminders))
|
out := make([]reminderJSON, 0, len(reminders))
|
||||||
@@ -197,7 +197,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create reminder", err)
|
s.internal(w, r, "create reminder", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete reminder", err)
|
s.storeError(w, r, "delete reminder", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
|
|||||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||||
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list templates", err)
|
s.internal(w, r, "list templates", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]templateJSON, 0, len(templates))
|
out := make([]templateJSON, 0, len(templates))
|
||||||
@@ -273,7 +273,7 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
|
|||||||
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
||||||
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "save template", err)
|
s.storeError(w, r, "save template", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
||||||
@@ -295,7 +295,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get template", err)
|
s.storeError(w, r, "get template", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create from template", err)
|
s.internal(w, r, "create from template", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, project.ID, "", "project.created", map[string]any{
|
s.activity(r, project.ID, "", "project.created", map[string]any{
|
||||||
@@ -323,7 +323,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete template", err)
|
s.storeError(w, r, "delete template", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
|
|||||||
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
||||||
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list filters", err)
|
s.internal(w, r, "list filters", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]filterJSON, 0, len(filters))
|
out := make([]filterJSON, 0, len(filters))
|
||||||
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
f.SortOrder = *req.SortOrder
|
f.SortOrder = *req.SortOrder
|
||||||
}
|
}
|
||||||
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
||||||
s.internal(w, "create filter", err)
|
s.internal(w, r, "create filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
|
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
|
||||||
@@ -108,7 +108,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
|
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "update filter", err)
|
s.storeError(w, r, "update filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -117,7 +117,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete filter", err)
|
s.storeError(w, r, "delete filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -129,7 +129,7 @@ func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
|
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get filter", err)
|
s.storeError(w, r, "get filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.runQuery(w, r, f.Query)
|
s.runQuery(w, r, f.Query)
|
||||||
@@ -167,7 +167,7 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string)
|
|||||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "run filter", err)
|
s.internal(w, r, "run filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||||
@@ -210,7 +210,7 @@ type labelJSON struct {
|
|||||||
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
|
||||||
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list labels", err)
|
s.internal(w, r, "list labels", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]labelJSON, 0, len(labels))
|
out := make([]labelJSON, 0, len(labels))
|
||||||
@@ -242,7 +242,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.internal(w, "create label", err)
|
s.internal(w, r, "create label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||||
@@ -261,7 +261,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.storeError(w, "update label", err)
|
s.storeError(w, r, "update label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publishToOwner(r, "label.changed", nil)
|
s.publishToOwner(r, "label.changed", nil)
|
||||||
@@ -271,7 +271,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
|
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete label", err)
|
s.storeError(w, r, "delete label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publishToOwner(r, "label.changed", nil)
|
s.publishToOwner(r, "label.changed", nil)
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
pkce, err := gmail.NewPKCE()
|
pkce, err := gmail.NewPKCE()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail pkce", err)
|
s.internal(w, r, "gmail pkce", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settings["pkce_verifier"] = pkce.Verifier
|
settings["pkce_verifier"] = pkce.Verifier
|
||||||
@@ -53,7 +53,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
|||||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||||
@@ -83,7 +83,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
verifier, _ := settings["pkce_verifier"].(string)
|
verifier, _ := settings["pkce_verifier"].(string)
|
||||||
@@ -138,7 +138,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||||
s.internal(w, "save gmail tokens", err)
|
s.internal(w, r, "save gmail tokens", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
|
|||||||
project := todoist.FromRows(rows, name)
|
project := todoist.FromRows(rows, name)
|
||||||
result, err := s.importProject(r, user, project)
|
result, err := s.importProject(r, user, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "import project", err)
|
s.internal(w, r, "import project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, result)
|
writeJSON(w, http.StatusCreated, result)
|
||||||
@@ -240,7 +240,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result, err := s.importProject(r, user, project)
|
result, err := s.importProject(r, user, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "import csv", err)
|
s.internal(w, r, "import csv", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, result)
|
writeJSON(w, http.StatusCreated, result)
|
||||||
@@ -255,12 +255,12 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exported, err := s.buildExport(r, user, project)
|
exported, err := s.buildExport(r, user, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export project", err)
|
s.internal(w, r, "export project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export projects", err)
|
s.internal(w, r, "export projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export tasks", err)
|
s.internal(w, r, "export tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, t := range tasks {
|
for _, t := range tasks {
|
||||||
@@ -445,14 +445,14 @@ func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export ics", err)
|
s.internal(w, r, "export ics", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "mail token", err)
|
s.internal(w, r, "mail token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||||
@@ -49,11 +49,11 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||||
token, err := auth.NewToken()
|
token, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate mail token", err)
|
s.internal(w, r, "generate mail token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
||||||
s.internal(w, "set mail token", err)
|
s.internal(w, r, "set mail token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||||
@@ -134,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
s.internal(w, "inbox", err)
|
s.internal(w, r, "inbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||||
s.internal(w, "create task from mail", err)
|
s.internal(w, r, "create task from mail", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ type aiSettings struct {
|
|||||||
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The key is never sent back. A settings page that repopulates a password
|
// The key is never sent back. A settings page that repopulates a password
|
||||||
@@ -216,7 +216,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
existing, err := s.aiConfig(r, user.ID)
|
existing, err := s.aiConfig(r, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
||||||
@@ -231,7 +231,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"model": req.Model, "api_key": key,
|
"model": req.Model, "api_key": key,
|
||||||
}
|
}
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
|
||||||
s.internal(w, "save ai settings", err)
|
s.internal(w, r, "save ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -266,7 +266,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
cfg, err := s.aiConfig(r, user.ID)
|
cfg, err := s.aiConfig(r, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !cfg.Configured() {
|
if !cfg.Configured() {
|
||||||
@@ -278,7 +278,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
Priority: task.Priority, CreatedBy: user.ID,
|
Priority: task.Priority, CreatedBy: user.ID,
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
|
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
|
||||||
s.internal(w, "create subtask", err)
|
s.internal(w, r, "create subtask", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created = append(created, toTaskJSON(*child))
|
created = append(created, toTaskJSON(*child))
|
||||||
@@ -315,7 +315,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
cfg, err := s.aiConfig(r, user.ID)
|
cfg, err := s.aiConfig(r, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !cfg.Configured() {
|
if !cfg.Configured() {
|
||||||
@@ -326,7 +326,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list tasks", err)
|
s.internal(w, r, "list tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(tasks) == 0 {
|
if len(tasks) == 0 {
|
||||||
@@ -372,7 +372,7 @@ type gmailSettings struct {
|
|||||||
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
||||||
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
str := func(key string) string {
|
str := func(key string) string {
|
||||||
@@ -396,14 +396,14 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
values["trigger"] = req.Trigger
|
values["trigger"] = req.Trigger
|
||||||
values["label"] = req.Label
|
values["label"] = req.Label
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
||||||
s.internal(w, "save gmail settings", err)
|
s.internal(w, r, "save gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -412,7 +412,7 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleDisconnectGmail forgets the connection, tokens and all.
|
// handleDisconnectGmail forgets the connection, tokens and all.
|
||||||
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
|
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
|
||||||
s.internal(w, "disconnect gmail", err)
|
s.internal(w, r, "disconnect gmail", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
user, err := s.db.UserByAPIToken(r.Context(), key)
|
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, store.ErrNotFound) {
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
s.internal(w, "mcp key", err)
|
s.internal(w, r, "mcp key", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|||||||
// as everybody being logged out at once — the frontend clears its state
|
// as everybody being logged out at once — the frontend clears its state
|
||||||
// and shows the sign-in screen — and nothing in the logs says why.
|
// and shows the sign-in screen — and nothing in the logs says why.
|
||||||
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
||||||
s.internal(w, "authenticate", err)
|
s.internal(w, r, "authenticate", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list notifications", err)
|
s.internal(w, r, "list notifications", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "count notifications", err)
|
s.internal(w, r, "count notifications", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "mark notifications read", err)
|
s.internal(w, r, "mark notifications read", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -148,7 +148,7 @@ type pushSubscriptionRequest struct {
|
|||||||
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||||
public, _, err := s.vapidKeys(r.Context())
|
public, _, err := s.vapidKeys(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "vapid keys", err)
|
s.internal(w, r, "vapid keys", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
||||||
@@ -172,7 +172,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "save push subscription", err)
|
s.internal(w, r, "save push subscription", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||||
s.internal(w, "delete push subscription", err)
|
s.internal(w, r, "delete push subscription", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -19,18 +19,22 @@ import (
|
|||||||
type projectGroupJSON struct {
|
type projectGroupJSON struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
Collapsed bool `json:"collapsed"`
|
Collapsed bool `json:"collapsed"`
|
||||||
SortOrder float64 `json:"sort_order"`
|
SortOrder float64 `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
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) {
|
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list project groups", err)
|
s.internal(w, r, "list project groups", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]projectGroupJSON, 0, len(groups))
|
out := make([]projectGroupJSON, 0, len(groups))
|
||||||
@@ -42,6 +46,7 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
type projectGroupRequest struct {
|
type projectGroupRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
Collapsed *bool `json:"collapsed"`
|
Collapsed *bool `json:"collapsed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +64,20 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
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}
|
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
|
||||||
|
if req.Color != nil {
|
||||||
|
g.Color = *req.Color
|
||||||
|
}
|
||||||
if req.Collapsed != nil {
|
if req.Collapsed != nil {
|
||||||
g.Collapsed = *req.Collapsed
|
g.Collapsed = *req.Collapsed
|
||||||
}
|
}
|
||||||
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
||||||
s.internal(w, "create project group", err)
|
s.internal(w, r, "create project group", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||||||
@@ -87,16 +100,20 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
req.Name = &name
|
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{
|
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 {
|
}); err != nil {
|
||||||
s.storeError(w, "update project group", err)
|
s.storeError(w, r, "update project group", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project group", err)
|
s.storeError(w, r, "get project group", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
||||||
@@ -109,7 +126,7 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
|
|||||||
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
groupID := chi.URLParam(r, "groupID")
|
groupID := chi.URLParam(r, "groupID")
|
||||||
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
||||||
s.storeError(w, "delete project group", err)
|
s.storeError(w, r, "delete project group", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||||||
@@ -128,7 +145,7 @@ func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||||
s.internal(w, "reorder project groups", err)
|
s.internal(w, r, "reorder project groups", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -219,3 +219,53 @@ func groupOf(t *testing.T, ts *testServer, projectID string) string {
|
|||||||
t.Fatalf("project %s is not in the listing", projectID)
|
t.Fatalf("project %s is not in the listing", projectID)
|
||||||
return ""
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list projects", err)
|
s.internal(w, r, "list projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]projectJSON, 0, len(projects))
|
out := make([]projectJSON, 0, len(projects))
|
||||||
@@ -59,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||||
@@ -105,7 +105,7 @@ func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||||
s.internal(w, "reorder projects", err)
|
s.internal(w, r, "reorder projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -122,7 +122,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
name = strings.TrimSpace(*req.Name)
|
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)
|
writeFieldErrors(w, fields)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
p.ViewMode = *req.ViewMode
|
p.ViewMode = *req.ViewMode
|
||||||
}
|
}
|
||||||
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
||||||
s.internal(w, "create project", err)
|
s.internal(w, r, "create project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||||
@@ -158,7 +158,13 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
name = strings.TrimSpace(*req.Name)
|
name = strings.TrimSpace(*req.Name)
|
||||||
req.Name = &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)
|
writeFieldErrors(w, fields)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -168,7 +174,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
ViewMode: req.ViewMode, Archived: req.Archived,
|
ViewMode: req.ViewMode, Archived: req.Archived,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "update project", err)
|
s.storeError(w, r, "update project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Grouping is written separately because it is scoped differently: the fields
|
// Grouping is written separately because it is scoped differently: the fields
|
||||||
@@ -178,7 +184,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.GroupID != nil {
|
if req.GroupID != nil {
|
||||||
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
||||||
userFrom(r.Context()).ID); err != nil {
|
userFrom(r.Context()).ID); err != nil {
|
||||||
s.storeError(w, "set project group", err)
|
s.storeError(w, r, "set project group", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +192,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||||
@@ -200,7 +206,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.storeError(w, "delete project", err)
|
s.storeError(w, r, "delete project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "project.deleted", nil)
|
s.activity(r, projectID, "", "project.deleted", nil)
|
||||||
@@ -224,7 +230,7 @@ type trashedProjectJSON struct {
|
|||||||
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list trashed projects", err)
|
s.internal(w, r, "list trashed projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +252,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
// deliberately invisible to it. Ownership is what governs restoring.
|
// deliberately invisible to it. Ownership is what governs restoring.
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if owner != userFrom(r.Context()).ID {
|
if owner != userFrom(r.Context()).ID {
|
||||||
@@ -254,13 +260,18 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
||||||
s.storeError(w, "restore project", err)
|
s.storeError(w, r, "restore project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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{}
|
fields := map[string]string{}
|
||||||
switch {
|
switch {
|
||||||
case name == "":
|
case name == "":
|
||||||
@@ -275,6 +286,11 @@ func validateProject(name string, viewMode *string) map[string]string {
|
|||||||
fields["view_mode"] = "must be list, board or calendar"
|
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
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +306,7 @@ type sectionJSON struct {
|
|||||||
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
||||||
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list sections", err)
|
s.internal(w, r, "list sections", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]sectionJSON, 0, len(sections))
|
out := make([]sectionJSON, 0, len(sections))
|
||||||
@@ -320,7 +336,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
sec.SortOrder = *req.SortOrder
|
sec.SortOrder = *req.SortOrder
|
||||||
}
|
}
|
||||||
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
||||||
s.internal(w, "create section", err)
|
s.internal(w, r, "create section", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
||||||
@@ -334,7 +350,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
|
|||||||
sectionID := chi.URLParam(r, "sectionID")
|
sectionID := chi.URLParam(r, "sectionID")
|
||||||
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "section project", err)
|
s.storeError(w, r, "section project", err)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
||||||
@@ -365,7 +381,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
||||||
Name: req.Name, SortOrder: req.SortOrder,
|
Name: req.Name, SortOrder: req.SortOrder,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.storeError(w, "update section", err)
|
s.storeError(w, r, "update section", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "section.updated", nil)
|
s.activity(r, projectID, "", "section.updated", nil)
|
||||||
@@ -378,7 +394,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
||||||
s.storeError(w, "delete section", err)
|
s.storeError(w, r, "delete section", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "section.deleted", nil)
|
s.activity(r, projectID, "", "section.deleted", nil)
|
||||||
@@ -398,7 +414,7 @@ type memberJSON struct {
|
|||||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list members", err)
|
s.internal(w, r, "list members", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]memberJSON, 0, len(members))
|
out := make([]memberJSON, 0, len(members))
|
||||||
@@ -447,7 +463,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||||
s.internal(w, "add member", err)
|
s.internal(w, r, "add member", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||||
@@ -469,7 +485,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create invite", err)
|
s.internal(w, r, "create invite", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||||
@@ -486,13 +502,60 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
|
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type memberRoleRequest struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetMemberRole changes an existing member between editor and viewer.
|
||||||
|
//
|
||||||
|
// Until now the only way to correct an invite sent with the wrong role was to
|
||||||
|
// remove the person and invite them again — which also unassigns everything they
|
||||||
|
// were responsible for, because removing a member clears their tasks' assignee.
|
||||||
|
// Fixing a dropdown should not cost somebody their work.
|
||||||
|
func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req memberRoleRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
userID := chi.URLParam(r, "userID")
|
||||||
|
|
||||||
|
role := store.Role(req.Role)
|
||||||
|
if !role.Valid() || role == store.RoleOwner {
|
||||||
|
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner has no project_members row at all — ownership lives on the project
|
||||||
|
// — so this would report "not a member", which is true and completely
|
||||||
|
// unhelpful. Say the real reason instead.
|
||||||
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "project owner", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if userID == owner {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict,
|
||||||
|
"the owner's standing cannot be changed; ownership is transferred, not granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil {
|
||||||
|
s.storeError(w, r, "set member role", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "member.role_changed",
|
||||||
|
map[string]any{"user_id": userID, "role": string(role)})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
userID := chi.URLParam(r, "userID")
|
userID := chi.URLParam(r, "userID")
|
||||||
|
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if userID == owner {
|
if userID == owner {
|
||||||
@@ -501,7 +564,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
|
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
|
||||||
s.storeError(w, "remove member", err)
|
s.storeError(w, r, "remove member", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
||||||
@@ -513,13 +576,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
|
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
|
||||||
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
|
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
|
||||||
// to learn.
|
// to learn.
|
||||||
func (s *Server) storeError(w http.ResponseWriter, what string, err error) {
|
func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
||||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
case errors.Is(err, store.ErrEmailInUse):
|
case errors.Is(err, store.ErrEmailInUse):
|
||||||
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
||||||
default:
|
default:
|
||||||
s.internal(w, what, err)
|
s.internal(w, r, what, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,3 +194,92 @@ func TestListMembersWorksForTheInbox(t *testing.T) {
|
|||||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A role sent by mistake used to be uncorrectable: the only way back was to remove
|
||||||
|
// the person and invite them again, which unassigns every task they were
|
||||||
|
// responsible for. Fixing a dropdown should not cost somebody their work.
|
||||||
|
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
member := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "anden@example.dk", "role": "viewer",
|
||||||
|
})
|
||||||
|
|
||||||
|
// A viewer cannot write. That is the rule, not the bug.
|
||||||
|
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
||||||
|
map[string]any{"content": "prøv", "project_id": projectID}); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("a viewer created a task: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||||
|
map[string]any{"role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("promote to editor: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
||||||
|
map[string]any{"content": "nu kan jeg", "project_id": projectID}); resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Errorf("an editor could not create a task: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
for _, raw := range members["members"].([]any) {
|
||||||
|
if m := raw.(map[string]any); m["user_id"] == memberUser.ID && m["role"] != "editor" {
|
||||||
|
t.Errorf("role = %v after the change, want editor", m["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner is not a member row, and ownership is not a role you can hand out.
|
||||||
|
func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
member := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
owner, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "anden@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Demoting the owner would leave a project nobody can administer.
|
||||||
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+owner.ID,
|
||||||
|
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("changing the owner's role: status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// And no member may promote themselves to owner.
|
||||||
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||||
|
map[string]any{"role": "owner"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||||
|
t.Errorf("granting ownership: status %d, want 422", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Nor may an editor change anybody's role: this is the owner's to do.
|
||||||
|
if resp, _ := member.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||||
|
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("an editor changed a role: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Somebody with no membership at all is not quietly given one.
|
||||||
|
stranger := ts.newUser(t, "tredje@example.dk", "Tredje")
|
||||||
|
_ = stranger
|
||||||
|
strangerUser, _ := ts.db.UserByEmail(t.Context(), "tredje@example.dk")
|
||||||
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+strangerUser.ID,
|
||||||
|
map[string]any{"role": "editor"}); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("/upcoming", s.handleUpcoming)
|
||||||
r.Get("/delegated", s.handleDelegated)
|
r.Get("/delegated", s.handleDelegated)
|
||||||
|
r.Get("/people", s.handlePeople)
|
||||||
r.Get("/search", s.handleSearch)
|
r.Get("/search", s.handleSearch)
|
||||||
|
|
||||||
r.Route("/projects", func(r chi.Router) {
|
r.Route("/projects", func(r chi.Router) {
|
||||||
@@ -292,6 +293,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Patch("/", s.handleUpdateProject)
|
r.Patch("/", s.handleUpdateProject)
|
||||||
r.Delete("/", s.handleDeleteProject)
|
r.Delete("/", s.handleDeleteProject)
|
||||||
r.Post("/invites", s.handleInvite)
|
r.Post("/invites", s.handleInvite)
|
||||||
|
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -303,6 +305,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// delete somebody else's.
|
// delete somebody else's.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireSession, s.requireAdmin)
|
r.Use(s.requireSession, s.requireAdmin)
|
||||||
|
r.Get("/errors", s.handleListErrors)
|
||||||
r.Get("/users", s.handleListUsers)
|
r.Get("/users", s.handleListUsers)
|
||||||
r.Post("/users", s.handleCreateUser)
|
r.Post("/users", s.handleCreateUser)
|
||||||
r.Patch("/users/{userID}", s.handleUpdateUser)
|
r.Patch("/users/{userID}", s.handleUpdateUser)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list sessions", err)
|
s.internal(w, r, "list sessions", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
sessionID := chi.URLParam(r, "sessionID")
|
sessionID := chi.URLParam(r, "sessionID")
|
||||||
|
|
||||||
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
||||||
s.storeError(w, "delete session", err)
|
s.storeError(w, r, "delete session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list tasks", err)
|
s.internal(w, r, "list tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||||
@@ -116,7 +116,7 @@ func toTaskList(tasks []store.Task) []taskJSON {
|
|||||||
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
||||||
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
@@ -162,7 +162,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
var err error
|
var err error
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
s.internal(w, "inbox", err)
|
s.internal(w, r, "inbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
||||||
s.internal(w, "create task", err)
|
s.internal(w, r, "create task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The store wrote the labels; the struct it was handed does not know that, and
|
// The store wrote the labels; the struct it was handed does not know that, and
|
||||||
@@ -302,13 +302,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
||||||
s.storeError(w, "update task", err)
|
s.storeError(w, r, "update task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||||
@@ -327,13 +327,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "complete task", err)
|
s.storeError(w, r, "complete task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,13 +369,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
||||||
s.storeError(w, "reopen task", err)
|
s.storeError(w, r, "reopen task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
||||||
@@ -394,11 +394,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||||
s.storeError(w, "delete task", err)
|
s.storeError(w, r, "delete task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
||||||
@@ -427,7 +427,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
@@ -449,12 +449,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
||||||
s.storeError(w, "move task", err)
|
s.storeError(w, r, "move task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
||||||
@@ -502,7 +502,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
var err error
|
var err error
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
s.internal(w, "inbox", err)
|
s.internal(w, r, "inbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,7 +523,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
||||||
s.internal(w, "create task", err)
|
s.internal(w, r, "create task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Labels = parsed.Labels
|
t.Labels = parsed.Labels
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type createdTokenJSON struct {
|
|||||||
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||||
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list api tokens", err)
|
s.internal(w, r, "list api tokens", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
|
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create api token", err)
|
s.internal(w, r, "create api token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete api token", err)
|
s.storeError(w, r, "delete api token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
|
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate totp secret", err)
|
s.internal(w, r, "generate totp secret", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Stored but not enabled: confirm decides that.
|
// Stored but not enabled: confirm decides that.
|
||||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
|
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
|
||||||
s.internal(w, "store totp secret", err)
|
s.internal(w, r, "store totp secret", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
||||||
@@ -63,15 +63,15 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
codes, hashes, err := auth.NewRecoveryCodes()
|
codes, hashes, err := auth.NewRecoveryCodes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate recovery codes", err)
|
s.internal(w, r, "generate recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||||
s.internal(w, "store recovery codes", err)
|
s.internal(w, r, "store recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
||||||
s.internal(w, "enable totp", err)
|
s.internal(w, r, "enable totp", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
|
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
|
||||||
s.internal(w, "disable totp", err)
|
s.internal(w, r, "disable totp", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
||||||
@@ -127,11 +127,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
codes, hashes, err := auth.NewRecoveryCodes()
|
codes, hashes, err := auth.NewRecoveryCodes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate recovery codes", err)
|
s.internal(w, r, "generate recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||||
s.internal(w, "store recovery codes", err)
|
s.internal(w, r, "store recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||||
@@ -140,7 +140,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
|||||||
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
|
||||||
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "count recovery codes", err)
|
s.internal(w, r, "count recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
|
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
users, err := s.db.ListUsers(r.Context())
|
users, err := s.db.ListUsers(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list users", err)
|
s.internal(w, r, "list users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
invites, err := s.db.ListPendingInvites(r.Context())
|
invites, err := s.db.ListPendingInvites(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list pending invites", err)
|
s.internal(w, r, "list pending invites", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
|
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
|
||||||
inviter.ID, s.cfg.InviteTTL)
|
inviter.ID, s.cfg.InviteTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create instance invite", err)
|
s.internal(w, r, "create instance invite", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||||
@@ -170,7 +170,7 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
|
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
|
||||||
s.storeError(w, "set user admin", err)
|
s.storeError(w, r, "set user admin", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
|
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
|
||||||
@@ -197,7 +197,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
|
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
|
||||||
s.storeError(w, "delete user", err)
|
s.storeError(w, r, "delete user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.log.Info("user deleted", "by", me.ID, "user", userID)
|
s.log.Info("user deleted", "by", me.ID, "user", userID)
|
||||||
@@ -206,7 +206,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
|
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
|
||||||
s.storeError(w, "delete invite", err)
|
s.storeError(w, r, "delete invite", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -218,7 +218,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
|
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
|
||||||
target, err := s.db.UserByID(r.Context(), userID)
|
target, err := s.db.UserByID(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get user", err)
|
s.storeError(w, r, "get user", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !target.IsAdmin {
|
if !target.IsAdmin {
|
||||||
@@ -226,7 +226,7 @@ func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userI
|
|||||||
}
|
}
|
||||||
admins, err := s.db.CountAdmins(r.Context())
|
admins, err := s.db.CountAdmins(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "count admins", err)
|
s.internal(w, r, "count admins", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if admins <= 1 {
|
if admins <= 1 {
|
||||||
|
|||||||
@@ -277,3 +277,70 @@ func indexAfter(s, needle string) int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 500 has to leave something behind that a restart cannot take.
|
||||||
|
//
|
||||||
|
// The panel's watcher already reports that one happened. What it cannot report is
|
||||||
|
// what it was: that lives in the container's log, and a Rune replaces the
|
||||||
|
// container on every restart, so the explanation is usually gone before anybody
|
||||||
|
// looks.
|
||||||
|
func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// Nothing has gone wrong yet.
|
||||||
|
_, empty := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||||
|
if rows, _ := empty["errors"].([]any); len(rows) != 0 {
|
||||||
|
t.Fatalf("a fresh instance has errors already: %v", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break something the handler cannot survive: the table it reads is gone, so
|
||||||
|
// the query fails and the handler answers 500. Dropping it is a blunt way to
|
||||||
|
// provoke that, and a faithful one — it is the same code path a disk error or
|
||||||
|
// a corrupt page would take.
|
||||||
|
if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
if resp.StatusCode != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||||
|
rows, _ := body["errors"].([]any)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("want one recorded error, got %v", rows)
|
||||||
|
}
|
||||||
|
row := rows[0].(map[string]any)
|
||||||
|
if row["what"] != "list project groups" {
|
||||||
|
t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"])
|
||||||
|
}
|
||||||
|
if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" {
|
||||||
|
t.Errorf("path/method = %v %v", row["method"], row["path"])
|
||||||
|
}
|
||||||
|
if row["status"] != float64(500) {
|
||||||
|
t.Errorf("status = %v", row["status"])
|
||||||
|
}
|
||||||
|
if row["message"] == "" {
|
||||||
|
t.Error("no message: the row says something broke and not what")
|
||||||
|
}
|
||||||
|
if row["user_name"] != "Kristian" {
|
||||||
|
t.Errorf("user_name = %v — a fault only one account hits is a different problem",
|
||||||
|
row["user_name"])
|
||||||
|
}
|
||||||
|
if row["request_id"] == "" {
|
||||||
|
t.Error("no request id, so the row cannot be tied back to the log line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list is an administrator's: it carries paths, accounts and the server's own
|
||||||
|
// error strings.
|
||||||
|
func TestTheErrorLogIsAdminsOnly(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("status %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: 500,
|
Limit: 500,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "today", err)
|
s.internal(w, r, "today", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: 1000,
|
Limit: 1000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "upcoming", err)
|
s.internal(w, r, "upcoming", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +92,31 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handlePeople lists everybody the caller shares a project with, so an interface
|
||||||
|
// can put a name and a face on an assignee id.
|
||||||
|
//
|
||||||
|
// One request per session rather than a lookup per row, and not the instance's
|
||||||
|
// user list — that is the administrator's page. A task list has no business
|
||||||
|
// enumerating everybody with an account here.
|
||||||
|
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
|
||||||
|
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list people", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]personJSON, 0, len(people))
|
||||||
|
for _, p := range people {
|
||||||
|
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"people": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type personJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
}
|
||||||
|
|
||||||
type delegatedPerson struct {
|
type delegatedPerson struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -117,7 +142,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "delegated", err)
|
s.internal(w, r, "delegated", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
people, err := s.db.PeopleByIDs(r.Context(), ids)
|
people, err := s.db.PeopleByIDs(r.Context(), ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "delegated people", err)
|
s.internal(w, r, "delegated people", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "search tasks", err)
|
s.internal(w, r, "search tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
// second FTS index for that would be more machinery than the problem needs.
|
// second FTS index for that would be more machinery than the problem needs.
|
||||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "search projects", err)
|
s.internal(w, r, "search projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
matched := []projectJSON{}
|
matched := []projectJSON{}
|
||||||
@@ -214,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
|
|||||||
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
||||||
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list activity", err)
|
s.internal(w, r, "list activity", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,3 +153,50 @@ func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
|
|||||||
t.Errorf("a task in a project they cannot see: %v", people)
|
t.Errorf("a task in a project they cannot see: %v", people)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /people is what puts a name on an assignee id. It must be the people you share
|
||||||
|
// work with — not the instance's user list, which is the administrator's page.
|
||||||
|
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
colleague := ts.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
ts.newUser(t, "fremmed@example.dk", "Fremmed")
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites", map[string]any{
|
||||||
|
"email": "anders@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/people", nil)
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, raw := range body["people"].([]any) {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
names[p["name"].(string)] = true
|
||||||
|
if p["avatar_color"] == "" {
|
||||||
|
t.Errorf("no avatar colour for %v — the row cannot draw a face", p["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !names["Kristian"] {
|
||||||
|
t.Error("yourself is missing, so a client cannot tell 'me' from 'somebody else' against one list")
|
||||||
|
}
|
||||||
|
if !names["Anders"] {
|
||||||
|
t.Error("somebody sharing a project is missing")
|
||||||
|
}
|
||||||
|
if names["Fremmed"] {
|
||||||
|
t.Error("an account with no project in common is listed — this is not the user directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it is symmetric: the person invited sees the person who invited them.
|
||||||
|
_, theirs := colleague.do(t, "GET", "/api/v1/people", nil)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, raw := range theirs["people"].([]any) {
|
||||||
|
seen[raw.(map[string]any)["name"].(string)] = true
|
||||||
|
}
|
||||||
|
if !seen["Kristian"] || !seen["Anders"] {
|
||||||
|
t.Errorf("the member cannot see who they share with: %v", seen)
|
||||||
|
}
|
||||||
|
if seen["Fremmed"] {
|
||||||
|
t.Errorf("a stranger is listed: %v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func (r *Runner) Start(ctx context.Context) {
|
|||||||
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
||||||
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
||||||
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
|
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
|
||||||
|
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
|
||||||
// Ten minutes is a compromise: Gmail's push notifications would be instant but
|
// Ten minutes is a compromise: Gmail's push notifications would be instant but
|
||||||
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
||||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||||
@@ -265,6 +266,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer
|
||||||
|
// "what happened last week" and short enough that a fault loop cannot fill the
|
||||||
|
// volume the tasks live on.
|
||||||
|
func (r *Runner) purgeErrors(ctx context.Context) error {
|
||||||
|
n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
r.log.Debug("old error rows removed", "count", n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) purgeSessions(ctx context.Context) error {
|
func (r *Runner) purgeSessions(ctx context.Context) error {
|
||||||
n, err := r.db.PurgeExpiredSessions(ctx)
|
n, err := r.db.PurgeExpiredSessions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
// The colours a project, group, label or filter may be marked with.
|
||||||
|
//
|
||||||
|
// Names rather than hex values, and the reason is what happens later: a hex in
|
||||||
|
// ten thousand rows is a decision nobody can revisit, while a name is resolved by
|
||||||
|
// the interface at the moment it paints and can be retuned for a new theme, a new
|
||||||
|
// palette, or a display nobody has yet. It is also the difference between a
|
||||||
|
// database that stores a fact and one that stores a rendering.
|
||||||
|
//
|
||||||
|
// The list is closed. An unknown name would be stored happily and then paint as
|
||||||
|
// the default, which looks like a colour that did not save — so it is refused at
|
||||||
|
// the edge instead. web/src/lib/colors.js carries the same ids for the picker, and
|
||||||
|
// a test reads that file to check the two have not drifted.
|
||||||
|
var Colors = []string{
|
||||||
|
"graphite",
|
||||||
|
"tomato",
|
||||||
|
"amber",
|
||||||
|
"lime",
|
||||||
|
"green",
|
||||||
|
"teal",
|
||||||
|
"blue",
|
||||||
|
"indigo",
|
||||||
|
"violet",
|
||||||
|
"magenta",
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultColor is what an unmarked thing is, and what the schema defaults to.
|
||||||
|
const DefaultColor = "graphite"
|
||||||
|
|
||||||
|
// ValidColor reports whether a name is one this app can paint.
|
||||||
|
func ValidColor(name string) bool {
|
||||||
|
for _, c := range Colors {
|
||||||
|
if c == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The palette is written down three times: here, in web/src/lib/colors.js for the
|
||||||
|
// picker, and in web/src/app.css as the tokens that actually paint. Three copies
|
||||||
|
// is two chances to drift, and the failure is quiet — a colour the server accepts
|
||||||
|
// and the interface cannot draw renders as the default, which looks like a colour
|
||||||
|
// that did not save.
|
||||||
|
//
|
||||||
|
// So this reads the other two. A small scanner rather than a parser, the same
|
||||||
|
// approach openapi_test.go takes to the spec: it needs the ids out of a list this
|
||||||
|
// repository also owns, and that is worth more than a dependency in a project
|
||||||
|
// whose whole shape is "one static binary".
|
||||||
|
func TestThePaletteIsTheSameInGoAndInTheFrontend(t *testing.T) {
|
||||||
|
js := readFrontendColors(t, filepath.Join("..", "..", "web", "src", "lib", "colors.js"))
|
||||||
|
css := readColorTokens(t, filepath.Join("..", "..", "web", "src", "app.css"))
|
||||||
|
|
||||||
|
for _, name := range Colors {
|
||||||
|
if !js[name] {
|
||||||
|
t.Errorf("%q is accepted by the server and missing from colors.js — the picker cannot offer it", name)
|
||||||
|
}
|
||||||
|
if !css[name] {
|
||||||
|
t.Errorf("--color-%s is missing from app.css — the server accepts a colour nothing can paint", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range js {
|
||||||
|
if !ValidColor(name) {
|
||||||
|
t.Errorf("colors.js offers %q, which the server refuses — picking it would fail to save", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrontendColors pulls the ids out of the COLORS array: lines shaped
|
||||||
|
// `{ id: 'tomato', name: 'Tomat' },`.
|
||||||
|
func readFrontendColors(t *testing.T, path string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read colors.js: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
id, ok := between(line, "{ id: '", "'")
|
||||||
|
if ok {
|
||||||
|
found[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
t.Fatal("no colours were read out of colors.js — the scanner and the file disagree")
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// readColorTokens pulls the names out of `--color-tomato: #d9614f;`.
|
||||||
|
func readColorTokens(t *testing.T, path string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read app.css: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
name, ok := between(line, "--color-", ":")
|
||||||
|
if ok {
|
||||||
|
found[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
t.Fatal("no --color- tokens were read out of app.css")
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func between(line, open, close string) (string, bool) {
|
||||||
|
start := strings.Index(line, open)
|
||||||
|
if start < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := line[start+len(open):]
|
||||||
|
end := strings.Index(rest, close)
|
||||||
|
if end <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rest[:end], true
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A ServerError is one 500 the API answered, kept so it can be read after the
|
||||||
|
// container that produced it has gone.
|
||||||
|
type ServerError struct {
|
||||||
|
ID string
|
||||||
|
At time.Time
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Status int
|
||||||
|
What string
|
||||||
|
Message string
|
||||||
|
UserID string
|
||||||
|
UserName string
|
||||||
|
RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordError stores one failure.
|
||||||
|
//
|
||||||
|
// Errors here are dropped rather than returned: this is called from the path that
|
||||||
|
// is already reporting a failure to somebody, and a diagnostic that can itself
|
||||||
|
// fail the request would turn one broken screen into two.
|
||||||
|
func (db *DB) RecordError(ctx context.Context, e ServerError) {
|
||||||
|
var user any
|
||||||
|
if e.UserID != "" {
|
||||||
|
user = e.UserID
|
||||||
|
}
|
||||||
|
_, _ = db.ExecContext(ctx,
|
||||||
|
`INSERT INTO error_log (id, at, method, path, status, what, message, user_id, request_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
NewID(), time.Now().Unix(), e.Method, truncate(e.Path, 500), e.Status,
|
||||||
|
truncate(e.What, 200), truncate(e.Message, 2000), user, truncate(e.RequestID, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListErrors returns the most recent failures, newest first.
|
||||||
|
func (db *DB) ListErrors(ctx context.Context, limit int) ([]ServerError, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT e.id, e.at, e.method, e.path, e.status, e.what, e.message,
|
||||||
|
COALESCE(e.user_id, ''), COALESCE(u.name, ''), e.request_id
|
||||||
|
FROM error_log e
|
||||||
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
|
ORDER BY e.at DESC
|
||||||
|
LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []ServerError{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e ServerError
|
||||||
|
var at int64
|
||||||
|
if err := rows.Scan(&e.ID, &at, &e.Method, &e.Path, &e.Status, &e.What,
|
||||||
|
&e.Message, &e.UserID, &e.UserName, &e.RequestID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.At = time.Unix(at, 0).UTC()
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeOldErrors is run by the nightly sweep. A diagnostic that grows without
|
||||||
|
// limit is a disk that fills for a reason nobody expected.
|
||||||
|
func (db *DB) PurgeOldErrors(ctx context.Context, keep time.Duration) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM error_log WHERE at < ?`, time.Now().Add(-keep).Unix())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A colour on a sidebar group, so a group can be marked the way a project is.
|
||||||
|
--
|
||||||
|
-- Projects, labels and filters have carried a `color` since 0001; groups arrived
|
||||||
|
-- in 0005 without one because nothing rendered any of them yet. Now that the
|
||||||
|
-- sidebar paints them, a group without one would be the only thing in the list
|
||||||
|
-- that could not be told apart at a glance.
|
||||||
|
--
|
||||||
|
-- Same default and same spelling as the others: a colour name, not a hex. The
|
||||||
|
-- value has to survive a theme change and a redesign, and '#8a8f98' in ten
|
||||||
|
-- thousand rows is a decision nobody can revisit.
|
||||||
|
ALTER TABLE project_groups ADD COLUMN color TEXT NOT NULL DEFAULT 'graphite';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Server errors, kept where they can outlive the container that produced them.
|
||||||
|
--
|
||||||
|
-- verdande already logs the real cause of every 500 to stdout. On a Rune that is
|
||||||
|
-- the container's log, and every restart starts a new one — so the panel can
|
||||||
|
-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone.
|
||||||
|
-- An operator is then told that something broke and given no way to find out what.
|
||||||
|
--
|
||||||
|
-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that
|
||||||
|
-- grows without limit is a disk that fills for a reason nobody expected.
|
||||||
|
CREATE TABLE error_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
at INTEGER NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
-- what: the operation the handler was attempting, in the words it uses in the
|
||||||
|
-- log — "list projects", "move task". The detail a stack trace would give and
|
||||||
|
-- a status code cannot.
|
||||||
|
what TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
-- Whose request it was, so a fault that only one account hits is visible as
|
||||||
|
-- one. NULL for anything that failed before authentication.
|
||||||
|
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
request_id TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_error_log_at ON error_log (at DESC);
|
||||||
@@ -18,6 +18,7 @@ type ProjectGroup struct {
|
|||||||
ID string
|
ID string
|
||||||
OwnerID string
|
OwnerID string
|
||||||
Name string
|
Name string
|
||||||
|
Color string
|
||||||
Collapsed bool
|
Collapsed bool
|
||||||
SortOrder float64
|
SortOrder float64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -26,7 +27,7 @@ type ProjectGroup struct {
|
|||||||
|
|
||||||
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
|
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
|
||||||
rows, err := db.QueryContext(ctx,
|
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 = ?
|
FROM project_groups WHERE owner_id = ?
|
||||||
ORDER BY sort_order, created_at`, userID)
|
ORDER BY sort_order, created_at`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,7 +40,7 @@ func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGr
|
|||||||
var g ProjectGroup
|
var g ProjectGroup
|
||||||
var collapsed int
|
var collapsed int
|
||||||
var created, updated int64
|
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 {
|
&created, &updated); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -59,9 +60,9 @@ func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*Pro
|
|||||||
var collapsed int
|
var collapsed int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
err := db.QueryRowContext(ctx,
|
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).
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,9 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
g.CreatedAt, g.UpdatedAt = now, now
|
g.CreatedAt, g.UpdatedAt = now, now
|
||||||
|
if g.Color == "" {
|
||||||
|
g.Color = DefaultColor
|
||||||
|
}
|
||||||
if g.SortOrder == 0 {
|
if g.SortOrder == 0 {
|
||||||
var max sql.NullFloat64
|
var max sql.NullFloat64
|
||||||
if err := db.QueryRowContext(ctx,
|
if err := db.QueryRowContext(ctx,
|
||||||
@@ -90,14 +94,15 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
|
|||||||
g.SortOrder = max.Float64 + 1024
|
g.SortOrder = max.Float64 + 1024
|
||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO project_groups (id, owner_id, name, collapsed, sort_order, created_at, updated_at)
|
`INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
g.ID, g.OwnerID, g.Name, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
|
g.ID, g.OwnerID, g.Name, g.Color, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectGroupUpdate struct {
|
type ProjectGroupUpdate struct {
|
||||||
Name *string
|
Name *string
|
||||||
|
Color *string
|
||||||
Collapsed *bool
|
Collapsed *bool
|
||||||
SortOrder *float64
|
SortOrder *float64
|
||||||
}
|
}
|
||||||
@@ -105,6 +110,7 @@ type ProjectGroupUpdate struct {
|
|||||||
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
|
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
|
||||||
set, args := buildUpdate(map[string]any{
|
set, args := buildUpdate(map[string]any{
|
||||||
"name": u.Name,
|
"name": u.Name,
|
||||||
|
"color": u.Color,
|
||||||
"collapsed": u.Collapsed,
|
"collapsed": u.Collapsed,
|
||||||
"sort_order": u.SortOrder,
|
"sort_order": u.SortOrder,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -389,6 +389,33 @@ func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role
|
|||||||
return err
|
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 {
|
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
// Anything assigned to them is unassigned rather than left pointing at
|
// Anything assigned to them is unassigned rather than left pointing at
|
||||||
|
|||||||
@@ -274,6 +274,50 @@ func (db *DB) DeleteUser(ctx context.Context, userID string) error {
|
|||||||
return nil
|
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.
|
// UserCount is what decides whether the instance still needs its first admin.
|
||||||
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
|
|||||||
+259
-7
@@ -153,9 +153,10 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
|||||||
'Integrationer',
|
'Integrationer',
|
||||||
'AI',
|
'AI',
|
||||||
'API-tokens',
|
'API-tokens',
|
||||||
// Only an administrator sees this one, and the account these tests run as
|
// Only an administrator sees these two, and the account these tests run as
|
||||||
// is the first account, which is one.
|
// is the first account, which is one.
|
||||||
'Brugere',
|
'Brugere',
|
||||||
|
'Fejl',
|
||||||
'Data og skabeloner'
|
'Data og skabeloner'
|
||||||
]) {
|
]) {
|
||||||
await tabs.getByRole('link', { name: label, exact: true }).click();
|
await tabs.getByRole('link', { name: label, exact: true }).click();
|
||||||
@@ -183,6 +184,12 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
|
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safari never reads the manifest's icons; it looks for this by name, and
|
||||||
|
// without it an installed PWA on iOS gets a screenshot of the page as its
|
||||||
|
// home-screen icon.
|
||||||
|
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||||
|
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||||
|
|
||||||
// The service worker is what Web Push needs; without it a subscription cannot
|
// The service worker is what Web Push needs; without it a subscription cannot
|
||||||
// be created at all.
|
// be created at all.
|
||||||
const worker = await request.get('/sw.js');
|
const worker = await request.get('/sw.js');
|
||||||
@@ -256,7 +263,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').fill('Arbejde');
|
||||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
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(heading).toBeVisible();
|
||||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||||
|
|
||||||
@@ -295,11 +305,38 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
await heading.click();
|
await heading.click();
|
||||||
await expect(heading).toHaveAttribute('aria-expanded', 'false');
|
await expect(heading).toHaveAttribute('aria-expanded', 'false');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(sidebar.getByRole('button', { name: 'Arbejde' })).toHaveAttribute(
|
await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute(
|
||||||
'aria-expanded',
|
'aria-expanded',
|
||||||
'false'
|
'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
|
// 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.
|
// group is a table and not a string repeated on every project.
|
||||||
@@ -307,19 +344,72 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
const name = sidebar.getByLabel('Gruppens navn');
|
const name = sidebar.getByLabel('Gruppens navn');
|
||||||
await name.fill('Kontoret');
|
await name.fill('Kontoret');
|
||||||
await name.press('Enter');
|
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
|
// 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
|
// ungrouped here, which is the case that would look identical either way if
|
||||||
// the assertion were only "the group is gone".
|
// the assertion were only "the group is gone".
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
await sidebar.getByRole('button', { name: 'Slet' }).click();
|
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();
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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.
|
* A task can be dragged onto another day, and onto another project.
|
||||||
*
|
*
|
||||||
@@ -355,6 +445,14 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
|
|||||||
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
|
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
|
||||||
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
|
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
|
||||||
|
|
||||||
|
// Onto "I dag" in the sidebar: the most-made rescheduling there is, and the
|
||||||
|
// sidebar is where the pointer already is. "Kommende" deliberately does not
|
||||||
|
// take one — it is a range, not a day, so a drop would have to invent a date
|
||||||
|
// its label does not name.
|
||||||
|
await row.dragTo(sidebar.getByRole('link', { name: 'I dag' }));
|
||||||
|
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
|
||||||
|
await row.dragTo(day('I morgen'));
|
||||||
|
|
||||||
// And onto a project, which is a move rather than a field: the task has to
|
// And onto a project, which is a move rather than a field: the task has to
|
||||||
// find a place among that project's tasks, and leave its old section behind.
|
// find a place among that project's tasks, and leave its old section behind.
|
||||||
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
|
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
|
||||||
@@ -443,6 +541,26 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
|||||||
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|
||||||
|
// The invitee opens the project. Nothing has ever driven this: the assertion
|
||||||
|
// above only proved the project reaches their *sidebar*, and a member's view
|
||||||
|
// of a project page is a different code path from an owner's — different role,
|
||||||
|
// different guards, and the page shows the same "findes ikke" message while it
|
||||||
|
// is still loading as it does when the load failed.
|
||||||
|
const inviteePage = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const member = await inviteePage.newPage();
|
||||||
|
const memberTrouble = watchForTrouble(member);
|
||||||
|
await member.goto('/');
|
||||||
|
await member.getByLabel('E-mail').fill('nabo@example.dk');
|
||||||
|
await member.getByLabel('Adgangskode').fill('et langt kodeord til test');
|
||||||
|
await member.getByRole('button', { name: 'Log ind' }).click();
|
||||||
|
|
||||||
|
const theirs = member.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await theirs.getByRole('link', { name: /Fælleshuset/ }).click();
|
||||||
|
await expect(member.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||||
|
await expect(member.getByText('findes ikke')).toHaveCount(0);
|
||||||
|
expect(memberTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
||||||
|
await inviteePage.close();
|
||||||
|
|
||||||
// Now that there is somebody to delegate to, the inviter can — and the view
|
// 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
|
// for it appears. It is hidden on an instance with one person, where it could
|
||||||
// never have anything in it.
|
// never have anything in it.
|
||||||
@@ -467,7 +585,10 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
|||||||
await sidebar.getByRole('link', { name: 'Venter på andre' }).click();
|
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: 'Venter på andre' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Nabo' })).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([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -602,6 +723,137 @@ test('den sidste administrator kan hverken fjernes eller slette sig selv', async
|
|||||||
await expect(mine.getByRole('button')).toHaveCount(0);
|
await expect(mine.getByRole('button')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A member's role can be corrected without removing them.
|
||||||
|
*
|
||||||
|
* This is what a viewer who cannot add tasks looks like from the other side. The
|
||||||
|
* rule itself is right — a viewer may not write — but until now the only way to
|
||||||
|
* fix an invite sent with the wrong role was to remove the person and invite them
|
||||||
|
* again, which unassigns every task they were responsible for on the way past.
|
||||||
|
*
|
||||||
|
* Driven end to end because the interesting part is that the *other* browser's
|
||||||
|
* permissions actually change: the server decides, and the page has to agree.
|
||||||
|
*/
|
||||||
|
test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Fælles');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Del' }).click();
|
||||||
|
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
|
||||||
|
await page.getByLabel('Rolle').selectOption('viewer');
|
||||||
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||||
|
const link = await page.locator('.link-out code').textContent();
|
||||||
|
|
||||||
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const andreas = await context.newPage();
|
||||||
|
await andreas.goto(link);
|
||||||
|
await andreas.getByLabel('Navn').fill('andreas');
|
||||||
|
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
|
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
|
||||||
|
await andreas
|
||||||
|
.getByRole('navigation', { name: 'Hovedmenu' })
|
||||||
|
.getByRole('link', { name: /Fælles/ })
|
||||||
|
.click();
|
||||||
|
// A viewer sees the project and is told, plainly, that it is read-only.
|
||||||
|
await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible();
|
||||||
|
await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0);
|
||||||
|
|
||||||
|
// The owner promotes them from the member list. The owner's own row is text,
|
||||||
|
// not a dropdown: ownership is transferred, not granted.
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Delt/ }).click();
|
||||||
|
await expect(page.getByText('Ejer')).toBeVisible();
|
||||||
|
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||||
|
|
||||||
|
await andreas.reload();
|
||||||
|
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||||
|
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Now that they can edit, hand them something — and the row says whose it is.
|
||||||
|
// Only when it is somebody else's: your own list would otherwise carry your own
|
||||||
|
// face on every line, which is a column of one initial telling you nothing.
|
||||||
|
await page.getByLabel('Ny opgave').fill('brænde kaffe');
|
||||||
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
|
await page.getByText('brænde kaffe', { exact: true }).click();
|
||||||
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
||||||
|
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
|
||||||
|
// And not on a task that is nobody's.
|
||||||
|
await page.getByLabel('Ny opgave').fill('min egen');
|
||||||
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
|
await expect(
|
||||||
|
page.locator('.row').filter({ hasText: 'min egen' }).locator('.assignee')
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
// The log has recorded all of this since the beginning and nothing showed it.
|
||||||
|
await page.getByRole('button', { name: 'Historik' }).click();
|
||||||
|
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
||||||
|
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project page on a phone.
|
||||||
|
*
|
||||||
|
* Every other test here runs at desktop width, and this is what that misses: the
|
||||||
|
* header is a flex row of a title and four controls, and 390px does not have room
|
||||||
|
* for them. Without wrapping, the heading was squeezed to its min-content — which
|
||||||
|
* with `overflow-wrap: anywhere` is one character — so a project called
|
||||||
|
* "Skovvænget" rendered as a vertical column of letters while the buttons ran off
|
||||||
|
* the right edge. It looked like a broken font.
|
||||||
|
*/
|
||||||
|
test('projektsiden er brugbar på en telefon', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// The sidebar is a drawer at this width, so the project is reached through it.
|
||||||
|
await page.getByRole('button', { name: 'Vis menu' }).click();
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Skovvænget');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: 'Skovvænget' });
|
||||||
|
await expect(heading).toBeVisible();
|
||||||
|
|
||||||
|
const box = await heading.boundingBox();
|
||||||
|
// Wide enough to hold the word, and short enough not to be a column of
|
||||||
|
// letters. One line of "Skovvænget" at --text-2xl is nowhere near 200px tall.
|
||||||
|
expect(box.width, 'overskriften er klemt sammen').toBeGreaterThan(120);
|
||||||
|
expect(box.height, 'overskriften er brudt op i én bogstavkolonne').toBeLessThan(120);
|
||||||
|
|
||||||
|
// And nothing sticks out past the right edge of the page.
|
||||||
|
const sideways = () =>
|
||||||
|
page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||||
|
);
|
||||||
|
expect(await sideways(), 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// The task drawer, too. It was `width: 100vw`, which includes the scrollbar
|
||||||
|
// gutter where a percentage does not, so the drawer came out wider than the
|
||||||
|
// page and the whole document could be pushed sideways.
|
||||||
|
await page.getByLabel('Ny opgave').fill('mal væggen');
|
||||||
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
|
await page.getByText('mal væggen', { exact: true }).click();
|
||||||
|
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||||
|
expect(await sideways(), 'opgaveruden skubber siden sidelæns').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The MCP connector address must not fall through to the app shell.
|
* The MCP connector address must not fall through to the app shell.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Renders static/icon.svg to the PNGs the manifest and iOS need.
|
||||||
|
*
|
||||||
|
* node scripts/generate-icons.js
|
||||||
|
*
|
||||||
|
* A script rather than a test, so CI never runs it and never rewrites a tracked
|
||||||
|
* binary. Run it when the icon changes; the smoke test fetches every icon the
|
||||||
|
* manifest names, so a reference with no file behind it fails there.
|
||||||
|
*
|
||||||
|
* It drives Chromium because there is no SVG renderer on this machine — which is
|
||||||
|
* why these references were once removed rather than left pointing at files that
|
||||||
|
* did not exist. Playwright is a renderer, and it is already a dependency.
|
||||||
|
*/
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const SIZES = [
|
||||||
|
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
|
||||||
|
{ file: 'static/icon-192.png', size: 192 },
|
||||||
|
{ file: 'static/icon-512.png', size: 512 },
|
||||||
|
// And the one Safari wants by name. It ignores the manifest's icons entirely
|
||||||
|
// and looks for this, which is why an installed PWA on iOS otherwise gets a
|
||||||
|
// screenshot of the page as its home-screen icon.
|
||||||
|
{ file: 'static/apple-touch-icon.png', size: 180 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const svg = readFileSync('static/icon.svg', 'utf8');
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
for (const { file, size } of SIZES) {
|
||||||
|
await page.setViewportSize({ width: size, height: size });
|
||||||
|
await page.setContent(
|
||||||
|
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
||||||
|
);
|
||||||
|
writeFileSync(file, await page.locator('svg').screenshot());
|
||||||
|
console.log(`${file} ${size}×${size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
@@ -45,6 +45,34 @@
|
|||||||
--danger: #cf6b6b;
|
--danger: #cf6b6b;
|
||||||
--danger-sunken: #33201f;
|
--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
|
/* Type. System stack: it renders instantly, matches the platform, and has
|
||||||
proper Danish glyphs on every device — a webfont would cost a round trip
|
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. */
|
and a flash of unstyled text on the one screen people open fifty times a day. */
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<!-- Safari ignores the manifest's icons entirely and looks for this by name.
|
||||||
|
Without it an installed PWA on iOS gets a screenshot of the page as its
|
||||||
|
home-screen icon. Generated from the same SVG by scripts/generate-icons.js. -->
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>verdande</title>
|
<title>verdande</title>
|
||||||
<script>
|
<script>
|
||||||
// Applied before first paint, so a dark-theme user never sees a white
|
// Applied before first paint, so a dark-theme user never sees a white
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export const api = {
|
|||||||
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
|
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
|
||||||
deleteUser: (id) => del(`/users/${id}`),
|
deleteUser: (id) => del(`/users/${id}`),
|
||||||
revokeInvite: (id) => del(`/invites/${id}`),
|
revokeInvite: (id) => del(`/invites/${id}`),
|
||||||
|
listErrors: () => get('/errors'),
|
||||||
|
|
||||||
recoveryCodes: () => get('/auth/recovery-codes'),
|
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||||
@@ -205,6 +206,8 @@ export const api = {
|
|||||||
|
|
||||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
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}`),
|
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
||||||
activity: (projectId) => get(`/projects/${projectId}/activity`),
|
activity: (projectId) => get(`/projects/${projectId}/activity`),
|
||||||
|
|
||||||
@@ -305,5 +308,6 @@ export const api = {
|
|||||||
today: () => get('/today'),
|
today: () => get('/today'),
|
||||||
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
|
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
|
||||||
delegated: () => get('/delegated'),
|
delegated: () => get('/delegated'),
|
||||||
|
people: () => get('/people'),
|
||||||
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
|
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 { app, sidebar } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
@@ -17,7 +18,13 @@
|
|||||||
newName = '';
|
newName = '';
|
||||||
adding = false;
|
adding = false;
|
||||||
const project = await app.createProject(name);
|
const project = await app.createProject(name);
|
||||||
if (project) goto(`/projekt/${project.id}`);
|
if (!project) return;
|
||||||
|
// Closes the drawer as well as navigating. On a phone the sidebar is an
|
||||||
|
// overlay, and creating a project moved the page underneath it while leaving
|
||||||
|
// it covering the screen — every link in here closes it, and this makes the
|
||||||
|
// same move for the same reason.
|
||||||
|
onnavigate?.();
|
||||||
|
goto(`/projekt/${project.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
@@ -78,6 +85,8 @@
|
|||||||
let newGroupName = $state('');
|
let newGroupName = $state('');
|
||||||
let renamingGroup = $state(null);
|
let renamingGroup = $state(null);
|
||||||
let groupName = $state('');
|
let groupName = $state('');
|
||||||
|
/** The group whose swatches are open, if any. */
|
||||||
|
let coloringGroup = $state(null);
|
||||||
|
|
||||||
async function createGroup(event) {
|
async function createGroup(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -221,6 +230,34 @@
|
|||||||
await app.reorderProjects(ordered.map((p) => p.id));
|
await app.reorderProjects(ordered.map((p) => p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- a task dropped on "I dag" ---------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Gør det i dag" is the most-made rescheduling there is, and the sidebar is
|
||||||
|
* where the pointer already goes.
|
||||||
|
*
|
||||||
|
* Only this one of the two date views. "Kommende" is a range rather than a
|
||||||
|
* day — it starts today and runs a week — so a drop on it would have to invent
|
||||||
|
* a date the label does not name. The month grid inside it takes drops per
|
||||||
|
* cell, which is where a specific day is actually offered.
|
||||||
|
*/
|
||||||
|
let overToday = $state(false);
|
||||||
|
|
||||||
|
function todayISO() {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
now.getDate()
|
||||||
|
).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDropOnToday(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = dragged(event, TASK);
|
||||||
|
overToday = false;
|
||||||
|
if (!id) return;
|
||||||
|
await app.reschedule(id, todayISO());
|
||||||
|
}
|
||||||
|
|
||||||
// --- a task dropped on a project ------------------------------------------------
|
// --- a task dropped on a project ------------------------------------------------
|
||||||
|
|
||||||
/** The project row lit up because a task is hovering over it. */
|
/** The project row lit up because a task is hovering over it. */
|
||||||
@@ -303,7 +340,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="views">
|
<div class="views">
|
||||||
<a href="/" class:active={current === '/'} onclick={onnavigate}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class:active={current === '/'}
|
||||||
|
class:receiving={overToday}
|
||||||
|
onclick={onnavigate}
|
||||||
|
ondragover={(e) => {
|
||||||
|
if (!carries(e, TASK)) return;
|
||||||
|
accept(e);
|
||||||
|
overToday = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (overToday = false)}
|
||||||
|
ondrop={onDropOnToday}
|
||||||
|
>
|
||||||
<span class="dot today" aria-hidden="true"></span>
|
<span class="dot today" aria-hidden="true"></span>
|
||||||
I dag
|
I dag
|
||||||
</a>
|
</a>
|
||||||
@@ -353,7 +403,7 @@
|
|||||||
}}
|
}}
|
||||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
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}
|
{project.name}
|
||||||
{#if project.shared}
|
{#if project.shared}
|
||||||
<span class="count">{project.member_count}</span>
|
<span class="count">{project.member_count}</span>
|
||||||
@@ -480,6 +530,20 @@
|
|||||||
the heading away from a screen reader. The whole heading folds —
|
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
|
at this size a lone chevron is a target you miss, and the name is
|
||||||
where the eye already 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">
|
<h2 class="fold">
|
||||||
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
|
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
|
||||||
<svg
|
<svg
|
||||||
@@ -505,6 +569,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{#if !group.collapsed}
|
||||||
{#each inside as project (project.id)}
|
{#each inside as project (project.id)}
|
||||||
{@render projectRow(project, true)}
|
{@render projectRow(project, true)}
|
||||||
@@ -852,6 +935,72 @@
|
|||||||
margin-left: 0;
|
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 {
|
.folder-head form {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -575,7 +575,12 @@
|
|||||||
.drawer {
|
.drawer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 0 auto;
|
inset: 0 0 0 auto;
|
||||||
width: min(480px, 100vw);
|
/* `100%` rather than `100vw`. For a fixed element a percentage resolves
|
||||||
|
against the initial containing block, which excludes the scrollbar gutter
|
||||||
|
— `100vw` does not, so the drawer was a few pixels wider than the page and
|
||||||
|
the whole document could be pushed sideways. */
|
||||||
|
width: min(480px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
z-index: 80;
|
z-index: 80;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -625,7 +630,12 @@
|
|||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
/* Nothing inside may widen the drawer. A pasted URL in a comment or a long
|
||||||
|
unbroken word would otherwise stretch the flex column and take the page
|
||||||
|
with it. */
|
||||||
|
overflow-x: hidden;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding: var(--s4) var(--s4) var(--s8);
|
padding: var(--s4) var(--s4) var(--s8);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -883,8 +893,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
/* Full width, stated as four insets rather than a width: the box is then
|
||||||
|
defined by the viewport's edges and cannot be a rounding error wider than
|
||||||
|
the thing it is pinned to. */
|
||||||
.drawer {
|
.drawer {
|
||||||
width: 100vw;
|
inset: 0;
|
||||||
|
width: auto;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@
|
|||||||
|
|
||||||
async function onDrop(event, target) {
|
async function onDrop(event, target) {
|
||||||
event.preventDefault();
|
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 id = draggingId;
|
||||||
const below = overBelow;
|
const below = overBelow;
|
||||||
draggingId = null;
|
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 due = $derived(dueLabel(task.due_date));
|
||||||
let time = $derived(
|
let time = $derived(
|
||||||
task.due_datetime
|
task.due_datetime
|
||||||
@@ -81,8 +83,19 @@
|
|||||||
<span class="description">{task.description}</span>
|
<span class="description">{task.description}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if due || task.labels?.length || task.recurrence_text}
|
{#if assignee || due || task.labels?.length || task.recurrence_text}
|
||||||
<span class="meta">
|
<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}
|
{#if due}
|
||||||
<span class="due" data-tone={due.tone}>
|
<span class="due" data-tone={due.tone}>
|
||||||
{due.text}{#if time} {time}{/if}
|
{due.text}{#if time} {time}{/if}
|
||||||
@@ -259,6 +272,25 @@
|
|||||||
stroke-linejoin: round;
|
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 {
|
.label {
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class AppState {
|
|||||||
projects = $state([]);
|
projects = $state([]);
|
||||||
/** The foldable headings over the projects in the sidebar. */
|
/** The foldable headings over the projects in the sidebar. */
|
||||||
groups = $state([]);
|
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([]);
|
tasks = $state([]);
|
||||||
loading = $state(true);
|
loading = $state(true);
|
||||||
/** Transient messages: a failed save, a rolled-back change. */
|
/** Transient messages: a failed save, a rolled-back change. */
|
||||||
@@ -52,6 +60,23 @@ class AppState {
|
|||||||
return this.projects.find((p) => p.id === id);
|
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() {
|
get detailTask() {
|
||||||
return this.tasks.find((t) => t.id === this.detailId) ?? null;
|
return this.tasks.find((t) => t.id === this.detailId) ?? null;
|
||||||
}
|
}
|
||||||
@@ -68,12 +93,14 @@ class AppState {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
this.user = await api.me();
|
this.user = await api.me();
|
||||||
const [{ projects }, { groups }] = await Promise.all([
|
const [{ projects }, { groups }, { people }] = await Promise.all([
|
||||||
api.listProjects(),
|
api.listProjects(),
|
||||||
api.listProjectGroups()
|
api.listProjectGroups(),
|
||||||
|
api.people()
|
||||||
]);
|
]);
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
|
this.people = people;
|
||||||
this.connect();
|
this.connect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
@@ -410,6 +437,10 @@ class AppState {
|
|||||||
await this.#patchGroup(id, { name });
|
await this.#patchGroup(id, { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setGroupColor(id, color) {
|
||||||
|
await this.#patchGroup(id, { color });
|
||||||
|
}
|
||||||
|
|
||||||
async #patchGroup(id, patch) {
|
async #patchGroup(id, patch) {
|
||||||
const previous = this.groups.find((g) => g.id === id);
|
const previous = this.groups.find((g) => g.id === id);
|
||||||
if (!previous) return;
|
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 ''. */
|
/** Files a project under a group, or takes it out of one when groupId is ''. */
|
||||||
async setProjectGroup(projectId, groupId) {
|
async setProjectGroup(projectId, groupId) {
|
||||||
const previous = this.projects.find((p) => p.id === projectId);
|
const previous = this.projects.find((p) => p.id === projectId);
|
||||||
|
|||||||
@@ -25,7 +25,12 @@
|
|||||||
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
|
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
|
||||||
{ href: '/indstillinger/ai', label: 'AI' },
|
{ href: '/indstillinger/ai', label: 'AI' },
|
||||||
{ href: '/indstillinger/tokens', label: 'API-tokens' },
|
{ href: '/indstillinger/tokens', label: 'API-tokens' },
|
||||||
...(app.user?.is_admin ? [{ href: '/indstillinger/brugere', label: 'Brugere' }] : []),
|
...(app.user?.is_admin
|
||||||
|
? [
|
||||||
|
{ href: '/indstillinger/brugere', label: 'Brugere' },
|
||||||
|
{ href: '/indstillinger/fejl', label: 'Fejl' }
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Fejl: what has gone wrong lately, kept where a restart cannot take it.
|
||||||
|
*
|
||||||
|
* The panel's watcher already says "HTTP 5xx, twice, at 11:49". That is enough
|
||||||
|
* to know something broke and nothing at all to act on — the line explaining it
|
||||||
|
* is in the container's log, and a Rune replaces the container on every
|
||||||
|
* restart, so it is usually gone before anybody looks. These are the same
|
||||||
|
* failures written somewhere that survives.
|
||||||
|
*/
|
||||||
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
|
||||||
|
let errors = $state([]);
|
||||||
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.listErrors()
|
||||||
|
.then((r) => {
|
||||||
|
errors = r.errors;
|
||||||
|
loaded = true;
|
||||||
|
})
|
||||||
|
.catch((e) => app.toast(humanMessage(e)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
const then = new Date(iso);
|
||||||
|
const seconds = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (seconds < 60) return 'lige nu';
|
||||||
|
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||||
|
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||||
|
return then.toLocaleString('da-DK', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>Serverfejl</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Hver 500, API'et har svaret, med hvad handleren var i gang med og fejlen den
|
||||||
|
ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra
|
||||||
|
ved hver genstart, så forklaringen er som regel væk, når nogen kigger.
|
||||||
|
Ryddes efter 30 dage.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if errors.length}
|
||||||
|
<ul class="rows">
|
||||||
|
{#each errors as error (error.id)}
|
||||||
|
<li>
|
||||||
|
<div class="what">
|
||||||
|
<span class="primary-line">
|
||||||
|
<span class="status">{error.status}</span>
|
||||||
|
{error.what}
|
||||||
|
</span>
|
||||||
|
<span class="secondary">{error.message}</span>
|
||||||
|
<span class="secondary mono">
|
||||||
|
{error.method}
|
||||||
|
{error.path}{#if error.user_name}{' · '}{error.user_name}{/if}{#if error.request_id}{' · ' +
|
||||||
|
error.request_id}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="when">{when(error.at)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if loaded}
|
||||||
|
<p class="hint">Ingen fejl registreret. Det er den rigtige tilstand.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 560;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.when {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,15 +4,30 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
|
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
import BoardView from '$lib/components/BoardView.svelte';
|
import BoardView from '$lib/components/BoardView.svelte';
|
||||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||||
|
|
||||||
let project = $state(null);
|
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 sections = $state([]);
|
||||||
let members = $state([]);
|
let members = $state([]);
|
||||||
let showShare = $state(false);
|
let showShare = $state(false);
|
||||||
|
let showLog = $state(false);
|
||||||
|
let activity = $state([]);
|
||||||
let inviteEmail = $state('');
|
let inviteEmail = $state('');
|
||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
let inviteLink = $state('');
|
let inviteLink = $state('');
|
||||||
@@ -21,29 +36,94 @@
|
|||||||
let id = $derived($page.params.id);
|
let id = $derived($page.params.id);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const projectId = id;
|
load(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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(() => {
|
$effect(() => {
|
||||||
if (showShare && project) {
|
if (showShare && project) {
|
||||||
api.listMembers(project.id).then((r) => (members = r.members));
|
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
|
// The chosen view is kept locally as well as on the project, so switching is
|
||||||
// instant and only persists once the request lands.
|
// instant and only persists once the request lands.
|
||||||
let view = $state(null);
|
let view = $state(null);
|
||||||
@@ -73,6 +153,18 @@
|
|||||||
let unsectioned = $derived(open.filter((t) => !t.section_id));
|
let unsectioned = $derived(open.filter((t) => !t.section_id));
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
|
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. */
|
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
|
||||||
async function rename(input) {
|
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 -------------------------------------------------------------------
|
// --- sections -------------------------------------------------------------------
|
||||||
|
|
||||||
let addingSection = $state(false);
|
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) {
|
async function invite(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
inviteError = '';
|
inviteError = '';
|
||||||
@@ -216,6 +385,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/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">
|
<div class="views" role="group" aria-label="Visning">
|
||||||
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
||||||
<button
|
<button
|
||||||
@@ -232,6 +412,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !project.is_inbox}
|
||||||
|
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
|
||||||
|
Historik
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isOwner && !project.is_inbox}
|
{#if isOwner && !project.is_inbox}
|
||||||
<button class="remove" onclick={remove} aria-label="Slet projektet">
|
<button class="remove" onclick={remove} aria-label="Slet projektet">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -241,6 +427,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</header>
|
</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}
|
{#if showShare}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<ul class="members">
|
<ul class="members">
|
||||||
@@ -250,7 +472,22 @@
|
|||||||
{member.name[0]?.toUpperCase()}
|
{member.name[0]?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="member-name">{member.name}</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'}
|
{#if isOwner && member.role !== 'owner'}
|
||||||
<button
|
<button
|
||||||
class="remove"
|
class="remove"
|
||||||
@@ -303,13 +540,31 @@
|
|||||||
{:else if mode === 'calendar'}
|
{:else if mode === 'calendar'}
|
||||||
<CalendarView projectId={project.id} />
|
<CalendarView projectId={project.id} />
|
||||||
{:else}
|
{: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} />
|
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
||||||
|
<!-- Only once there are sections to be "without": on a project with none,
|
||||||
|
the label is a heading for the only list there is, and the empty
|
||||||
|
state below already says the project is empty. -->
|
||||||
|
{#if sections.length && !unsectioned.length}
|
||||||
|
<p class="empty">Uden sektion</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#each sections as section (section.id)}
|
{#each sections as section (section.id)}
|
||||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||||
<section>
|
<!-- 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">
|
<div class="section-head">
|
||||||
{#if renamingSection === section.id}
|
{#if renamingSection === section.id}
|
||||||
<form onsubmit={(e) => renameSection(e, section)}>
|
<form onsubmit={(e) => renameSection(e, section)}>
|
||||||
@@ -384,8 +639,18 @@
|
|||||||
{#if project.role === 'viewer'}
|
{#if project.role === 'viewer'}
|
||||||
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
|
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
|
||||||
{/if}
|
{/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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -402,17 +667,28 @@
|
|||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wraps, and the title keeps a floor.
|
||||||
|
*
|
||||||
|
* Without both, a phone squeezes the heading to its min-content — which with
|
||||||
|
* `overflow-wrap: anywhere` is *one character*, so "Skovvænget" came out as a
|
||||||
|
* vertical column of letters while the buttons ran off the right edge. Four
|
||||||
|
* controls beside a title is more than 390px has, so they belong on the next
|
||||||
|
* line rather than in a race for the same one.
|
||||||
|
*/
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s4);
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
flex: 1;
|
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
|
||||||
min-width: 0;
|
point where the wrapping below is the better answer. */
|
||||||
|
flex: 1 1 8ch;
|
||||||
|
min-width: 8ch;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,8 +708,8 @@
|
|||||||
|
|
||||||
/* Matches the heading it stands in for, so the swap does not move the page. */
|
/* Matches the heading it stands in for, so the swap does not move the page. */
|
||||||
.title {
|
.title {
|
||||||
flex: 1;
|
flex: 1 1 8ch;
|
||||||
min-width: 0;
|
min-width: 8ch;
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.015em;
|
||||||
@@ -482,6 +758,56 @@
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A circle in the project's own colour, which is both the control and its own
|
||||||
|
preview: the thing you are about to change is what you press. */
|
||||||
|
.color {
|
||||||
|
flex: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
box-shadow: 0 0 0 2px var(--ground);
|
||||||
|
transition: box-shadow var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color:hover,
|
||||||
|
.color:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--ground),
|
||||||
|
0 0 0 3px var(--line-strong);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex: none;
|
||||||
|
/* A ring in the panel's own colour, so the selected swatch reads as chosen
|
||||||
|
without changing size and shuffling the row as you move along it. */
|
||||||
|
box-shadow: 0 0 0 2px var(--surface);
|
||||||
|
transition: box-shadow var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch:hover,
|
||||||
|
.swatch:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--surface),
|
||||||
|
0 0 0 3px var(--line-strong);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch.chosen {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--surface),
|
||||||
|
0 0 0 3px var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.views {
|
.views {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
@@ -579,6 +905,24 @@
|
|||||||
font-size: var(--text-xs);
|
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 {
|
.remove {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -626,6 +970,52 @@
|
|||||||
font-weight: 550;
|
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 {
|
.link-out {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -651,6 +1041,14 @@
|
|||||||
|
|
||||||
section {
|
section {
|
||||||
margin-top: var(--s5);
|
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 {
|
h2 {
|
||||||
@@ -765,4 +1163,24 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
opacity: 0.5;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -135,17 +135,21 @@
|
|||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wraps, for the same reason the project header does: a title and a switcher
|
||||||
|
are more than a phone's width, and the switcher belongs underneath rather
|
||||||
|
than squeezing the heading. */
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s4);
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
flex: 1;
|
flex: 1 1 8ch;
|
||||||
min-width: 0;
|
min-width: 8ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.views {
|
.views {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -12,7 +12,25 @@
|
|||||||
"src": "/icon.svg",
|
"src": "/icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user