Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b45aa33e1 | ||
|
|
19aa02e80c | ||
|
|
fe52818ab6 | ||
|
|
982c3aab18 | ||
|
|
a52ad9817f | ||
|
|
0dbf7a40bf | ||
|
|
9cc7df69f0 | ||
|
|
ab5e3e35a6 | ||
|
|
d6d5bf590b | ||
|
|
6853897d00 |
@@ -79,12 +79,18 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# Chromium only. A smoke test exists to catch a build that does not work at
|
||||
# all; running it three times in three engines finds the same breakage three
|
||||
# times and costs three times as much.
|
||||
- name: Install Chromium
|
||||
# Chromium for the suite, WebKit for one test.
|
||||
#
|
||||
# A smoke test exists to catch a build that does not work at all, and running
|
||||
# the whole thing in three engines finds the same breakage three times for
|
||||
# three times the cost. Focus is the exception, and it took a real report to
|
||||
# learn it: Safari ignores `autofocus` on anything inserted after the page has
|
||||
# loaded, so every inline form in the app opened with no cursor in it. WebKit
|
||||
# here is the same engine Safari is, and `webkit-forms` in the Playwright
|
||||
# config greps a single test out of the suite to run in it.
|
||||
- name: Install browsers
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||
# playwright.config.js. There is nothing to build here first.
|
||||
|
||||
@@ -197,6 +197,9 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
||||
|
||||
## What lives in `/data`
|
||||
|
||||
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
|
||||
All three together let **Indstillinger → Notifikationer** restart this instance,
|
||||
which is also how it picks up a new version: a container cannot replace its own
|
||||
image, so the panel recreates it and pulls `:latest` on the way. Any one of the
|
||||
three alone does nothing, and the page names the ones that are missing.
|
||||
|
||||
The token is a panel credential with control over that server. Anybody who can
|
||||
read this environment can restart it — which is true of anybody with the host
|
||||
anyway — and it is never sent to a browser.
|
||||
|
||||
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
||||
at most every six hours and sends nothing about your instance — no identifier, no
|
||||
|
||||
@@ -388,6 +388,195 @@ paths:
|
||||
"204": { description: Off. }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/auth/passkeys:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: The keys registered on this account.
|
||||
description: |
|
||||
A passkey is a key on a device rather than a secret in a head. The private
|
||||
half never leaves the device, and the signature is bound to the origin that
|
||||
asked for it — so a convincing copy of the sign-in page at another address
|
||||
gets a signature it cannot use.
|
||||
|
||||
One row per credential, because a person has a laptop and a phone and losing
|
||||
one must not mean losing the way in. The name is theirs to write: a list
|
||||
nobody can read is a list nobody revokes from.
|
||||
|
||||
Sessions only, like `/tokens` — a leaked API token must not be able to add
|
||||
its own way in, which would turn a theft into a tenancy.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
passkeys:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Passkey" }
|
||||
available:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether this deployment can offer passkeys at all. False when
|
||||
`VERDANDE_BASE_URL` is an IP address or otherwise not a
|
||||
domain an authenticator will accept — the interface asks once
|
||||
rather than discovering it from a 503 it caused by offering a
|
||||
button.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/auth/passkeys/register/begin:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Start registering a key. Returns the options and a challenge id.
|
||||
description: |
|
||||
Registration is done by somebody already signed in: you cannot be handed a
|
||||
way into an account you have not proved you own.
|
||||
|
||||
The challenge is kept server-side rather than in a cookie, because the whole
|
||||
point of it is that the server chose it and remembers choosing it. It is
|
||||
answerable once and expires after five minutes.
|
||||
responses:
|
||||
"200":
|
||||
description: Pass `options` to `navigator.credentials.create()`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
options: { type: object, additionalProperties: true }
|
||||
"503":
|
||||
description: |
|
||||
`VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
|
||||
rather than 404: this version has passkeys, this deployment cannot offer
|
||||
them, and those are different things to fix.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
|
||||
/api/v1/auth/passkeys/register/finish:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Finish registering a key.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
name:
|
||||
type: string
|
||||
description: What to call it in the list — "min bærbare".
|
||||
credential:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: The response from `navigator.credentials.create()`.
|
||||
responses:
|
||||
"201":
|
||||
description: Registered.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Passkey" }
|
||||
"400":
|
||||
description: The attempt expired, was already used, or did not verify.
|
||||
"503":
|
||||
description: Passkeys are unavailable on this instance.
|
||||
|
||||
/api/v1/auth/passkeys/{passkeyID}:
|
||||
parameters:
|
||||
- { name: passkeyID, in: path, required: true, schema: { type: string } }
|
||||
patch:
|
||||
tags: [auth]
|
||||
summary: Rename a key.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
responses:
|
||||
"204": { description: Renamed. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
delete:
|
||||
tags: [auth]
|
||||
summary: Remove a key.
|
||||
description: |
|
||||
Scoped in the SQL rather than checked first: an id read in one step and
|
||||
deleted in the next is a window, and the reason somebody is on this screen
|
||||
is usually that they think a device is gone.
|
||||
responses:
|
||||
"204": { description: Removed. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/auth/passkey/login/begin:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Start signing in with a key. No session, and no email.
|
||||
description: |
|
||||
A discoverable login: nobody has said who they are yet, and that is the
|
||||
point. The device knows which account its key belongs to, so there is no
|
||||
address to type and no list of accounts for anybody to probe.
|
||||
responses:
|
||||
"200":
|
||||
description: Pass `options` to `navigator.credentials.get()`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
options: { type: object, additionalProperties: true }
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
"503":
|
||||
description: Passkeys are unavailable on this instance.
|
||||
|
||||
/api/v1/auth/passkey/login/finish:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Finish signing in with a key.
|
||||
description: |
|
||||
A key that verified its owner — a PIN, a fingerprint, a face — is both
|
||||
factors at once, and completes the login outright. One that only proved
|
||||
possession still stops at the TOTP step, exactly as a password does.
|
||||
|
||||
A counter that has gone backwards means the credential has been cloned. It
|
||||
is refused rather than logged: that counter is the only signal this design
|
||||
gives.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
credential:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: The response from `navigator.credentials.get()`.
|
||||
responses:
|
||||
"200":
|
||||
description: Signed in, or `totp_required` when the key was possession only.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/User" }
|
||||
totp_required: { type: boolean }
|
||||
"401":
|
||||
description: |
|
||||
One message for an unknown key and for a bad signature. Telling them
|
||||
apart is a way to learn which credentials exist here.
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
"503":
|
||||
description: Passkeys are unavailable on this instance.
|
||||
|
||||
/api/v1/auth/sessions:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -1148,6 +1337,67 @@ paths:
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/panel:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether this instance can restart itself. Administrators only.
|
||||
description: |
|
||||
A container cannot replace its own image from the inside, so "update" is
|
||||
really "ask whoever owns the container to recreate it" — Yggdrasil pulls
|
||||
`:latest` on the way, which is what makes a restart an update at all.
|
||||
|
||||
This says whether the three settings are present. The token is never sent:
|
||||
the page needs to know whether the button can work, not what the credential
|
||||
is. When it cannot, the missing variables are named — an operator reading
|
||||
"not configured" should not have to go and find out which of three.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
configured: { type: boolean }
|
||||
panel_url: { type: string }
|
||||
missing:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: The environment variables that are not set.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Ask the panel to recreate this container. Administrators only.
|
||||
description: |
|
||||
The panel does the work and records who asked; this removes the second
|
||||
browser tab, not the panel's authority.
|
||||
|
||||
Answers 202 rather than waiting: the panel stops this container as part of
|
||||
answering, so the reply may never arrive. A request cut off mid-flight is
|
||||
the successful case wearing a failure's clothes — the interface polls
|
||||
`/healthz` rather than believing either answer.
|
||||
|
||||
Sessions only, like the rest of this group. A leaked API token that could
|
||||
restart the server is a denial of service in one request.
|
||||
responses:
|
||||
"202":
|
||||
description: The panel accepted, or was already restarting this container.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
restarting: { type: boolean }
|
||||
note: { type: string }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"502":
|
||||
description: |
|
||||
The panel refused. Usually a token without control of this server.
|
||||
"503":
|
||||
description: The three settings are not all present.
|
||||
|
||||
/api/v1/errors:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -3127,6 +3377,32 @@ components:
|
||||
same thing once decoded, and telling "put it nowhere" apart from "I
|
||||
did not mention it" is what makes this a PATCH.
|
||||
|
||||
Passkey:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
name:
|
||||
type: string
|
||||
description: What the owner called it. "min bærbare" is what makes a list reviewable.
|
||||
aaguid:
|
||||
type: string
|
||||
description: |
|
||||
Identifies the authenticator model, which is what lets a list say
|
||||
something recognisable beside a key somebody named months ago.
|
||||
discoverable:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether the credential lives on the device. Only these can start a login
|
||||
with no email typed first.
|
||||
user_verified:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether the authenticator verified the person — a PIN, a fingerprint, a
|
||||
face. One that did is both factors at once; one that only proved
|
||||
possession is a first factor and still meets the TOTP step.
|
||||
created_at: { type: string, format: date-time }
|
||||
last_used_at: { type: string, format: date-time }
|
||||
|
||||
ProjectGroup:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
@@ -3,21 +3,30 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.55.0
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -2,12 +2,27 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -18,6 +33,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
@@ -25,10 +42,17 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
@@ -39,6 +63,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// The relying party is derived from the instance's own base URL, and the two ways
|
||||
// that goes wrong are both silent until a browser refuses.
|
||||
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
base string
|
||||
ok bool
|
||||
why string
|
||||
}{
|
||||
{"https://verdande.example.dk", true, "a real domain"},
|
||||
{"http://localhost:5173", true, "localhost, which every browser excepts"},
|
||||
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
|
||||
|
||||
// An IP address is not a domain. The browser throws SecurityError long
|
||||
// after the server has happily issued a challenge, so it is refused here
|
||||
// and the endpoints answer 503 instead.
|
||||
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
|
||||
{"http://[::1]:8080", false, "an IPv6 literal"},
|
||||
{"", false, "no address at all"},
|
||||
{"not a url", false, "not an address"},
|
||||
} {
|
||||
_, err := NewWebAuthn(c.base, "verdande")
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// Passkeys, wrapped so the rest of the app never touches the protocol types.
|
||||
//
|
||||
// A password and a TOTP code are both shared secrets: the server keeps something
|
||||
// that can be stolen from it, and the person keeps something that can be talked
|
||||
// out of them. A passkey is neither. The private half never leaves the device, and
|
||||
// the signature is bound to the origin that asked for it — so a convincing copy of
|
||||
// the sign-in page at another address gets a signature it cannot use.
|
||||
//
|
||||
// That binding is also the one thing that can be got wrong here, and it is
|
||||
// invisible when it is: the RP ID has to be the registrable domain of the address
|
||||
// people actually visit. Get it wrong and registration works, login works on your
|
||||
// machine, and everybody behind a different hostname is quietly locked out.
|
||||
|
||||
// WebAuthn is the relying party — this instance, as an authenticator sees it.
|
||||
type WebAuthn struct{ *webauthn.WebAuthn }
|
||||
|
||||
// NewWebAuthn derives the relying party from the instance's own base URL.
|
||||
//
|
||||
// Derived rather than configured, because a second setting that has to agree with
|
||||
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
|
||||
// the failure is a login that cannot be completed, reported as "the button does
|
||||
// nothing".
|
||||
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
|
||||
}
|
||||
|
||||
// The RP ID is the host without the port. A port in it is rejected by every
|
||||
// browser, and localhost:5173 during development is exactly where somebody
|
||||
// would first put one.
|
||||
rpID := parsed.Hostname()
|
||||
|
||||
// An IP address is not a domain, and no browser will accept one as an RP ID —
|
||||
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
|
||||
// the server has happily issued a challenge. Refused here instead, so the
|
||||
// endpoints answer 503 and the interface never offers a button that cannot
|
||||
// work. `localhost` is the exception every browser makes, and it is what
|
||||
// development runs on.
|
||||
if ip := net.ParseIP(rpID); ip != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"auth: passkeys need a domain name, and %q is an IP address — "+
|
||||
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
|
||||
}
|
||||
|
||||
w, err := webauthn.New(&webauthn.Config{
|
||||
RPID: rpID,
|
||||
RPDisplayName: displayName,
|
||||
// The origin is the full scheme://host:port, which is what the browser
|
||||
// sends and what the signature is checked against.
|
||||
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: passkeys: %w", err)
|
||||
}
|
||||
return &WebAuthn{w}, nil
|
||||
}
|
||||
|
||||
// A PasskeyUser is what the library needs to know about an account.
|
||||
//
|
||||
// The handle is the account id rather than anything a person chose. It is written
|
||||
// into the credential on the device and cannot be changed afterwards, so it must
|
||||
// not be an email address: changing your email would orphan every key you own.
|
||||
type PasskeyUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
|
||||
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
|
||||
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
|
||||
|
||||
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
|
||||
// go in a BLOB well enough, but the id is also compared against what the browser
|
||||
// sends back as base64url, and one encoding on both sides is one thing to be
|
||||
// wrong about.
|
||||
func EncodeCredentialID(raw []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func DecodeCredentialID(encoded string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(encoded)
|
||||
}
|
||||
@@ -38,6 +38,22 @@ type Config struct {
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
// A container cannot replace its own image from the inside. Restarting is the
|
||||
// panel's job — it recreates the container and pulls `:latest` on the way — so
|
||||
// what this needs is a way to ask. All three are required together; any one
|
||||
// alone cannot do anything.
|
||||
//
|
||||
// The token is a panel credential with control over that server. Anybody who
|
||||
// can read this environment can restart it, which is true of anybody with the
|
||||
// host anyway. It is never sent to a browser: the settings page is told only
|
||||
// whether it is set.
|
||||
PanelURL string
|
||||
PanelToken string
|
||||
PanelServerID string
|
||||
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
@@ -69,6 +85,9 @@ func Load() (*Config, error) {
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://127.0.0.1",
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
|
||||
@@ -1172,7 +1172,7 @@ func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
|
||||
}
|
||||
// The single most likely thing to be wrong, so it is spelled out rather than
|
||||
// left for somebody to reconstruct.
|
||||
if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
|
||||
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
|
||||
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Asking the panel to restart this instance.
|
||||
//
|
||||
// A container cannot replace its own image from the inside, so "update" is really
|
||||
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
|
||||
// the way, which is what makes a restart an update at all.
|
||||
//
|
||||
// The alternative was a second browser tab: read the version here, go to the
|
||||
// panel, find the server, press Restart. This removes the tab, not the panel's
|
||||
// authority — the panel still does the work and still records who asked.
|
||||
//
|
||||
// Administrators only, and sessions only. A leaked API token that could restart
|
||||
// the server is a denial of service in one request, and the whole point of the
|
||||
// session rule elsewhere in this file is that a stolen token must not be able to
|
||||
// do things only a person at a keyboard should.
|
||||
|
||||
type panelStatusJSON struct {
|
||||
// Configured is all three settings being present. The token itself is never
|
||||
// sent: the page needs to know whether the button can work, not what the
|
||||
// credential is.
|
||||
Configured bool `json:"configured"`
|
||||
PanelURL string `json:"panel_url,omitempty"`
|
||||
// Why it cannot work, when it cannot. An operator reading "not configured"
|
||||
// should not have to go and find out which of three things is missing.
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) panelStatus() panelStatusJSON {
|
||||
var missing []string
|
||||
if s.cfg.PanelURL == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_URL")
|
||||
}
|
||||
if s.cfg.PanelToken == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_TOKEN")
|
||||
}
|
||||
if s.cfg.PanelServerID == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
|
||||
}
|
||||
return panelStatusJSON{
|
||||
Configured: len(missing) == 0,
|
||||
PanelURL: s.cfg.PanelURL,
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.panelStatus())
|
||||
}
|
||||
|
||||
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.panelStatus()
|
||||
if !status.Configured {
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
|
||||
|
||||
// A short timeout of its own. The panel stops this container as part of
|
||||
// answering, so the reply may never arrive — that is not a failure, and a
|
||||
// request that hung waiting for it would look like one.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
s.internal(w, r, "build panel request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
// Including "the panel killed us mid-reply", which is the successful case
|
||||
// wearing a failure's clothes. The interface polls /healthz rather than
|
||||
// believing either answer, so this only has to be honest about what it saw.
|
||||
s.log.Warn("panel restart call did not complete", "err", err)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"restarting": true,
|
||||
"note": "the panel did not answer; it may already be restarting this container",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal,
|
||||
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// Restarting is administrators only, and sessions only. A leaked API token that
|
||||
// could restart the server is a denial of service in one request.
|
||||
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
|
||||
method := "GET"
|
||||
if path != "/api/v1/panel" {
|
||||
method = "POST"
|
||||
}
|
||||
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Unconfigured, it says which of the three settings are missing rather than
|
||||
// "not configured" — an operator should not have to go and find out which.
|
||||
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||
if status["configured"] != false {
|
||||
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
|
||||
}
|
||||
missing, _ := status["missing"].([]any)
|
||||
if len(missing) != 3 {
|
||||
t.Errorf("missing = %v, want all three settings named", missing)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
|
||||
}
|
||||
if body["error"] == nil {
|
||||
t.Error("the refusal says nothing about why")
|
||||
}
|
||||
}
|
||||
|
||||
// Configured, it calls the panel with a bearer token and reports what came back.
|
||||
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "hemmelig"
|
||||
cfg.PanelServerID = "abc-123"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||
if status["configured"] != true {
|
||||
t.Fatalf("configured = %v with all three set", status["configured"])
|
||||
}
|
||||
// The token is a credential and must never reach a browser.
|
||||
for key, value := range status {
|
||||
if s, ok := value.(string); ok && s == "hemmelig" {
|
||||
t.Errorf("the panel token was sent to the client in %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
|
||||
}
|
||||
if gotPath != "/api/servers/abc-123/restart" {
|
||||
t.Errorf("called %q", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer hemmelig" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// A panel that refuses is reported as the panel refusing, not as this server
|
||||
// breaking: the fix is a token with control of that server, and saying "internal
|
||||
// error" would send somebody looking in the wrong place.
|
||||
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "forkert"
|
||||
cfg.PanelServerID = "abc-123"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
|
||||
}
|
||||
if msg, _ := body["error"].(string); msg == "" {
|
||||
t.Error("the refusal does not say what to check")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Passkeys: a key on a device instead of a secret in a head.
|
||||
//
|
||||
// Four endpoints, in two pairs. Registration is done by somebody already signed in
|
||||
// — you cannot be handed a way into an account you have not proved you own.
|
||||
// Logging in is done by somebody who is not, and is the one place here that is
|
||||
// reachable without a session.
|
||||
//
|
||||
// Each ceremony is two requests because the browser has to talk to the
|
||||
// authenticator in between: the server issues a challenge, the device signs it,
|
||||
// the server checks the signature against what it remembers issuing. The challenge
|
||||
// lives in the database rather than in a cookie, because the whole point of it is
|
||||
// that the server chose it and remembers choosing it.
|
||||
|
||||
const challengeTTL = 5 * time.Minute
|
||||
|
||||
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
|
||||
// built. 404 would read as "this version does not have passkeys"; the truth is
|
||||
// that this deployment cannot offer them, which is a different thing to fix.
|
||||
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.passkeys != nil {
|
||||
return true
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
|
||||
return false
|
||||
}
|
||||
|
||||
type passkeyJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// AAGUID identifies the authenticator model, which is what lets the list say
|
||||
// something recognisable beside a key somebody named three months ago.
|
||||
AAGUID string `json:"aaguid,omitempty"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
UserVerified bool `json:"user_verified"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list passkeys", err)
|
||||
return
|
||||
}
|
||||
out := make([]passkeyJSON, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
j := passkeyJSON{
|
||||
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
|
||||
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
|
||||
CreatedAt: k.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if !k.LastUsedAt.IsZero() {
|
||||
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
// Whether this deployment can offer them at all, alongside the ones that exist.
|
||||
// The interface asks one question rather than discovering the answer from a 503
|
||||
// it triggered by offering a button — an address an authenticator refuses is a
|
||||
// deployment fact, not an error somebody caused.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"passkeys": out, "available": s.passkeys != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// --- registering a key ------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Resident keys, and the authenticator asked to verify its owner. Both are
|
||||
// what make this a *passkey* rather than a second factor: only a discoverable
|
||||
// credential can start a login with no email typed, and only a verified one
|
||||
// stands in for the password rather than beside it.
|
||||
creation, session, err := s.passkeys.BeginRegistration(account,
|
||||
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
}),
|
||||
// Excluding what they already have, so a second registration on the same
|
||||
// device replaces nothing and reports itself instead of silently making a
|
||||
// duplicate.
|
||||
webauthn.WithExclusions(account.excludeList()),
|
||||
)
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey registration", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, user.ID, "register", session, creation)
|
||||
}
|
||||
|
||||
type finishRegistrationRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishRegistrationRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
key := &store.Passkey{
|
||||
UserID: user.ID,
|
||||
CredentialID: auth.EncodeCredentialID(credential.ID),
|
||||
PublicKey: credential.PublicKey,
|
||||
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
|
||||
SignCount: credential.Authenticator.SignCount,
|
||||
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
|
||||
UserVerified: credential.Flags.UserVerified,
|
||||
Name: name,
|
||||
}
|
||||
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
|
||||
s.internal(w, r, "store passkey", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, passkeyJSON{
|
||||
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
|
||||
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
|
||||
CreatedAt: key.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// --- signing in with a key ---------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Discoverable: nobody has said who they are yet, and that is the point. The
|
||||
// device knows which account its key belongs to, so there is no email to type
|
||||
// and no list of accounts for anybody to probe.
|
||||
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey login", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, "", "login", session, assertion)
|
||||
}
|
||||
|
||||
type finishLoginRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishLoginRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
// Which account, resolved from the credential the device signed with. The
|
||||
// handler is given the raw id and returns the owner; the library checks the
|
||||
// signature against that owner's stored public key.
|
||||
var matched *store.Passkey
|
||||
credential, err := s.passkeys.ValidateDiscoverableLogin(
|
||||
func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched = key
|
||||
owner, err := s.db.UserByID(r.Context(), key.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
|
||||
}, *session, parsed)
|
||||
if err != nil || matched == nil {
|
||||
// One message, as with a wrong password: telling "no such key" apart from
|
||||
// "bad signature" is a way to learn which credentials exist here.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
// A counter that goes backwards means the credential has been cloned. Not
|
||||
// every authenticator keeps one, so zero on both sides is silence rather than
|
||||
// suspicion — but a real counter that has gone down is the one signal this
|
||||
// design gives, and it is refused rather than logged.
|
||||
if credential.Authenticator.CloneWarning {
|
||||
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
owner, err := s.db.UserByID(r.Context(), matched.UserID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load account", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
|
||||
s.log.Warn("record passkey use", "err", err)
|
||||
}
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
|
||||
// A verified key is both factors at once: the device proved possession and the
|
||||
// person proved they were there, which is exactly what a password plus a code
|
||||
// is for. One that only proved possession still stops at the code.
|
||||
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
|
||||
ttl := s.cfg.SessionTTL
|
||||
if pending {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if pending {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(owner)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
}
|
||||
|
||||
// --- managing them ------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||
return
|
||||
}
|
||||
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID, name); err != nil {
|
||||
s.storeError(w, r, "rename passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "delete passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- shared plumbing -----------------------------------------------------------------
|
||||
|
||||
// passkeyAccount is the library's view of an account, plus the rows behind it.
|
||||
type passkeyAccount struct {
|
||||
*auth.PasskeyUser
|
||||
stored []store.Passkey
|
||||
}
|
||||
|
||||
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
|
||||
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
|
||||
for _, c := range a.PasskeyUser.Credentials {
|
||||
out = append(out, c.Descriptor())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials := make([]webauthn.Credential, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
raw, err := auth.DecodeCredentialID(k.CredentialID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
credentials = append(credentials, webauthn.Credential{
|
||||
ID: raw,
|
||||
PublicKey: k.PublicKey,
|
||||
Authenticator: webauthn.Authenticator{
|
||||
SignCount: k.SignCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
return &passkeyAccount{
|
||||
PasskeyUser: &auth.PasskeyUser{
|
||||
ID: userID, Email: email, Name: name, Credentials: credentials,
|
||||
},
|
||||
stored: keys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// issueChallenge stores the server's side of a ceremony and hands the browser the
|
||||
// options plus the id it will answer with.
|
||||
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
|
||||
encoded, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
s.internal(w, r, "encode webauthn session", err)
|
||||
return
|
||||
}
|
||||
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store challenge", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
|
||||
}
|
||||
|
||||
// takeChallenge reads a challenge once and checks it belongs where it is being
|
||||
// used. A registration challenge answered by a login — or one account's answered
|
||||
// by another — is refused rather than merely failing later.
|
||||
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
|
||||
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"that attempt has expired or was already used; start again")
|
||||
return nil, false
|
||||
}
|
||||
if userID != "" && owner != userID {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
|
||||
return nil, false
|
||||
}
|
||||
var session webauthn.SessionData
|
||||
if err := json.Unmarshal(raw, &session); err != nil {
|
||||
s.internal(w, r, "decode webauthn session", err)
|
||||
return nil, false
|
||||
}
|
||||
return &session, true
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// What can be tested without an authenticator, which is most of the surface that
|
||||
// actually goes wrong.
|
||||
//
|
||||
// The signature itself cannot be produced here — that needs a real device or a
|
||||
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
|
||||
// What is testable is everything around it: who may reach these endpoints, that a
|
||||
// challenge is answerable exactly once, that one account's challenge cannot be
|
||||
// answered by another, and that an expired one is refused. Those are the parts a
|
||||
// mistake would make silently permissive.
|
||||
|
||||
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
|
||||
// A leaked API token must not be able to add its own way in: that would turn a
|
||||
// theft into a tenancy.
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/auth/passkeys"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/begin"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/finish"},
|
||||
} {
|
||||
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beginning a registration hands back options and a challenge id, and the options
|
||||
// carry the things that make this a passkey rather than a second factor.
|
||||
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["challenge_id"] == "" || body["challenge_id"] == nil {
|
||||
t.Fatal("no challenge id came back")
|
||||
}
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
if publicKey == nil {
|
||||
t.Fatalf("no publicKey in the options: %v", options)
|
||||
}
|
||||
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
|
||||
if selection["residentKey"] != "preferred" {
|
||||
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
|
||||
"with no email typed", selection["residentKey"])
|
||||
}
|
||||
if selection["userVerification"] != "preferred" {
|
||||
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
|
||||
"stand in for the password", selection["userVerification"])
|
||||
}
|
||||
// The user handle must be the account id rather than the email: it is written
|
||||
// into the credential and cannot be changed, so an email there would orphan
|
||||
// every key the moment somebody changed their address.
|
||||
user, _ := publicKey["user"].(map[string]any)
|
||||
if user["name"] != "[email protected]" {
|
||||
t.Errorf("user.name = %v", user["name"])
|
||||
}
|
||||
if id, _ := user["id"].(string); id == "" {
|
||||
t.Error("no user handle in the options")
|
||||
}
|
||||
}
|
||||
|
||||
// A challenge is answerable exactly once, and only by whom it was issued to.
|
||||
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
challenge := begun["challenge_id"].(string)
|
||||
|
||||
// Somebody else's challenge, answered by this account. Refused before the
|
||||
// signature is even looked at.
|
||||
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And it is spent: the rightful owner cannot use it either now, because taking
|
||||
// it is what deleted it. That is deliberate — a challenge that survives a
|
||||
// failed attempt is a challenge somebody can keep trying.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Logging in with a key needs no session and says nothing about who exists here.
|
||||
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// No session at all — a fresh client, which is the whole point.
|
||||
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
|
||||
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
challenge := body["challenge_id"].(string)
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
// A discoverable login must not list credentials: that list would say which
|
||||
// keys exist on this instance to anybody who asked.
|
||||
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
|
||||
if list, _ := allowed.([]any); len(list) > 0 {
|
||||
t.Errorf("the login options list %d credentials; a discoverable login must not",
|
||||
len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// A garbage answer is refused with the same message a wrong key would get.
|
||||
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A key belongs to its owner, and nobody else can rename or remove it.
|
||||
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Written straight to the store: registering one properly needs a device.
|
||||
key := &store.Passkey{
|
||||
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
|
||||
}
|
||||
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
|
||||
t.Fatalf("the owner sees %d keys, want 1", len(keys))
|
||||
}
|
||||
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
|
||||
t.Errorf("somebody else sees %d of this account's keys", len(keys))
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what, method string
|
||||
body any
|
||||
}{
|
||||
{"rename", "PATCH", map[string]any{"name": "min nu"}},
|
||||
{"delete", "DELETE", nil},
|
||||
} {
|
||||
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
|
||||
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner renaming: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner deleting: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -365,3 +365,59 @@ func TestAProjectCanBeCreatedIntoAGroup(t *testing.T) {
|
||||
t.Errorf("the stored project has group_id = %v, want %q", found["group_id"], groupID)
|
||||
}
|
||||
}
|
||||
|
||||
// A section holds as many tasks as you put in it.
|
||||
//
|
||||
// Reported from use: "sections can only have one task". Driven through the same
|
||||
// endpoint the interface uses, with the same `after_id` the drop handler sends —
|
||||
// the second task goes after the first, which is the case that would fail if
|
||||
// positioning inside a section were wrong.
|
||||
func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Skovvænget"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Håndværker"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
var previous string
|
||||
for _, content := range []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"} {
|
||||
_, created := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": content, "project_id": projectID,
|
||||
})
|
||||
id := created["id"].(string)
|
||||
|
||||
resp, moved := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
|
||||
"project_id": projectID, "section_id": sectionID, "after_id": previous,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("move %q: status %d, body %v", content, resp.StatusCode, moved)
|
||||
}
|
||||
if moved["section_id"] != sectionID {
|
||||
t.Fatalf("%q did not land in the section: %v", content, moved["section_id"])
|
||||
}
|
||||
previous = id
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
|
||||
tasks, _ := listed["tasks"].([]any)
|
||||
inSection := []string{}
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["section_id"] == sectionID {
|
||||
inSection = append(inSection, task["content"].(string))
|
||||
}
|
||||
}
|
||||
if len(inSection) != 3 {
|
||||
t.Fatalf("the section holds %d tasks, want 3: %v", len(inSection), inSection)
|
||||
}
|
||||
// And in the order they were placed, since each went after the one before it.
|
||||
want := []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"}
|
||||
for i, content := range want {
|
||||
if inSection[i] != content {
|
||||
t.Errorf("position %d is %q, want %q — the whole list: %v", i, inSection[i], content, inSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/mail"
|
||||
"github.com/kristianwind/verdande/internal/mcp"
|
||||
@@ -39,6 +40,12 @@ type Server struct {
|
||||
mcp *mcp.Server
|
||||
updates *update.Checker
|
||||
|
||||
// Nil when the base URL is not something an authenticator will accept — a bare
|
||||
// hostname with no scheme, say. The endpoints answer 503 rather than the
|
||||
// process refusing to start: passkeys are one way in among three, and an
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
}
|
||||
s.mcp = s.buildMCP()
|
||||
s.updates = update.New(Version, cfg.UpdateCheck)
|
||||
|
||||
@@ -132,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Method(http.MethodPost, "/password/reset",
|
||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
||||
|
||||
// Signing in with a passkey. Rate-limited like the password path: a
|
||||
// signature cannot be guessed, but the endpoint can still be used to
|
||||
// hammer the database.
|
||||
r.Method(http.MethodPost, "/passkey/login/begin",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
|
||||
r.Method(http.MethodPost, "/passkey/login/finish",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
|
||||
|
||||
// The second step of a login: a session that has passed the password
|
||||
// but not yet the code, and can reach nothing else.
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -160,6 +180,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
|
||||
// Registering a key, and the list of them. Sessions only, for
|
||||
// the same reason: a leaked token must not be able to add its
|
||||
// own way in — that would turn a theft into a tenancy.
|
||||
r.Get("/passkeys", s.handleListPasskeys)
|
||||
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
|
||||
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
|
||||
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
|
||||
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -318,6 +347,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession, s.requireAdmin)
|
||||
r.Get("/errors", s.handleListErrors)
|
||||
// Restarting this instance from its own interface. The panel does
|
||||
// the work — a container cannot replace its own image — and pulls
|
||||
// `:latest` on the way, which is what makes a restart an update.
|
||||
r.Get("/panel", s.handlePanelStatus)
|
||||
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
|
||||
@@ -475,6 +475,10 @@ type quickAddRequest struct {
|
||||
// ProjectID is the view the user was looking at. A "#project" in the text
|
||||
// wins over it: what somebody typed beats where they happened to be standing.
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// SectionID is the section the box was opened in, for the field that sits at
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -518,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolved after the project, because a section belongs to one project and the
|
||||
// same name can exist in several. A name that matches nothing is reported the
|
||||
// same way an unknown project is: the task is still created, in the project
|
||||
// without a section, and the caller is told which word did not land.
|
||||
sectionID, unknownSection := "", ""
|
||||
// The section the box belongs to, unless the text names one. Checked against
|
||||
// the project the task actually landed in, so a "#project" that moved it
|
||||
// elsewhere cannot drag a section id from another project along with it.
|
||||
if req.SectionID != "" {
|
||||
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
|
||||
sectionID = req.SectionID
|
||||
}
|
||||
}
|
||||
if parsed.Section != "" {
|
||||
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
|
||||
sectionID = id
|
||||
} else {
|
||||
unknownSection = parsed.Section
|
||||
}
|
||||
}
|
||||
|
||||
t := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
@@ -537,13 +563,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
if unknownProject != "" || unknownSection != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
|
||||
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
|
||||
t.Errorf("unread = %v after marking everything read", after["unread"])
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add can put a task straight into a section.
|
||||
//
|
||||
// A section belongs to one project, so the name is resolved after the project is
|
||||
// decided — and two projects may both have a "Produktion", which are different
|
||||
// sections. A name that matches nothing is reported the way an unknown project is:
|
||||
// the task is still created, and the caller is told which word did not land.
|
||||
func TestQuickAddPutsATaskInASection(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Kunder/Ordrer"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// The section name has a slash in it, which is also the sigil. The bare form is
|
||||
// greedy, so the whole name comes across.
|
||||
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
|
||||
}
|
||||
if task["content"] != "Karpenhøj kaffe bestilling" {
|
||||
t.Errorf("content = %v — the sigils should be gone", task["content"])
|
||||
}
|
||||
|
||||
// Case-insensitive, like the project sigil.
|
||||
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
|
||||
if lower["section_id"] != sectionID {
|
||||
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
|
||||
}
|
||||
|
||||
// A name that matches nothing still creates the task, and says so.
|
||||
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
|
||||
if missing["section_id"] != nil && missing["section_id"] != "" {
|
||||
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
|
||||
}
|
||||
if missing["unknown_section"] != "Findesikke" {
|
||||
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
|
||||
}
|
||||
if missing["content"] != "noget andet" {
|
||||
t.Errorf("content = %v", missing["content"])
|
||||
}
|
||||
|
||||
// A section in another project is a different section, and must not be found.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
|
||||
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
|
||||
map[string]any{"name": "Egen"})
|
||||
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "forkert sted #Risteriet /Egen"})
|
||||
if crossed["unknown_section"] != "Egen" {
|
||||
t.Errorf("a section from another project was found: %v", crossed["section_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// The field at the foot of a section passes the section it sits in, and a
|
||||
// "#project" in the same line can move the task out from under it.
|
||||
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
|
||||
hereID := here["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
|
||||
map[string]any{"name": "Produktion"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// Standing in the section is enough; no sigil needed.
|
||||
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if plain["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
|
||||
}
|
||||
|
||||
// A "#project" that moves the task must not drag the section id with it: that
|
||||
// section belongs to the project being left behind.
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if moved["section_id"] != nil && moved["section_id"] != "" {
|
||||
t.Errorf("the section came along to another project: %v", moved["section_id"])
|
||||
}
|
||||
if moved["content"] != "male gavlen" {
|
||||
t.Errorf("content = %v", moved["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Danish prose left in a component is the failure the dictionaries cannot see.
|
||||
//
|
||||
// `i18n_test.go` checks that the two dictionaries agree. It cannot check the thing
|
||||
// that actually went wrong: a string that was never put in either, because nobody
|
||||
// noticed it. Three survived the sweep — "Ingen nyere version fundet.", "Ikke mere
|
||||
// i dag." and a token hint split across two lines — and they survived it for the
|
||||
// same reason. The sweep grepped for æøå, and none of them contain one.
|
||||
//
|
||||
// So this reads the templates instead and looks for the words that give Danish
|
||||
// away whatever letters it happens to use. A word list rather than a language
|
||||
// detector: this repository ships one static binary, and a list of thirty common
|
||||
// words catches the sentences a person would actually write.
|
||||
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||
// Text nodes of three characters or more, and the three attributes a person
|
||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||
// and is not what this is looking for.
|
||||
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
|
||||
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
|
||||
// Words that are Danish and are not also English. "is", "for" and "to" would
|
||||
// match English prose and are deliberately absent.
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
|
||||
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
styles := regexp.MustCompile(`(?s)<style>.*`)
|
||||
|
||||
var found []string
|
||||
root := filepath.Join("..", "..", "web", "src")
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".svelte") {
|
||||
return err
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
markup := string(body)
|
||||
if at := strings.Index(markup, "</script>"); at > 0 {
|
||||
markup = markup[at:]
|
||||
}
|
||||
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
|
||||
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
|
||||
attrs.FindAllStringSubmatch(markup, -1)...) {
|
||||
phrase := strings.TrimSpace(m[1])
|
||||
if danish.MatchString(phrase) {
|
||||
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
|
||||
"t(). An English interface shows them in Danish, and neither dictionary knows "+
|
||||
"they exist:\n %s", len(found), strings.Join(found, "\n "))
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
KindTime Kind = "time"
|
||||
KindPriority Kind = "priority"
|
||||
KindProject Kind = "project"
|
||||
KindSection Kind = "section"
|
||||
KindLabel Kind = "label"
|
||||
KindRepeat Kind = "repeat"
|
||||
)
|
||||
@@ -52,7 +53,11 @@ type Result struct {
|
||||
Content string `json:"content"`
|
||||
// Project is the name written after '#', not an id: the caller resolves it
|
||||
// against the user's projects and decides what to do when it matches nothing.
|
||||
Project string `json:"project,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
// Section is the name written after '/', resolved by the caller against the
|
||||
// sections of whichever project the task lands in. A section belongs to one
|
||||
// project, so it cannot be looked up until the project is known.
|
||||
Section string `json:"section,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
||||
Priority int `json:"priority"`
|
||||
@@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
|
||||
// called "Fredag" from later being read as a weekday.
|
||||
res.Project, spans = extractProject(input, spans)
|
||||
res.Section, spans = extractSection(input, spans)
|
||||
res.Labels, spans = extractLabels(input, spans)
|
||||
res.Priority, spans = extractPriority(input, spans)
|
||||
|
||||
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
var (
|
||||
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
|
||||
//
|
||||
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
|
||||
// name — so the bare form is greedy and swallows the lot.
|
||||
//
|
||||
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
|
||||
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
|
||||
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
|
||||
// three are the shapes a slash actually takes in a Danish task line.
|
||||
reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
)
|
||||
|
||||
func extractProject(input string, spans []Span) (string, []Span) {
|
||||
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
|
||||
return name, spans
|
||||
}
|
||||
|
||||
// Last one wins, as with the project: retyping is a correction, not two sections.
|
||||
func extractSection(input string, spans []Span) (string, []Span) {
|
||||
all := reSection.FindAllStringSubmatchIndex(input, -1)
|
||||
if len(all) == 0 {
|
||||
return "", spans
|
||||
}
|
||||
var name string
|
||||
for _, m := range all {
|
||||
name = submatch(input, m)
|
||||
spans = append(spans, sigilSpan(input, m, KindSection))
|
||||
}
|
||||
return name, spans
|
||||
}
|
||||
|
||||
func extractLabels(input string, spans []Span) ([]string, []Span) {
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
|
||||
t.Error("no span of kind repeat")
|
||||
}
|
||||
}
|
||||
|
||||
// A section, and the three shapes a slash takes in ordinary text.
|
||||
//
|
||||
// The sigil is the one that needed thinking about: '#' and '@' never occur in a
|
||||
// Danish task line by accident, and '/' occurs constantly. What keeps it apart is
|
||||
// that the slash must follow whitespace and the name must follow the slash with
|
||||
// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail.
|
||||
func TestASectionIsWrittenWithASlash(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
section string
|
||||
content string
|
||||
}{
|
||||
{"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"},
|
||||
{"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"},
|
||||
// A real section name with a slash in it. The bare form is greedy, so the
|
||||
// whole thing comes across rather than stopping at the second slash.
|
||||
{"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"},
|
||||
{`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"},
|
||||
// Retyping is a correction, not two sections.
|
||||
{"nye poser /Drift /Produktion", "Produktion", "nye poser"},
|
||||
// And alongside everything else.
|
||||
{"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"},
|
||||
|
||||
// The ways a slash shows up when nobody meant a section. None of them may
|
||||
// produce one: the slash has to follow whitespace, and the name has to
|
||||
// follow the slash with nothing between.
|
||||
{"kør 80 km/t forbi", "", "kør 80 km/t forbi"},
|
||||
{"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"},
|
||||
// "3/4" is a section to nobody and a date to a Dane — the 3rd of April —
|
||||
// and the date parser is right to claim it. What matters here is only that
|
||||
// it did not become a section.
|
||||
{"læs 3/4 af rapporten", "", "læs af rapporten"},
|
||||
} {
|
||||
got := Parse(c.in, ref(t), "da")
|
||||
if got.Section != c.section {
|
||||
t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section)
|
||||
}
|
||||
if got.Content != c.content {
|
||||
t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Passkeys: a key on a device instead of a secret in a head.
|
||||
--
|
||||
-- The account already has two ways in — a password, and a TOTP code on top of it.
|
||||
-- Both are shared secrets: the server keeps something that can be stolen from it,
|
||||
-- and the person keeps something that can be phished out of them. A passkey is
|
||||
-- neither. The private half never leaves the device, the public half is all this
|
||||
-- table holds, and the signature is bound to the origin — so a convincing copy of
|
||||
-- the sign-in page at a different address gets nothing.
|
||||
--
|
||||
-- One row per credential rather than per account, because a person has more than
|
||||
-- one device and losing a laptop should not mean losing the way in. The name is
|
||||
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
|
||||
-- list nobody can read is a list nobody revokes from.
|
||||
--
|
||||
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
|
||||
-- a counter that goes backwards means the credential has been cloned, which is
|
||||
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
|
||||
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
|
||||
CREATE TABLE passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
-- The credential id the authenticator generated, raw bytes, base64url as
|
||||
-- stored. Unique across the instance: it is what a login is looked up by,
|
||||
-- before anybody has said who they are.
|
||||
credential_id TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
-- The AAGUID identifies the authenticator model, which is what lets a list say
|
||||
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- Whether the credential lives on the device itself (a resident key). Only
|
||||
-- those can start a login with no email typed first, so the interface has to
|
||||
-- know which it has before it offers that.
|
||||
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
|
||||
-- face. A key that only proves possession is a first factor; one that verified
|
||||
-- its owner is both, and that is what decides whether a password is still
|
||||
-- asked for.
|
||||
user_verified INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
|
||||
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
|
||||
|
||||
-- The challenge a registration or a login is answered against.
|
||||
--
|
||||
-- Kept server-side rather than in a cookie: the whole point of the challenge is
|
||||
-- that the server chose it and remembers choosing it. One row per attempt, deleted
|
||||
-- when it is answered, and swept when it expires — a challenge that outlives its
|
||||
-- ceremony is a replay waiting to be tried.
|
||||
--
|
||||
-- user_id is null for a login that has not said who it is yet, which is the case
|
||||
-- a discoverable credential exists for.
|
||||
CREATE TABLE webauthn_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
|
||||
challenge TEXT NOT NULL,
|
||||
-- 'register' or 'login'. A challenge issued for one must not be answerable by
|
||||
-- the other: registering is done by somebody already signed in, and logging in
|
||||
-- is not.
|
||||
purpose TEXT NOT NULL,
|
||||
-- The library's own session state, which carries the allowed credentials and
|
||||
-- the verification requirement it will check the answer against.
|
||||
session BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
CHECK (purpose IN ('register', 'login'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
|
||||
@@ -0,0 +1,206 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A Passkey is one credential on one device.
|
||||
//
|
||||
// One row per credential rather than per account: a person has a laptop and a
|
||||
// phone, and losing one must not mean losing the way in. The name is theirs to
|
||||
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
|
||||
// read is a list nobody revokes from.
|
||||
type Passkey struct {
|
||||
ID string
|
||||
UserID string
|
||||
CredentialID string
|
||||
PublicKey []byte
|
||||
AAGUID string
|
||||
SignCount uint32
|
||||
Discoverable bool
|
||||
UserVerified bool
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
LastUsedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
|
||||
if p.ID == "" {
|
||||
p.ID = NewID()
|
||||
}
|
||||
p.CreatedAt = time.Now().UTC()
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||
discoverable, user_verified, name, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
|
||||
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanPasskeys(rows)
|
||||
}
|
||||
|
||||
// PasskeyByCredentialID is how a login finds the account, before anybody has said
|
||||
// who they are. The credential id is unique across the instance, which is what
|
||||
// makes that possible.
|
||||
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||
FROM passkeys WHERE credential_id = ?`, credentialID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list, err := scanPasskeys(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &list[0], nil
|
||||
}
|
||||
|
||||
// TouchPasskey records a use and the authenticator's counter.
|
||||
//
|
||||
// The counter is stored to be compared. One that goes backwards means the
|
||||
// credential has been cloned, which is the single thing this design cannot
|
||||
// otherwise detect — and not every authenticator keeps one, so zero means "not
|
||||
// offered" rather than "suspicious".
|
||||
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
|
||||
signCount, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
|
||||
// id read in one step and deleted in the next is a window, and the reason somebody
|
||||
// is on this screen at all is usually that they think a device is gone.
|
||||
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountPasskeys is what stops somebody removing their last way in when it is the
|
||||
// only one they have.
|
||||
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
|
||||
out := []Passkey{}
|
||||
for rows.Next() {
|
||||
var p Passkey
|
||||
var discoverable, verified int
|
||||
var created, used int64
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
|
||||
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Discoverable = discoverable == 1
|
||||
p.UserVerified = verified == 1
|
||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||
if used > 0 {
|
||||
p.LastUsedAt = time.Unix(used, 0).UTC()
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- challenges -------------------------------------------------------------------
|
||||
|
||||
// StoreChallenge keeps the server's side of one ceremony.
|
||||
//
|
||||
// Server-side rather than in a cookie: the whole point of a challenge is that the
|
||||
// server chose it and remembers choosing it. It is deleted when answered and swept
|
||||
// when it expires — a challenge that outlives its ceremony is a replay waiting to
|
||||
// be tried.
|
||||
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
|
||||
id := NewID()
|
||||
now := time.Now()
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
|
||||
return id, err
|
||||
}
|
||||
|
||||
// TakeChallenge reads a challenge and removes it in the same transaction.
|
||||
//
|
||||
// Taken rather than read, because a challenge is answerable exactly once. Two
|
||||
// requests racing the same one would otherwise both succeed, which is the whole
|
||||
// shape of a replay.
|
||||
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
|
||||
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
var owner sql.NullString
|
||||
var expires int64
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
|
||||
id, purpose)
|
||||
if err := row.Scan(&owner, &session, &expires); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if time.Now().Unix() > expires {
|
||||
return ErrNotFound
|
||||
}
|
||||
userID = owner.String
|
||||
return nil
|
||||
})
|
||||
return userID, session, err
|
||||
}
|
||||
|
||||
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
|
||||
// itself; an abandoned one is what this is for.
|
||||
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -630,3 +630,36 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SectionByName resolves a "/name" from quick add within one project.
|
||||
//
|
||||
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||
// belongs to exactly one project, so the name cannot be looked up before that is
|
||||
// decided. Two projects may both have a "Produktion" and they are different
|
||||
// sections; asking without the project would be asking the wrong question.
|
||||
func (db *DB) SectionByName(ctx context.Context, projectID, name string) (string, error) {
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id FROM sections
|
||||
WHERE project_id = ? AND lower(name) = lower(?) AND deleted_at IS NULL
|
||||
ORDER BY sort_order
|
||||
LIMIT 1`, projectID, name).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// SectionInProject reports whether a section id belongs to the given project.
|
||||
//
|
||||
// Quick add takes a section id from the client — the field at the foot of a
|
||||
// section passes the one it sits in — and a "#project" in the same line can move
|
||||
// the task somewhere else entirely. Without this the id would follow it, and the
|
||||
// task would land in a section belonging to a project it is not in.
|
||||
func (db *DB) SectionInProject(ctx context.Context, sectionID, projectID string) (bool, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM sections WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||
sectionID, projectID).Scan(&n)
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at`
|
||||
ORDER BY ` + order(f)
|
||||
|
||||
if f.Limit > 0 {
|
||||
query += ` LIMIT ?`
|
||||
@@ -346,6 +346,19 @@ const taskCounts = `
|
||||
WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL),
|
||||
(SELECT count(*) FROM attachments a WHERE a.task_id = t.id)`
|
||||
|
||||
// order is how a list of tasks is sorted, which depends on what the list is for.
|
||||
//
|
||||
// A plan is read by when it is due and how urgent it is. A record of what has been
|
||||
// finished is read backwards from now — "what did I just do" and "what did I close
|
||||
// by mistake" are both questions about the most recent thing, and neither is
|
||||
// answered by a due date the task no longer has.
|
||||
func order(f TaskFilter) string {
|
||||
if f.CompletedOnly {
|
||||
return "t.completed_at DESC, t.id DESC"
|
||||
}
|
||||
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||
}
|
||||
|
||||
func scanTask(rows *sql.Rows) (Task, error) {
|
||||
var t Task
|
||||
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
||||
|
||||
@@ -78,6 +78,24 @@ gameskill:
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
# Restarting from verdande's own settings, which is also how it picks up a new
|
||||
# version: a container cannot replace its own image, so the panel recreates it
|
||||
# and pulls :latest on the way. All three are needed together; the page names
|
||||
# whichever are missing.
|
||||
- key: VERDANDE_PANEL_URL
|
||||
name: "Panel address, so verdande can ask to be restarted from its own settings"
|
||||
type: string
|
||||
default: ""
|
||||
- key: VERDANDE_PANEL_TOKEN
|
||||
name: "Panel API token — from an account with control of this server"
|
||||
type: string
|
||||
secret: true
|
||||
default: ""
|
||||
- key: VERDANDE_PANEL_SERVER_ID
|
||||
name: "This server's id in the panel (the one in its URL)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
install:
|
||||
# The image has no shell, so the install step borrows one. It only has to make
|
||||
# the directory layout exist; verdande creates and migrates its own database on
|
||||
|
||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
@@ -19,6 +19,25 @@ import { USER } from './user.js';
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
/**
|
||||
* Opens the project header's overflow menu and clicks one of its items.
|
||||
*
|
||||
* The header used to carry eight controls in one row — title, colour, show-done,
|
||||
* three view buttons, share, history, delete — and a project name is not a short
|
||||
* word, so the heading was what gave way. Everything but the view switcher moved
|
||||
* behind one button; this is how a test reaches them.
|
||||
*/
|
||||
async function projectAction(page, name) {
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
// `exact` only means anything for a string. The share item reads "Delt · 2" once
|
||||
// somebody is in the project, so callers pass a regex for that one.
|
||||
const item =
|
||||
name instanceof RegExp
|
||||
? page.getByRole('menuitem', { name })
|
||||
: page.getByRole('menuitem', { name, exact: true });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
@@ -31,9 +50,16 @@ function watchForTrouble(page) {
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes('/api/') && !response.ok()) {
|
||||
trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
|
||||
}
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
@@ -224,7 +250,7 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||
await projectAction(page, 'Slet projektet');
|
||||
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||
|
||||
@@ -540,7 +566,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
|
||||
@@ -558,7 +584,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
await invitee.goto(link);
|
||||
|
||||
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
|
||||
await invitee.getByLabel('Navn').fill('Nabo');
|
||||
await invitee.getByLabel('Navn', { exact: true }).fill('Nabo');
|
||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
@@ -715,7 +741,7 @@ test('en administrator kan invitere en bruger til instansen', async ({ browser,
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const invitee = await context.newPage();
|
||||
await invitee.goto(link);
|
||||
await invitee.getByLabel('Navn').fill('Anders');
|
||||
await invitee.getByLabel('Navn', { exact: true }).fill('Anders');
|
||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
@@ -779,7 +805,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
// controls below only exist once it has.
|
||||
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
|
||||
await page.getByLabel('Rolle').selectOption('viewer');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
@@ -788,7 +814,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
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('Navn', { exact: true }).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();
|
||||
@@ -804,7 +830,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
// 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 projectAction(page, /Delt/);
|
||||
await expect(page.getByText('Ejer')).toBeVisible();
|
||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||
|
||||
@@ -832,7 +858,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
).toHaveCount(0);
|
||||
|
||||
// The log has recorded all of this since the beginning and nothing showed it.
|
||||
await page.getByRole('button', { name: 'Historik' }).click();
|
||||
await projectAction(page, 'Historik');
|
||||
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
||||
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
||||
|
||||
@@ -945,7 +971,7 @@ test('historik-siden viser hændelser fra et projekt, administratoren ikke er me
|
||||
expect(link).toContain('/invite?token=');
|
||||
|
||||
await theirs.goto(link);
|
||||
await theirs.getByLabel('Navn').fill('Sigrid');
|
||||
await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid');
|
||||
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await theirs.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
@@ -1131,8 +1157,13 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) =
|
||||
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
||||
);
|
||||
|
||||
// The Inbox keeps its name: it is a project row created at signup, not a
|
||||
// string in the interface, and the person can rename it like any other.
|
||||
// The Inbox is named by the server at account creation, before anybody has seen
|
||||
// a settings page — so it is the one project name nobody chose, and it follows
|
||||
// the language like a label. Rename it and that becomes your name for it in
|
||||
// every language, the same rule as any other project.
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
|
||||
).toBeVisible();
|
||||
|
||||
// Danish again, for everything that runs after this.
|
||||
await page.goto('/indstillinger');
|
||||
@@ -1184,7 +1215,7 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
// And it can be looked at again, under its own heading at the bottom.
|
||||
await page.getByRole('button', { name: 'Vis færdige' }).click();
|
||||
await projectAction(page, 'Vis færdige');
|
||||
const done = page.locator('section.done');
|
||||
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
|
||||
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
|
||||
@@ -1196,13 +1227,329 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
||||
await page.reload();
|
||||
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Skjul færdige' }).click();
|
||||
await projectAction(page, 'Skjul færdige');
|
||||
await expect(page.locator('section.done')).toHaveCount(0);
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A section holds more than one task, and stops being highlighted afterwards.
|
||||
*
|
||||
* Both reported from use, and both the same root cause. TaskList read the dragged
|
||||
* task from its *own* `draggingId`, which is only set when the drag started in
|
||||
* that list — so a task dragged in from the unsectioned rows arrived as null and
|
||||
* the handler returned early. It had already called `stopPropagation`, so the
|
||||
* section around it never got the drop either.
|
||||
*
|
||||
* The result was a section you could only drop into while it was empty, because
|
||||
* an empty one has no rows to aim at. Put one task in, and there was no way to add
|
||||
* a second: it looked exactly like a section that holds one task.
|
||||
*
|
||||
* The same `stopPropagation` left the section's frame lit, since the handler that
|
||||
* clears it never ran.
|
||||
*/
|
||||
test('en sektion kan rumme flere opgaver, og rammen forsvinder bagefter @forms', 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('Badeværelset');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Badeværelset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
await page.getByLabel('Ny sektion').fill('Håndværker');
|
||||
await page.getByLabel('Ny sektion').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Håndværker' })).toBeVisible();
|
||||
|
||||
for (const what of ['knager op', 'fuge om vinduet']) {
|
||||
await page.getByLabel('Ny opgave').fill(what);
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const section = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Håndværker' })
|
||||
});
|
||||
const row = (what) => page.locator('.row').filter({ hasText: what });
|
||||
|
||||
// The first goes in while the section is empty — the only drop that used to
|
||||
// work, because there was no row in the way to swallow it.
|
||||
await row('knager op').dragTo(section);
|
||||
await expect(section.getByText('knager op', { exact: true })).toBeVisible();
|
||||
|
||||
// And the second, aimed at the row that is now in there. This is the one that
|
||||
// did nothing at all.
|
||||
await row('fuge om vinduet').dragTo(section.getByText('knager op', { exact: true }));
|
||||
await expect(section.getByText('fuge om vinduet', { exact: true })).toBeVisible();
|
||||
await expect(section.locator('.row')).toHaveCount(2);
|
||||
|
||||
// The frame is a drop target being offered, not a state. Once the drag is over
|
||||
// it has nothing left to say.
|
||||
await expect(section).not.toHaveClass(/\bover\b/);
|
||||
|
||||
// It survives a reload, so this is the server's answer and not the page's.
|
||||
await page.reload();
|
||||
const after = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Håndværker' })
|
||||
});
|
||||
await expect(after.locator('.row')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The project header keeps its name readable.
|
||||
*
|
||||
* It was a flex row of eight controls and a heading, and the heading was the part
|
||||
* that gave way: "GarageRisteriet" rendered as "GarageRist / eriet", broken
|
||||
* mid-word, because `overflow-wrap: anywhere` breaks at the first opportunity
|
||||
* rather than the last necessary one. A name split mid-syllable reads as a
|
||||
* rendering fault, not as a wrap.
|
||||
*
|
||||
* Everything but the view switcher lives behind one button now. This measures the
|
||||
* heading rather than counting controls, because the thing being protected is that
|
||||
* the name is legible — not any particular arrangement of the row.
|
||||
*/
|
||||
test('projektets navn brydes ikke midt i et ord', 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('GarageRisteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'GarageRisteriet', level: 1 });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// One line. The name is fifteen characters and the row now has room for it,
|
||||
// which is the whole point of moving the rest behind a button.
|
||||
const box = await heading.boundingBox();
|
||||
const lineHeight = await heading.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||
expect(box.height, 'overskriften er brudt over flere linjer').toBeLessThan(lineHeight * 2);
|
||||
|
||||
// And the actions are still reachable, just one click further in.
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
for (const item of ['Vis færdige', 'Del', 'Historik', 'Farve på projektet', 'Slet projektet']) {
|
||||
await expect(page.getByRole('menuitem', { name: item, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Escape closes it — a menu you can only leave by pressing its own button again
|
||||
// is one you click twice to get out of.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menuitem', { name: 'Historik', exact: true })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task can be written straight into a section, two ways.
|
||||
*
|
||||
* The box at the top of the page puts things in the project with no section, which
|
||||
* is right for capturing — but a project has sections because the work belongs
|
||||
* somewhere, and dragging every new task down into the right one is a second
|
||||
* gesture for something you already knew when you typed it.
|
||||
*
|
||||
* The field at the foot of a section passes the section it sits in. The sigil is
|
||||
* for the box at the top, and for Today and Upcoming, where you are not standing
|
||||
* in a section at all.
|
||||
*/
|
||||
test('en opgave kan skrives direkte i en sektion @forms', 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 expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
||||
|
||||
// A section whose name contains the sigil, because that is the one that would
|
||||
// break: the bare form has to be greedy enough to swallow the whole name.
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
await page.getByLabel('Ny sektion').fill('Kunder/Ordrer');
|
||||
await page.getByLabel('Ny sektion').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible();
|
||||
|
||||
const section = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Kunder/Ordrer' })
|
||||
});
|
||||
|
||||
// The field at the foot of the section. No sigil — standing there is enough.
|
||||
await section.getByRole('button', { name: '+ Tilføj opgave' }).click();
|
||||
const field = page.getByLabel('Ny opgave i Kunder/Ordrer');
|
||||
await field.fill('Karpenhøj bestilling i morgen p1');
|
||||
await field.press('Enter');
|
||||
|
||||
const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' });
|
||||
await expect(landed).toBeVisible();
|
||||
// It went through quick add, so the date and the priority were read as well.
|
||||
await expect(landed.getByText('I morgen')).toBeVisible();
|
||||
|
||||
// And the sigil, from the box at the top, which is not standing anywhere.
|
||||
await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible();
|
||||
// The sigil is consumed, not left in the title.
|
||||
await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0);
|
||||
|
||||
await expect(section.locator('.row')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task closed by mistake can be got back, two ways.
|
||||
*
|
||||
* Reported from use: "I just closed a task by accident, and there is no undo and no
|
||||
* list of closed tasks." Both halves were true. Completing is one click on a small
|
||||
* circle beside a row you were only reading, and it takes the row off the screen —
|
||||
* so the mistake and the evidence of it leave together.
|
||||
*
|
||||
* The toast covers the mistake you notice at once. Færdige covers the one you
|
||||
* notice tomorrow, and it is a place rather than a toggle because somebody hunting
|
||||
* for a task they closed does not know which view they closed it in.
|
||||
*/
|
||||
test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('rens tagrenden i dag');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
const row = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Closed by mistake.
|
||||
await row.getByRole('button', { name: 'Markér som færdig' }).click();
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
// The toast names it, so you can tell which one you hit.
|
||||
const toast = page.locator('.toast').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(toast).toBeVisible();
|
||||
await toast.getByRole('button', { name: 'Fortryd' }).click();
|
||||
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toBeVisible();
|
||||
|
||||
// Now the other half: close it again and walk away from the toast.
|
||||
await page
|
||||
.locator('.row')
|
||||
.filter({ hasText: 'rens tagrenden' })
|
||||
.getByRole('button', { name: 'Markér som færdig' })
|
||||
.click();
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Færdige' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
|
||||
|
||||
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(closed).toBeVisible();
|
||||
// Grouped by the day it was closed, which is how anybody would describe it.
|
||||
await expect(page.getByRole('heading', { name: 'I dag', level: 2 })).toBeVisible();
|
||||
|
||||
// And it reopens from here, leaving the list at once.
|
||||
await closed.getByRole('button', { name: 'Genåbn opgave' }).click();
|
||||
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A passkey, registered and then signed in with, end to end.
|
||||
*
|
||||
* The Go tests cover who may reach these endpoints and that a challenge is spent
|
||||
* when it is used. What they cannot do is produce a signature — that needs an
|
||||
* authenticator. Chrome has a virtual one behind CDP, and it is the only way to
|
||||
* find out whether the encoding is right: every boundary here is a base64url
|
||||
* conversion, and one wrong byte produces a signature that will not verify,
|
||||
* reported as "that key was not accepted" — which reads as a broken key rather
|
||||
* than a broken encoder.
|
||||
*
|
||||
* Chromium only, and deliberately so: this is testing our ceremony, not WebKit's
|
||||
* authenticator.
|
||||
*/
|
||||
test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium');
|
||||
|
||||
// Its own context, at `localhost` rather than the loopback address the rest of
|
||||
// the suite uses. WebAuthn refuses an IP as a relying party id — the browser
|
||||
// throws `SecurityError: This is an invalid domain` — and localhost is the
|
||||
// exception every browser makes. It is not the shared baseURL because
|
||||
// resolving `localhost` is slow enough here to cost the whole suite minutes.
|
||||
//
|
||||
// Cookies are bound to an origin, so the signed-in state from the setup project
|
||||
// does not carry across: this signs in with a password first, which is also the
|
||||
// honest order — you register a key on an account you have already proved you
|
||||
// own.
|
||||
const origin = 'http://localhost:8097';
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const page = await context.newPage();
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto(`${origin}/`);
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
// A virtual authenticator that verifies its owner, so the key counts as both
|
||||
// factors — which is what lets the login complete without a password.
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send('WebAuthn.enable');
|
||||
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: 'ctap2',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`${origin}/indstillinger`);
|
||||
await page.getByLabel('Navn på nøglen').fill('Testnøglen');
|
||||
await page.getByRole('button', { name: 'Tilføj en nøgle' }).click();
|
||||
|
||||
// It is in the list, and it counts as both factors — the virtual authenticator
|
||||
// verifies, so the login below needs no password.
|
||||
const key = page.locator('li').filter({ hasText: 'Testnøglen' });
|
||||
await expect(key).toBeVisible();
|
||||
await expect(key.getByText('begge faktorer')).toBeVisible();
|
||||
|
||||
// It survives a reload, so this is the server's row and not the page's state.
|
||||
await page.reload();
|
||||
await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible();
|
||||
|
||||
// Now sign out and back in with it. No email typed: the device knows which
|
||||
// account its key belongs to, which is also why this page cannot be used to
|
||||
// find out who has an account here.
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
||||
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
||||
// Playwright matches an accessible name loosely unless told otherwise.
|
||||
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// And the key records that it was used, which is what makes the list worth
|
||||
// reading when somebody wonders whether a device is still in play.
|
||||
await page.goto(`${origin}/indstillinger`);
|
||||
await expect(
|
||||
page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
|
||||
).toBeVisible();
|
||||
|
||||
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
||||
await context.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
@@ -1221,7 +1568,9 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
// A name long enough to overflow the column at its default width.
|
||||
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
|
||||
// `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
|
||||
// ambiguous locator waits for one of them to go away rather than failing.
|
||||
await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
||||
|
||||
@@ -1236,7 +1585,7 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// Put the name back, so the tests after this one see the account they expect.
|
||||
await page.getByLabel('Navn').fill(USER.name);
|
||||
await page.getByLabel('Navn', { exact: true }).fill(USER.name);
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
@@ -1291,6 +1640,16 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
|
||||
// inside a board was exactly true.
|
||||
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
|
||||
|
||||
// A board gets the width a reading column withholds. This was `.view:has(.board)`
|
||||
// for as long as the board has existed and never once matched: `.board` is inside
|
||||
// a child component, and Svelte scopes a selector to the component that wrote it.
|
||||
// The rule read correctly, compiled, and could not fire.
|
||||
const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
||||
expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow);
|
||||
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
const field = page.getByLabel('Ny sektion');
|
||||
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
|
||||
|
||||
@@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
||||
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||
// every browser makes. The passkey test cannot run against a loopback address.
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
@@ -118,7 +121,15 @@ export default defineConfig({
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
VERDANDE_BASE_URL: baseURL,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
|
||||
@@ -51,6 +51,9 @@ const SHOTS = [
|
||||
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
|
||||
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
|
||||
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
|
||||
// The same project as a list, which is where sections read as bands rather than
|
||||
// as columns — and the shape most people actually work in.
|
||||
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
|
||||
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
||||
@@ -59,6 +62,11 @@ const SHOTS = [
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
||||
];
|
||||
|
||||
async function showList(page) {
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showMonth(page) {
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
@@ -181,6 +181,15 @@ export const api = {
|
||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
||||
listSessions: () => get('/auth/sessions'),
|
||||
|
||||
// --- passkeys
|
||||
listPasskeys: () => get('/auth/passkeys'),
|
||||
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
|
||||
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
|
||||
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
|
||||
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
|
||||
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
|
||||
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
|
||||
endSession: (id) => del(`/auth/sessions/${id}`),
|
||||
|
||||
// --- users, administrators only
|
||||
@@ -190,6 +199,8 @@ export const api = {
|
||||
deleteUser: (id) => del(`/users/${id}`),
|
||||
revokeInvite: (id) => del(`/invites/${id}`),
|
||||
listErrors: () => get('/errors'),
|
||||
panelStatus: () => get('/panel'),
|
||||
restartFromPanel: () => post('/panel/restart'),
|
||||
|
||||
// The nightly backup, which ran since the beginning with nothing to show it.
|
||||
// The OAuth client itself, which is the instance's registration with Google
|
||||
@@ -267,7 +278,8 @@ export const api = {
|
||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
|
||||
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
quickAddPreview: (text, signal) =>
|
||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
let adding = $state(false);
|
||||
let name = $state('');
|
||||
|
||||
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
|
||||
let addingIn = $state(null);
|
||||
let newInSection = $state('');
|
||||
|
||||
async function addToSection(event, sectionId) {
|
||||
event.preventDefault();
|
||||
const text = newInSection.trim();
|
||||
if (!text) return;
|
||||
newInSection = '';
|
||||
addingIn = null;
|
||||
try {
|
||||
app.upsert(await api.quickAdd(text, project.id, sectionId));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
@@ -147,6 +164,35 @@
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- At the foot of the column, where the next card would go. A board's
|
||||
columns *are* the sections, so this is the one place where "add a
|
||||
task here" needs no explanation at all. -->
|
||||
{#if canEdit}
|
||||
<!-- `column`, which is what the each binds. The unsectioned column has
|
||||
an empty id, and that is exactly what quick add wants for "in this
|
||||
project, no section". -->
|
||||
{#if addingIn === column.id}
|
||||
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
|
||||
<input
|
||||
bind:value={newInSection}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.addTaskHere')}
|
||||
aria-label={t('view.addTaskIn', { name: column.name })}
|
||||
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add-task-open"
|
||||
onclick={() => {
|
||||
addingIn = column.id;
|
||||
newInSection = '';
|
||||
}}>{t('view.addTask')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -305,6 +351,36 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.add-task-open {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.column:hover .add-task-open,
|
||||
.add-task-open:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-task-open:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-task input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--s3) var(--s2);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { app, sidebar } from '$lib/stores.svelte.js';
|
||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
@@ -368,6 +368,14 @@
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- After the three views of what is left, because it is the one that is not
|
||||
about what is left. It earns a permanent place rather than a toggle in
|
||||
each view: somebody looking for a task they closed by mistake does not
|
||||
know which view they closed it in. -->
|
||||
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('done.title')}
|
||||
</a>
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
@@ -402,7 +410,7 @@
|
||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
{#if project.shared}
|
||||
<span class="count">{project.member_count}</span>
|
||||
{/if}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { available, signIn } from '$lib/passkey.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
@@ -31,6 +32,41 @@
|
||||
let fields = $state({});
|
||||
let busy = $state(false);
|
||||
|
||||
// --- passkey -----------------------------------------------------------------
|
||||
|
||||
let passkeyReady = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
available().then((ok) => (passkeyReady = ok));
|
||||
});
|
||||
|
||||
/**
|
||||
* No email is asked for and none is sent: the device knows which account its
|
||||
* key belongs to. That is not only convenience — it means this page cannot be
|
||||
* used to find out who has an account here.
|
||||
*/
|
||||
async function signInWithPasskey() {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const result = await signIn();
|
||||
if (result.totp_required) {
|
||||
mode = 'totp';
|
||||
} else {
|
||||
app.user = result.user;
|
||||
await app.load();
|
||||
}
|
||||
} catch (e) {
|
||||
// A cancelled prompt is not a failure. Somebody changed their mind, and
|
||||
// an error for that reads as though the key was rejected.
|
||||
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
|
||||
error = humanMessage(e);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The token from an emailed link, if this is one.
|
||||
*
|
||||
@@ -267,6 +303,15 @@
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
|
||||
|
||||
<!-- Only when the browser has somewhere to keep a key. Offering it where
|
||||
it cannot work is a button that fails for reasons nobody can act on. -->
|
||||
{#if passkeyReady}
|
||||
<button type="button" class="passkey" disabled={busy} onclick={signInWithPasskey}>
|
||||
{busy ? t('passkey.signingIn') : t('passkey.signIn')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
|
||||
{t('auth.forgot')}
|
||||
</button>
|
||||
@@ -381,6 +426,22 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Set apart from the primary button but not made secondary: it is an equal way
|
||||
in, not a fallback. */
|
||||
.passkey {
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s4);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.passkey:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* necessarily the last one sent.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
@@ -436,7 +436,7 @@
|
||||
<label for="project">{t('detail.project')}</label>
|
||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
<option value={project.id}>{projectName(project)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* drawn where the row would land.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
|
||||
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
||||
@@ -33,7 +34,12 @@
|
||||
}
|
||||
|
||||
function onDragOver(event, task) {
|
||||
if (!canEdit || !carries(event, TASK) || !draggingId || draggingId === task.id) return;
|
||||
// `carries` rather than `draggingId`: a task dragged from another list — the
|
||||
// unsectioned rows above, or another section — never called this component's
|
||||
// own dragstart, so its `draggingId` is null. Refusing on that made every
|
||||
// list reject everything that came from outside it, which is the whole point
|
||||
// of dragging between them.
|
||||
if (!canEdit || !carries(event, TASK) || draggingId === task.id) return;
|
||||
accept(event);
|
||||
|
||||
// Which half of the row the pointer is in decides the gap. Comparing against
|
||||
@@ -54,13 +60,22 @@
|
||||
// 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;
|
||||
// From the drag itself, not from `draggingId`, which is only set when the
|
||||
// drag *started* in this list. A task dragged in from the unsectioned rows
|
||||
// or another section left it null, so this returned early — and it had
|
||||
// already called stopPropagation, so the section around it never got the
|
||||
// drop either. The result: a task could only be dropped into a section with
|
||||
// no rows in it to aim at, which made every section look like it could hold
|
||||
// exactly one task.
|
||||
const id = dragged(event, TASK) || draggingId;
|
||||
const below = overBelow;
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
if (!id || !canEdit || id === target.id) return;
|
||||
|
||||
const ordered = [...tasks].sort((a, b) => a.sort_order - b.sort_order);
|
||||
// The dragged task may not be in this list at all — that is the cross-list
|
||||
// case — so `without` is "this list minus it" either way.
|
||||
const without = ordered.filter((t) => t.id !== id);
|
||||
const at = without.findIndex((t) => t.id === target.id);
|
||||
if (at < 0) return;
|
||||
@@ -101,7 +116,7 @@
|
||||
);
|
||||
} catch {
|
||||
app.replace(id, previous);
|
||||
app.toast('Kunne ikke flytte opgaven.');
|
||||
app.toast(t('task.moveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -587,5 +587,64 @@ export const da = {
|
||||
|
||||
'view.done': 'Færdige',
|
||||
'view.showDone': 'Vis færdige',
|
||||
'view.hideDone': 'Skjul færdige'
|
||||
'view.hideDone': 'Skjul færdige',
|
||||
|
||||
'task.moveFailed': 'Kunne ikke flytte opgaven.',
|
||||
|
||||
'push.updatesOff': 'Der bliver ikke set efter opdateringer.',
|
||||
'push.updateOut': '{version} er ude.',
|
||||
'push.upToDate': 'Ingen nyere version fundet.',
|
||||
|
||||
'view.nothingMoreToday': 'Ikke mere i dag.',
|
||||
'project.roleFor': 'Rolle for {name}',
|
||||
|
||||
'project.more': 'Flere handlinger',
|
||||
'project.removeMember': 'Fjern {name}',
|
||||
|
||||
'nav.closeMenu': 'Luk menu',
|
||||
|
||||
'view.addTask': '+ Tilføj opgave',
|
||||
'view.addTaskHere': 'Skriv en opgave — dato, p1 og @etiket virker her også',
|
||||
'view.addTaskIn': 'Ny opgave i {name}',
|
||||
|
||||
'task.completedUndo': '“{what}” er lukket.',
|
||||
'task.undo': 'Fortryd',
|
||||
|
||||
'done.title': 'Færdige',
|
||||
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
|
||||
'done.empty': 'Du har ikke lukket noget endnu.',
|
||||
'done.unknownDay': 'Uden dato',
|
||||
|
||||
// --- passkeys ----------------------------------------------------------------------
|
||||
'passkey.title': 'Passkeys',
|
||||
'passkey.hint':
|
||||
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
|
||||
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
|
||||
'passkey.none': 'Ingen nøgler endnu.',
|
||||
'passkey.name': 'Navn på nøglen',
|
||||
'passkey.namePlaceholder': 'fx min bærbare',
|
||||
'passkey.add': 'Tilføj en nøgle',
|
||||
'passkey.registering': 'Venter på enheden…',
|
||||
'passkey.remove': 'Fjern',
|
||||
'passkey.removeQuestion': 'Fjern "{name}"? Enheden kan ikke logge ind med den bagefter.',
|
||||
'passkey.bothFactors': 'begge faktorer',
|
||||
'passkey.possessionOnly': 'kun besiddelse — koden bliver stadig krævet',
|
||||
'passkey.lastUsed': 'sidst brugt {when}',
|
||||
'passkey.neverUsed': 'aldrig brugt',
|
||||
'passkey.signIn': 'Log ind med en passkey',
|
||||
'passkey.signingIn': 'Venter på enheden…',
|
||||
|
||||
'passkey.unavailable':
|
||||
'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.',
|
||||
|
||||
// --- restarting from the interface ---------------------------------------------
|
||||
'update.restart': 'Genstart og hent nyeste version',
|
||||
'update.restarting': 'Genstarter…',
|
||||
'update.restartQuestion':
|
||||
'Genstart nu? Serveren er nede i omkring et halvt minut, og henter den nyeste version på vejen op.',
|
||||
'update.asking': 'Beder panelet om at genstarte…',
|
||||
'update.waiting': 'Venter på at serveren kommer op igen…',
|
||||
'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
|
||||
'update.notConfigured':
|
||||
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.'
|
||||
};
|
||||
|
||||
@@ -582,5 +582,64 @@ export const en = {
|
||||
|
||||
'view.done': 'Done',
|
||||
'view.showDone': 'Show done',
|
||||
'view.hideDone': 'Hide done'
|
||||
'view.hideDone': 'Hide done',
|
||||
|
||||
'task.moveFailed': 'The task could not be moved.',
|
||||
|
||||
'push.updatesOff': 'Updates are not being checked for.',
|
||||
'push.updateOut': '{version} is out.',
|
||||
'push.upToDate': 'No newer version found.',
|
||||
|
||||
'view.nothingMoreToday': 'Nothing more today.',
|
||||
'project.roleFor': 'Role for {name}',
|
||||
|
||||
'project.more': 'More actions',
|
||||
'project.removeMember': 'Remove {name}',
|
||||
|
||||
'nav.closeMenu': 'Close menu',
|
||||
|
||||
'view.addTask': '+ Add a task',
|
||||
'view.addTaskHere': 'Write a task — a date, p1 and @label work here too',
|
||||
'view.addTaskIn': 'New task in {name}',
|
||||
|
||||
'task.completedUndo': '“{what}” is done.',
|
||||
'task.undo': 'Undo',
|
||||
|
||||
'done.title': 'Done',
|
||||
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
|
||||
'done.empty': 'You have not closed anything yet.',
|
||||
'done.unknownDay': 'No date',
|
||||
|
||||
// --- passkeys ----------------------------------------------------------------------
|
||||
'passkey.title': 'Passkeys',
|
||||
'passkey.hint':
|
||||
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
|
||||
'passkey.unsupported': 'This browser does not support passkeys.',
|
||||
'passkey.none': 'No keys yet.',
|
||||
'passkey.name': 'Name for the key',
|
||||
'passkey.namePlaceholder': 'e.g. my laptop',
|
||||
'passkey.add': 'Add a key',
|
||||
'passkey.registering': 'Waiting for the device…',
|
||||
'passkey.remove': 'Remove',
|
||||
'passkey.removeQuestion': 'Remove "{name}"? That device cannot sign in with it afterwards.',
|
||||
'passkey.bothFactors': 'both factors',
|
||||
'passkey.possessionOnly': 'possession only — the code is still required',
|
||||
'passkey.lastUsed': 'last used {when}',
|
||||
'passkey.neverUsed': 'never used',
|
||||
'passkey.signIn': 'Sign in with a passkey',
|
||||
'passkey.signingIn': 'Waiting for the device…',
|
||||
|
||||
'passkey.unavailable':
|
||||
'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.',
|
||||
|
||||
// --- restarting from the interface ---------------------------------------------
|
||||
'update.restart': 'Restart and pull the latest version',
|
||||
'update.restarting': 'Restarting…',
|
||||
'update.restartQuestion':
|
||||
'Restart now? The server is down for about half a minute, and pulls the newest version on the way back up.',
|
||||
'update.asking': 'Asking the panel to restart…',
|
||||
'update.waiting': 'Waiting for the server to come back…',
|
||||
'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
|
||||
'update.notConfigured':
|
||||
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The browser half of a passkey ceremony.
|
||||
*
|
||||
* WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every
|
||||
* boundary between this and the server is a base64url conversion. Getting one of
|
||||
* them wrong produces a signature that will not verify, reported as "that key was
|
||||
* not accepted" — which reads as a broken key rather than a broken encoder. That
|
||||
* is the whole reason this is one file and not three call sites.
|
||||
*/
|
||||
import { api } from './api.js';
|
||||
|
||||
/** Whether this browser can do it at all. */
|
||||
export function supported() {
|
||||
return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key can be offered without the person typing anything first.
|
||||
*
|
||||
* Not the same question as `supported()`: a browser can do WebAuthn and still have
|
||||
* nowhere to put a discoverable credential. Asking first is what stops the sign-in
|
||||
* page offering a button that cannot work.
|
||||
*/
|
||||
export async function available() {
|
||||
if (!supported()) return false;
|
||||
try {
|
||||
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const toBuffer = (value) => {
|
||||
const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '=');
|
||||
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
||||
};
|
||||
|
||||
const toBase64URL = (buffer) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
/** Registers a key on the signed-in account. Returns the row the server stored. */
|
||||
export async function register(name) {
|
||||
const { challenge_id, options } = await api.beginPasskeyRegistration();
|
||||
const publicKey = {
|
||||
...options.publicKey,
|
||||
challenge: toBuffer(options.publicKey.challenge),
|
||||
user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) },
|
||||
excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({
|
||||
...c,
|
||||
id: toBuffer(c.id)
|
||||
}))
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
if (!credential) throw new Error('cancelled');
|
||||
|
||||
return api.finishPasskeyRegistration({
|
||||
challenge_id,
|
||||
name,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: toBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
|
||||
attestationObject: toBase64URL(credential.response.attestationObject)
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in with a key.
|
||||
*
|
||||
* No email is asked for and none is sent: the device knows which account its key
|
||||
* belongs to. That is not only convenience — it means the sign-in page cannot be
|
||||
* used to find out who has an account here.
|
||||
*/
|
||||
export async function signIn() {
|
||||
const { challenge_id, options } = await api.beginPasskeyLogin();
|
||||
const publicKey = {
|
||||
...options.publicKey,
|
||||
challenge: toBuffer(options.publicKey.challenge),
|
||||
allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({
|
||||
...c,
|
||||
id: toBuffer(c.id)
|
||||
}))
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
if (!credential) throw new Error('cancelled');
|
||||
|
||||
return api.finishPasskeyLogin({
|
||||
challenge_id,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: toBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
|
||||
authenticatorData: toBase64URL(credential.response.authenticatorData),
|
||||
signature: toBase64URL(credential.response.signature),
|
||||
userHandle: credential.response.userHandle
|
||||
? toBase64URL(credential.response.userHandle)
|
||||
: null
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n } from './i18n.svelte.js';
|
||||
import { i18n, t } from './i18n.svelte.js';
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -278,8 +278,36 @@ class AppState {
|
||||
* leaves — which is the single most-repeated action in the app, and the one
|
||||
* place latency would be felt all day.
|
||||
*/
|
||||
/**
|
||||
* Closes a task, and offers to put it back.
|
||||
*
|
||||
* Completing is one click on a small circle next to a row you were only
|
||||
* reading, and it takes the row off the screen — so the mistake and the
|
||||
* evidence of it leave together. Somebody who clicks the wrong one is left with
|
||||
* no name to search for and no list to find it in.
|
||||
*
|
||||
* The undo is offered where the mistake happened rather than filed somewhere to
|
||||
* be looked up. It is the same call `reopen` makes; the only new thing is that
|
||||
* the interface says it is available.
|
||||
*
|
||||
* A recurring task is deliberately not offered one. Completing it moves it to
|
||||
* its next date rather than closing it, so there is nothing to undo — and an
|
||||
* "undo" that silently rolled the date back would be a different act from the
|
||||
* one it claims to reverse.
|
||||
*/
|
||||
async complete(id) {
|
||||
const task = this.get(id);
|
||||
const repeats = Boolean(task?.recurrence_rule);
|
||||
const what = task?.content ?? '';
|
||||
|
||||
await this.#optimistic(id, { completed: true }, () => api.completeTask(id));
|
||||
|
||||
if (!repeats) {
|
||||
this.toast(t('task.completedUndo', { what: truncate(what) }), {
|
||||
action: t('task.undo'),
|
||||
onaction: () => this.reopen(id)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reopen(id) {
|
||||
@@ -569,12 +597,22 @@ class AppState {
|
||||
|
||||
// --- toasts -------------------------------------------------------------------
|
||||
|
||||
toast(message) {
|
||||
/**
|
||||
* Says something, and optionally offers one thing to do about it.
|
||||
*
|
||||
* An action gets longer on screen than a plain message: five seconds is enough
|
||||
* to read "could not save", and not enough to notice you closed the wrong task,
|
||||
* decide, and reach the mouse.
|
||||
*/
|
||||
toast(message, { action, onaction } = {}) {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
this.toasts.push({ id, message });
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}, 5000);
|
||||
this.toasts.push({ id, message, action, onaction });
|
||||
setTimeout(
|
||||
() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
},
|
||||
action ? 12000 : 5000
|
||||
);
|
||||
}
|
||||
|
||||
dismissToast(id) {
|
||||
@@ -582,6 +620,34 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call a project on screen.
|
||||
*
|
||||
* Almost always its own name — somebody wrote it and it is theirs. The Inbox is the
|
||||
* exception: the server names it at account creation, before anybody has seen a
|
||||
* settings page, so it is the one project name nobody chose. Leaving it as stored
|
||||
* meant an account created in Danish said "Indbakke" in an English interface, which
|
||||
* reads as a string somebody forgot to translate. It was.
|
||||
*
|
||||
* Only while it still carries a name the server gave it. Rename it and that is your
|
||||
* name for it, in every language — the same rule as any other project.
|
||||
*/
|
||||
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
||||
|
||||
export function projectName(project) {
|
||||
if (!project) return '';
|
||||
if (project.is_inbox && SYSTEM_INBOX_NAMES.has(project.name.trim().toLowerCase())) {
|
||||
return t('nav.inbox');
|
||||
}
|
||||
return project.name;
|
||||
}
|
||||
|
||||
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
||||
function truncate(text, max = 40) {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > max ? trimmed.slice(0, max - 1).trimEnd() + '…' : trimmed;
|
||||
}
|
||||
|
||||
export const app = new AppState();
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<button
|
||||
class="scrim"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
aria-label="Luk menu"
|
||||
aria-label={t('nav.closeMenu')}
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
@@ -160,9 +160,25 @@
|
||||
had succeeded. Nothing else earns an interruption. -->
|
||||
<div class="toasts" role="status" aria-live="polite">
|
||||
{#each app.toasts as toast (toast.id)}
|
||||
<button class="toast" onclick={() => app.dismissToast(toast.id)}>
|
||||
{toast.message}
|
||||
</button>
|
||||
<div class="toast">
|
||||
<!-- The message dismisses, the action acts. They were one button before,
|
||||
which is fine while the only thing a toast says is "that failed" —
|
||||
and wrong the moment it offers to undo something, because then a
|
||||
click meaning "yes, put it back" and a click meaning "go away" land
|
||||
in the same place. -->
|
||||
<button class="body" onclick={() => app.dismissToast(toast.id)}>
|
||||
{toast.message}
|
||||
</button>
|
||||
{#if toast.action}
|
||||
<button
|
||||
class="action"
|
||||
onclick={() => {
|
||||
app.dismissToast(toast.id);
|
||||
toast.onaction?.();
|
||||
}}>{toast.action}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -304,6 +320,29 @@
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.toast .body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Set apart, because it is the one thing in a toast that does something rather
|
||||
than says something. */
|
||||
.toast .action {
|
||||
flex: none;
|
||||
padding: var(--s1) var(--s2);
|
||||
margin: calc(var(--s1) * -1) calc(var(--s1) * -1) calc(var(--s1) * -1) 0;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 560;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toast .action:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
bottom: var(--s4);
|
||||
@@ -317,8 +356,14 @@
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
/* The red edge said "something went wrong", which was true while that was
|
||||
all a toast could say. An undo is not a failure — it is the app offering
|
||||
to put something back — so the edge follows the message. */
|
||||
border-left: 2px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -329,6 +374,10 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toast:has(.action) {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Below the tablet breakpoint the sidebar becomes a drawer. The layout is
|
||||
mobile-first in behaviour even though the rules read desktop-first: the
|
||||
drawer is the exception, and writing it as one keeps it in a single block. */
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
like finishing, not like something is missing. -->
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Ikke mere i dag.
|
||||
{t('view.nothingMoreToday')}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script>
|
||||
/**
|
||||
* Færdige: everything that has been closed, newest first.
|
||||
*
|
||||
* Completing a task is one click on a small circle beside a row you were only
|
||||
* reading, and it takes the row off the screen — so the mistake and the evidence
|
||||
* of it leave together. Somebody who clicked the wrong one had no name to search
|
||||
* for and nowhere to look: the project page can show its own closed tasks, but
|
||||
* only if you already know which project it was in.
|
||||
*
|
||||
* The toast that appears on completing offers an undo, which covers the mistake
|
||||
* you notice at once. This covers the one you notice tomorrow.
|
||||
*
|
||||
* Newest first, decided by the server. A record of what has been finished is
|
||||
* read backwards from now, not by a due date the task no longer has.
|
||||
*/
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
|
||||
let loaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
app.loadTasks({ completed: 'only', limit: 200 }).then(() => (loaded = true));
|
||||
});
|
||||
|
||||
// Read back out of the store, so a task reopened from here leaves the list at
|
||||
// once rather than on the next load — which is the whole reason to be here.
|
||||
let done = $derived(app.tasks.filter((task) => task.completed));
|
||||
|
||||
/**
|
||||
* Grouped by the day it was closed.
|
||||
*
|
||||
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
|
||||
* how anybody looking for something they just closed would describe it.
|
||||
*/
|
||||
let days = $derived.by(() => {
|
||||
const groups = new Map();
|
||||
for (const task of done) {
|
||||
const key = (task.completed_at ?? '').slice(0, 10);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(task);
|
||||
}
|
||||
return [...groups.entries()].map(([date, tasks]) => ({ date, tasks }));
|
||||
});
|
||||
|
||||
function heading(date) {
|
||||
if (!date) return t('done.unknownDay');
|
||||
const day = new Date(date + 'T00:00:00');
|
||||
const today = new Date(new Date().toDateString());
|
||||
const diff = Math.round((day - today) / 86400000);
|
||||
if (diff === 0) return t('task.today');
|
||||
if (diff === -1) return t('task.yesterday');
|
||||
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
<header>
|
||||
<h1>{t('done.title')}</h1>
|
||||
<p>{t('done.hint')}</p>
|
||||
</header>
|
||||
|
||||
{#each days as day (day.date)}
|
||||
<section>
|
||||
<h2>{heading(day.date)}</h2>
|
||||
{#each day.tasks as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if loaded && !done.length}
|
||||
<p class="empty">{t('done.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: var(--s1) 0 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
padding-bottom: var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
@@ -165,7 +165,7 @@
|
||||
<a href="/projekt/{project.id}">
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
<!-- Counted by the server in the same request. Open tasks, not all
|
||||
of them: "12" beside a project finished last year is a number
|
||||
that means nothing. -->
|
||||
|
||||
@@ -3,12 +3,67 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||
import { supported, register } from '$lib/passkey.js';
|
||||
import { ago } from '$lib/when.js';
|
||||
|
||||
// --- profile ---------------------------------------------------------------
|
||||
|
||||
let name = $state('');
|
||||
let timezone = $state('');
|
||||
let locale = $state('da');
|
||||
|
||||
// --- passkeys -------------------------------------------------------------------
|
||||
|
||||
let passkeys = $state([]);
|
||||
let passkeyName = $state('');
|
||||
let passkeyError = $state('');
|
||||
let registering = $state(false);
|
||||
let passkeySupported = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
passkeySupported = supported();
|
||||
if (!passkeySupported) return;
|
||||
api
|
||||
.listPasskeys()
|
||||
.then((r) => {
|
||||
passkeys = r.passkeys;
|
||||
// The server has the other half of the answer: this browser can do
|
||||
// passkeys, and this deployment may still be on an address no
|
||||
// authenticator will accept.
|
||||
passkeySupported = r.available;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
async function addPasskey(event) {
|
||||
event.preventDefault();
|
||||
passkeyError = '';
|
||||
registering = true;
|
||||
try {
|
||||
passkeys = [...passkeys, await register(passkeyName.trim())];
|
||||
passkeyName = '';
|
||||
} catch (e) {
|
||||
// A cancelled prompt is not a failure — the person changed their mind,
|
||||
// and an error message for that reads as though something broke.
|
||||
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
|
||||
passkeyError = humanMessage(e);
|
||||
}
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removePasskey(key) {
|
||||
if (!confirm(t('passkey.removeQuestion', { name: key.name }))) return;
|
||||
const previous = passkeys;
|
||||
passkeys = passkeys.filter((k) => k.id !== key.id);
|
||||
try {
|
||||
await api.deletePasskey(key.id);
|
||||
} catch (e) {
|
||||
passkeys = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
let profileErrors = $state({});
|
||||
let profileSaved = $state(false);
|
||||
let savingProfile = $state(false);
|
||||
@@ -187,16 +242,6 @@
|
||||
* older than a week gets its date instead: "for 23 dage siden" is a number
|
||||
* nobody can place.
|
||||
*/
|
||||
function ago(iso) {
|
||||
const then = new Date(iso);
|
||||
const seconds = Math.round((Date.now() - then) / 1000);
|
||||
|
||||
if (seconds < 60) return 'lige nu';
|
||||
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
|
||||
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -413,6 +458,47 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
||||
is a way in, and it is a thing on a device you might later want to revoke. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('passkey.title')}</h2>
|
||||
<p class="hint">{t('passkey.hint')}</p>
|
||||
</header>
|
||||
|
||||
{#if !passkeySupported}
|
||||
<p class="hint">{t('passkey.unavailable')}</p>
|
||||
{:else}
|
||||
{#if passkeys.length}
|
||||
<ul class="list">
|
||||
{#each passkeys as key (key.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="primary-line">{key.name}</span>
|
||||
<span class="secondary">
|
||||
{key.user_verified ? t('passkey.bothFactors') : t('passkey.possessionOnly')}
|
||||
·
|
||||
{key.last_used_at ? t('passkey.lastUsed', { when: ago(key.last_used_at) }) : t('passkey.neverUsed')}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => removePasskey(key)}>{t('passkey.remove')}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">{t('passkey.none')}</p>
|
||||
{/if}
|
||||
|
||||
<form class="row" onsubmit={addPasskey}>
|
||||
<input bind:value={passkeyName} placeholder={t('passkey.namePlaceholder')} aria-label={t('passkey.name')} />
|
||||
<button class="primary" type="submit" disabled={registering}>
|
||||
{registering ? t('passkey.registering') : t('passkey.add')}
|
||||
</button>
|
||||
</form>
|
||||
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('account.devices')}</h2>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* audit log must not have.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { eventName, eventDetail } from '$lib/events.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { ago } from '$lib/when.js';
|
||||
@@ -130,7 +130,7 @@
|
||||
<select bind:value={filterProject}>
|
||||
<option value="">{t('history.all')}</option>
|
||||
{#each projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
<option value={project.id}>{projectName(project)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -89,6 +89,55 @@
|
||||
|
||||
let version = $state(null);
|
||||
|
||||
// --- restarting from here ---------------------------------------------------
|
||||
|
||||
let panel = $state(null);
|
||||
let restarting = $state(false);
|
||||
let restartNote = $state('');
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.panelStatus()
|
||||
.then((p) => (panel = p))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
/**
|
||||
* Asks the panel to recreate this container, then waits for it to come back.
|
||||
*
|
||||
* The reply may never arrive — the panel stops this container as part of
|
||||
* answering — so the answer is not believed either way. `/healthz` is polled
|
||||
* until it responds, which is the only thing that actually says "it is up".
|
||||
*/
|
||||
async function restart() {
|
||||
if (!confirm(t('update.restartQuestion'))) return;
|
||||
restarting = true;
|
||||
restartNote = t('update.asking');
|
||||
try {
|
||||
await api.restartFromPanel();
|
||||
} catch (e) {
|
||||
// A request cut off mid-flight is the successful case wearing a
|
||||
// failure's clothes. The poll below decides.
|
||||
restartNote = humanMessage(e);
|
||||
}
|
||||
|
||||
restartNote = t('update.waiting');
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const response = await fetch('/healthz', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Still down, which is expected for the first few seconds.
|
||||
}
|
||||
}
|
||||
restarting = false;
|
||||
restartNote = t('update.stillDown');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
api.version().then((v) => (version = v)).catch(() => {});
|
||||
});
|
||||
@@ -174,14 +223,28 @@
|
||||
<p class="hint">
|
||||
{t('push.running')} <span class="mono">{version.current}</span>.
|
||||
{#if version.disabled}
|
||||
Der bliver ikke set efter opdateringer.
|
||||
{t('push.updatesOff')}
|
||||
{:else if version.update_available}
|
||||
<strong>{version.latest} er ude.</strong>
|
||||
<strong>{t('push.updateOut', { version: version.latest })}</strong>
|
||||
{:else if version.checked_at}
|
||||
Ingen nyere version fundet.
|
||||
{t('push.upToDate')}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Restarting is what makes a new version arrive: a container cannot replace
|
||||
its own image, and the panel pulls `:latest` when it recreates one. The
|
||||
button is here rather than in another browser tab. -->
|
||||
{#if panel?.configured}
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={restart} disabled={restarting}>
|
||||
{restarting ? t('update.restarting') : t('update.restart')}
|
||||
</button>
|
||||
{#if restartNote}<span class="saved">{restartNote}</span>{/if}
|
||||
</div>
|
||||
{:else if panel}
|
||||
<p class="hint">{t('update.notConfigured', { missing: panel.missing.join(', ') })}</p>
|
||||
{/if}
|
||||
|
||||
{#if version.update_available}
|
||||
{#if version.notes}
|
||||
<p class="notes">{version.notes}</p>
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
{#if fresh}
|
||||
<div class="fresh">
|
||||
<p class="hint">
|
||||
<strong>{t('tokens.copyNow')}</strong> Den vises kun denne ene gang — serveren har
|
||||
kun dens hash.
|
||||
<strong>{t('tokens.copyNow')}</strong>
|
||||
{t('tokens.onceOnly')}
|
||||
</p>
|
||||
<p class="mono value">{fresh.token}</p>
|
||||
<div class="row">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, completedView } from '$lib/stores.svelte.js';
|
||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { eventName, eventDetail } from '$lib/events.js';
|
||||
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
||||
@@ -140,6 +140,24 @@
|
||||
|
||||
let editing = $state(false);
|
||||
let choosingColor = $state(false);
|
||||
let showMenu = $state(false);
|
||||
|
||||
// Closes on a click anywhere else and on Escape. A menu that only closes by
|
||||
// pressing its own button again is one you end up clicking twice to leave.
|
||||
$effect(() => {
|
||||
if (!showMenu) return;
|
||||
const away = (event) => {
|
||||
if (!event.target.closest?.('.more')) showMenu = false;
|
||||
};
|
||||
const escape = (event) => event.key === 'Escape' && (showMenu = false);
|
||||
// Capture, so a click that its own handler stops still closes the menu.
|
||||
window.addEventListener('click', away, true);
|
||||
window.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
window.removeEventListener('click', away, true);
|
||||
window.removeEventListener('keydown', escape);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The colour is written to the store as well as here: the sidebar reads its own
|
||||
@@ -197,6 +215,24 @@
|
||||
*/
|
||||
let overSection = $state(null);
|
||||
|
||||
/**
|
||||
* Clears the highlight when the drag ends, however it ends.
|
||||
*
|
||||
* `dragleave` and the section's own `ondrop` are not enough. A drop on a *row*
|
||||
* inside a section is handled by TaskList, which stops propagation — so the
|
||||
* section never sees the drop and its frame stayed lit afterwards. A drag
|
||||
* abandoned with Escape has the same shape: no drop at all, and the frame kept
|
||||
* standing until the next one.
|
||||
*
|
||||
* `dragend` fires exactly once per drag, on the source, and bubbles to the
|
||||
* window — so it is the one place that is true for every way a drag can finish.
|
||||
*/
|
||||
$effect(() => {
|
||||
const clear = () => (overSection = null);
|
||||
window.addEventListener('dragend', clear);
|
||||
return () => window.removeEventListener('dragend', clear);
|
||||
});
|
||||
|
||||
function onSectionDragOver(event, sectionId) {
|
||||
if (!canEdit || !carries(event, TASK)) return;
|
||||
accept(event);
|
||||
@@ -241,6 +277,36 @@
|
||||
let renamingSection = $state(null);
|
||||
let sectionName = $state('');
|
||||
|
||||
/**
|
||||
* A task written straight into a section.
|
||||
*
|
||||
* The quick-add box at the top of the page puts things in the project with no
|
||||
* section, which is right for "capture it now" — but a project with sections has
|
||||
* them because the work belongs somewhere, and dragging every new task down into
|
||||
* the right one is a second gesture for something you already knew when you
|
||||
* typed it.
|
||||
*
|
||||
* It goes through quick add rather than createTask, so a line written here still
|
||||
* parses its dates, priorities and labels. The section is passed rather than
|
||||
* written, so `/Produktion` is not needed when you are already standing in it.
|
||||
*/
|
||||
let addingIn = $state(null);
|
||||
let newInSection = $state('');
|
||||
|
||||
async function addToSection(event, sectionId) {
|
||||
event.preventDefault();
|
||||
const text = newInSection.trim();
|
||||
if (!text) return;
|
||||
newInSection = '';
|
||||
addingIn = null;
|
||||
try {
|
||||
const created = await api.quickAdd(text, project.id, sectionId);
|
||||
app.upsert(created);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
@@ -336,7 +402,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
||||
in CSS: this was `.view:has(.board)` for as long as the board has existed and
|
||||
never once matched, because `.board` is inside a child component and Svelte
|
||||
scopes a selector to the component that wrote it. The rule read correctly,
|
||||
compiled, and could not fire. Kommende has done it this way all along. -->
|
||||
<div class="view" class:wide={mode !== 'list'}>
|
||||
{#if project}
|
||||
<header>
|
||||
<!-- A heading first, a field second. Swapping the h1 out for an input
|
||||
@@ -363,31 +434,13 @@
|
||||
<h1 class:renameable={isOwner}>
|
||||
{#if isOwner}
|
||||
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
</button>
|
||||
{:else}
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
{/if}
|
||||
</h1>
|
||||
{/if}
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button
|
||||
class="color"
|
||||
style="background: {colorVar(project.color)}"
|
||||
onclick={() => (choosingColor = !choosingColor)}
|
||||
aria-expanded={choosingColor}
|
||||
aria-label={t('project.color')}
|
||||
title={t('project.color')}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="ghost"
|
||||
onclick={() => completedView.toggle()}
|
||||
aria-pressed={completedView.shown}
|
||||
>
|
||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||
</button>
|
||||
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]}
|
||||
<button
|
||||
@@ -398,25 +451,94 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button class="share" onclick={() => (showShare = !showShare)}>
|
||||
{project.shared ? t('project.sharedWith', { n: project.member_count }) : t('project.share')}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Everything else lives behind one button.
|
||||
The row had eight controls in it — title, colour, show-done, three view
|
||||
buttons, share, history, delete — and a project name is not a short
|
||||
word. They fitted only on a wide window, and past that the heading was
|
||||
the thing that gave way, which is the one part of the row that has to
|
||||
be readable.
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
|
||||
{t('project.history')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button class="remove" onclick={remove} aria-label={t('project.delete')}>
|
||||
What stays out is what you use while working: which view you are in.
|
||||
What moves in is what you do to a project rather than in it, and most
|
||||
of it once ever. -->
|
||||
<div class="more">
|
||||
<button
|
||||
class="more-toggle"
|
||||
onclick={() => (showMenu = !showMenu)}
|
||||
aria-expanded={showMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('project.more')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
||||
<circle cx="5" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="19" cy="12" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if showMenu}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
completedView.toggle();
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||
</button>
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
showShare = !showShare;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{project.shared
|
||||
? t('project.sharedWith', { n: project.member_count })
|
||||
: t('project.share')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
showLog = !showLog;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{t('project.history')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
choosingColor = !choosingColor;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
<span class="swatch-dot" style="background: {colorVar(project.color)}"></span>
|
||||
{t('project.color')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
class="danger"
|
||||
onclick={() => {
|
||||
showMenu = false;
|
||||
remove();
|
||||
}}
|
||||
>
|
||||
{t('project.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showLog}
|
||||
@@ -427,7 +549,7 @@
|
||||
<!-- Empty when the account has been deleted: the record of what was
|
||||
done outlives whoever did it, so the row stays and the name goes. -->
|
||||
<span class="who" class:gone={!entry.user_name}
|
||||
>{entry.user_name || 'En slettet konto'}</span
|
||||
>{entry.user_name || t('history.deletedAccount')}</span
|
||||
>
|
||||
<span class="what">
|
||||
{eventName(entry.event)}{#if eventDetail(entry)}
|
||||
@@ -475,7 +597,7 @@
|
||||
<select
|
||||
class="role-picker"
|
||||
value={member.role}
|
||||
aria-label="Rolle for {member.name}"
|
||||
aria-label={t('project.roleFor', { name: member.name })}
|
||||
onchange={(e) => setMemberRole(member, e.currentTarget.value)}
|
||||
>
|
||||
<option value="editor">{ROLES.editor}</option>
|
||||
@@ -491,7 +613,7 @@
|
||||
await api.removeMember(project.id, member.user_id);
|
||||
members = (await api.listMembers(project.id)).members;
|
||||
}}
|
||||
aria-label="Fjern {member.name}">×</button
|
||||
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -597,6 +719,29 @@
|
||||
{#if !tasks.length}
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
|
||||
{#if canEdit}
|
||||
{#if addingIn === section.id}
|
||||
<form class="add-task" onsubmit={(e) => addToSection(e, section.id)}>
|
||||
<input
|
||||
bind:value={newInSection}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.addTaskHere')}
|
||||
aria-label={t('view.addTaskIn', { name: section.name })}
|
||||
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add-task-open"
|
||||
onclick={() => {
|
||||
addingIn = section.id;
|
||||
newInSection = '';
|
||||
}}>{t('view.addTask')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -664,22 +809,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* A quiet control: it changes what you are looking at, not what is there. It
|
||||
sits beside the view switcher because it is the same kind of decision. */
|
||||
.ghost {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ghost:hover,
|
||||
.ghost[aria-pressed='true'] {
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Recessed, because it is a record rather than a plan. The rows keep their own
|
||||
completed styling; this is about the section around them. */
|
||||
.done {
|
||||
@@ -692,12 +821,14 @@
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
/* A board and a month grid need the room; a list is easier to read narrow. */
|
||||
.view:has(.board),
|
||||
.view:has(.calendar) {
|
||||
/* A board and a month grid need the room a reading column deliberately
|
||||
withholds. Same allowance Kommende makes. */
|
||||
.view.wide {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* A board and a month grid need the room; a list is easier to read narrow. */
|
||||
|
||||
/* Wraps, and the title keeps a floor.
|
||||
*
|
||||
* Without both, a phone squeezes the heading to its min-content — which with
|
||||
@@ -716,11 +847,15 @@
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
|
||||
point where the wrapping below is the better answer. */
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
overflow-wrap: anywhere;
|
||||
/* Takes the row, and lets the controls keep their own width. With the rest
|
||||
of them behind one button there is room for a real project name. */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
/* `break-word`, not `anywhere`. `anywhere` breaks inside a word at the first
|
||||
opportunity, so "GarageRisteriet" came out as "GarageRist / eriet" the
|
||||
moment the row was tight — a name split mid-syllable reads as a rendering
|
||||
fault. This only breaks a word that cannot fit on a line of its own. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* The heading's button carries no button-ness: it is the heading, and the only
|
||||
@@ -761,6 +896,83 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- the overflow menu ------------------------------------------------------ */
|
||||
|
||||
.more {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.more-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.more-toggle:hover,
|
||||
.more-toggle[aria-expanded='true'] {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.more-toggle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Right-aligned, because the button is at the right end of the row and a menu
|
||||
that opened leftward from there would hang off the page. */
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--s1));
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
min-width: 190px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--s1);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s2) var(--s3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu button:hover {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.menu button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.menu button.danger:hover {
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.swatch-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
@@ -779,34 +991,7 @@
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* A circle in the project's own colour, which is both the control and its own
|
||||
preview: the thing you are about to change is what you press. */
|
||||
.color {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: 0 0 0 2px var(--ground);
|
||||
transition: box-shadow var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.color:hover,
|
||||
.color:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ground),
|
||||
0 0 0 3px var(--line-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
@@ -869,20 +1054,7 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share {
|
||||
flex: none;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
padding: var(--s1) var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-full);
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.share:hover {
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
@@ -1101,19 +1273,39 @@
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
/* A band rather than a rule.
|
||||
*
|
||||
* Small uppercase text over a hairline is a heading you have to look for. A
|
||||
* project with four sections is four groups, and the eye should be able to
|
||||
* count them without reading a word — so the heading gets a ground of its own,
|
||||
* and the rows below sit against the page.
|
||||
*
|
||||
* `--surface`, not a tint of the accent: the colour on this page belongs to the
|
||||
* project, and a section is not a second thing to colour. What is wanted is
|
||||
* separation, and a step in lightness gives that in all five themes without
|
||||
* anybody measuring anything.
|
||||
*/
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s2) var(--s3);
|
||||
margin-bottom: var(--s1);
|
||||
/* Pulled out to the padding of the rows below, so the band lines up with the
|
||||
text it heads rather than with the rows' own inset. */
|
||||
margin-left: calc(var(--s2) * -1);
|
||||
margin-right: calc(var(--s2) * -1);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Hidden until the section is hovered or something in it has focus: a heading
|
||||
@@ -1159,6 +1351,38 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Quiet until wanted. A section that is not being added to should read as a
|
||||
heading and its rows, not as a form. */
|
||||
.add-task-open {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2) 0 var(--s2) var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
section:hover .add-task-open,
|
||||
.add-task-open:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-task-open:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-task input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--accent);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-section {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||