Compare commits
@@ -45,6 +45,62 @@ jobs:
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
spec:
|
||||
name: OpenAPI is valid
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# Structural validity only. That the spec matches the *router* is checked by
|
||||
# a Go test, which is the half that actually goes stale.
|
||||
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
|
||||
|
||||
e2e:
|
||||
name: End-to-end
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install
|
||||
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
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||
# playwright.config.js. There is nothing to build here first.
|
||||
- name: Test
|
||||
working-directory: web
|
||||
run: npm run test:e2e
|
||||
|
||||
- if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Docker image builds
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -6,7 +6,9 @@ on:
|
||||
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# write rather than read: the job creates the GitHub Release the update notice
|
||||
# reads. packages: write is for pushing the image.
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
@@ -50,3 +52,15 @@ jobs:
|
||||
VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# A tag is not a release. The in-app update notice reads GitHub's
|
||||
# releases/latest, which answers 404 for a repository that only has tags —
|
||||
# so without this step the check can never find anything, however correctly
|
||||
# it is switched on.
|
||||
#
|
||||
# Created after the image, so a release never announces a version that
|
||||
# failed to build.
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
|
||||
+12
@@ -31,5 +31,17 @@ node_modules/
|
||||
/web/build/
|
||||
/web/.svelte-kit/
|
||||
|
||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||
# signs in with, and whatever a failure left behind.
|
||||
/web/.playwright-data/
|
||||
/web/e2e/.auth/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
|
||||
# Built documentation
|
||||
/site_build/
|
||||
|
||||
# Vite writes these while bundling vite.config.js and removes them again — unless
|
||||
# the process is killed, which during a hung build is exactly what happens. One
|
||||
# was committed by accident.
|
||||
/web/vite.config.js.timestamp-*.mjs
|
||||
|
||||
@@ -63,6 +63,9 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Sharing: invites, roles, members | Done, tested |
|
||||
| Live sync over WebSocket | Done |
|
||||
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
||||
| Task detail: description, sub-tasks, comments, files, reminders | Done |
|
||||
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
||||
| Drag to reorder, in both the list and the board | Done |
|
||||
| Recurring tasks (RRULE, Danish + English) | Done, tested |
|
||||
| Reminders, absolute and relative | Done, tested |
|
||||
| Saved filters with a query language | Done, tested |
|
||||
@@ -78,13 +81,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||
| Update notice for administrators | Done, tested |
|
||||
| Personal API tokens | Done, tested |
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
|
||||
**Not finished:** Web Push, the AI providers and the Gmail API calls are
|
||||
implemented against their specifications but have only been exercised there, not
|
||||
against live services. And the interface does not yet expose comments,
|
||||
attachments, reminders, templates, import/export or the settings pages — all of it
|
||||
works through the API.
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
not against live services. Everything else has an interface: opening a task shows
|
||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||
templates.
|
||||
|
||||
Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an
|
||||
accented `o` and a Danish-first app that cannot do this is broken for its own users.
|
||||
|
||||
@@ -77,7 +77,11 @@ func run() error {
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("listening", "addr", cfg.Addr)
|
||||
// The version is repeated here rather than only on the "starting" line
|
||||
// above: this is the line the Rune's done_regex matches, so it is the one
|
||||
// sitting at the bottom of the panel console after a deploy — which is
|
||||
// exactly when somebody wants to know what actually started.
|
||||
log.Info("listening", "addr", cfg.Addr, "version", version)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
|
||||
+30
@@ -19,6 +19,16 @@ curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks
|
||||
Make one under **Settings → API tokens**. It carries your permissions exactly —
|
||||
there is no separate service identity.
|
||||
|
||||
## The machine-readable spec
|
||||
|
||||
[`openapi.yaml`](openapi.yaml) describes the same API as an OpenAPI 3.1 document —
|
||||
for generating a client, for importing into Bruno or Insomnia, or for reading in a
|
||||
tool that renders it.
|
||||
|
||||
It is written by hand, and a test walks the router on every build and fails if an
|
||||
endpoint is routed but not described, or described but no longer routed. So it is
|
||||
as current as this page is, which is to say: as current as CI is green.
|
||||
|
||||
## Errors
|
||||
|
||||
Every failure has the same shape:
|
||||
@@ -42,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
|
||||
| `forbidden` | 403 | Cross-site request, or admin needed. |
|
||||
| `not_found` | 404 | Does not exist, or you cannot see it. |
|
||||
| `conflict` | 409 | Already exists, or the state does not allow it. |
|
||||
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
|
||||
| `ai_not_configured` | 409 | No AI provider has been set up. |
|
||||
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
|
||||
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
|
||||
| `validation_failed` | 422 | Check `fields`. |
|
||||
| `rate_limited` | 429 | Too many attempts. |
|
||||
| `internal_error` | 500 | Our fault. |
|
||||
@@ -118,6 +132,22 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| `GET` | `/export/projects/{id}.csv` | Todoist-compatible. |
|
||||
| `GET` | `/export/projects/{id}.ics` | A calendar file. |
|
||||
|
||||
### API tokens
|
||||
|
||||
| Method | Path | |
|
||||
|---|---|---|
|
||||
| `GET` `POST` | `/tokens` | List, or mint one. `name` is required; `expires_in_days` is optional, and `0` means it never expires. |
|
||||
| `DELETE` | `/tokens/{id}` | Revoked immediately. |
|
||||
|
||||
The plaintext token comes back from `POST` and never again — only its hash is
|
||||
stored. The listing shows a `prefix`, which is enough to tell two of your own
|
||||
tokens apart.
|
||||
|
||||
!!! warning "These three need a signed-in session"
|
||||
A bearer token is refused here with `403`, even though it is accepted
|
||||
everywhere else. Otherwise a leaked token would be permanent: whoever had it
|
||||
could issue a second one, and revoking the first would change nothing.
|
||||
|
||||
### Everything else
|
||||
|
||||
| Method | Path | |
|
||||
|
||||
+27
-4
@@ -12,17 +12,40 @@ conversation.
|
||||
|
||||
## Connecting it
|
||||
|
||||
**Make a token.** **Settings → API tokens → New token**. It is shown once.
|
||||
**Make a token** under **Settings → API tokens**. The page then shows the
|
||||
connector address to go with it — token and all — which is the thing to copy:
|
||||
|
||||
**Add the connector.** In Claude, add a custom connector with:
|
||||
```
|
||||
https://todo.example.dk/mcp?key=vrd_…
|
||||
```
|
||||
|
||||
- **URL**: `https://todo.example.dk/api/v1/mcp`
|
||||
- **Authentication**: Bearer token, using the token you just made
|
||||
**Add the connector** in Claude with that address, and nothing else. The dialog
|
||||
asks for a name and a URL; leave the OAuth fields empty.
|
||||
|
||||
!!! note "Why the key is in the address"
|
||||
The dialog has no field for a bearer token — only OAuth client credentials —
|
||||
so a header cannot be configured from it. The address *is* the credential
|
||||
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
|
||||
Treat it like a password: anyone holding it reaches what you reach. The
|
||||
server logs request paths without their query strings, so it does not end up
|
||||
in the log.
|
||||
|
||||
!!! warning "It has to be reachable from the internet"
|
||||
Claude connects from Anthropic's side, not from your browser. An instance only
|
||||
reachable on your LAN or over a VPN cannot be used this way.
|
||||
|
||||
### With a header instead
|
||||
|
||||
Anything that *can* send a header should, and use the API path:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer vrd_…" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
||||
https://todo.example.dk/api/v1/mcp
|
||||
```
|
||||
|
||||
Both endpoints run the same server and reach the same things.
|
||||
|
||||
## What Claude can do
|
||||
|
||||
| Tool | What it does |
|
||||
|
||||
+2282
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,54 @@
|
||||
verdande ships with a Rune manifest for
|
||||
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
|
||||
|
||||
## If the image will not pull
|
||||
|
||||
```
|
||||
create container: Error response from daemon:
|
||||
No such image: ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
This message is a liar. It does not mean the tag is missing from the registry —
|
||||
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||
without saying so.
|
||||
|
||||
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||
looks locally — then reports the image as missing.
|
||||
|
||||
!!! warning "`docker login` on the host does not help"
|
||||
`docker login` is client-side. The daemon stores no credentials; the CLI
|
||||
sends them itself with each request. Yggdrasil is a different client and
|
||||
sends none, so logging in changes nothing for the panel.
|
||||
|
||||
**Make the package public.** Under
|
||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||
readable, which is what the manifest and these pages have always assumed.
|
||||
|
||||
Once it is public, press **Start** on the server you already have. There is no
|
||||
need to delete it and make another: the image reference is read from the rune at
|
||||
container-create time, and both Start and Restart recreate the container.
|
||||
|
||||
To keep the package private you have to put the image on the host yourself,
|
||||
before creating the server, and again after every release:
|
||||
|
||||
```bash
|
||||
sudo docker login ghcr.io -u kristianwind
|
||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||
panel's own pull still fails silently; create simply finds the image already
|
||||
there.
|
||||
|
||||
!!! danger "This hides updates"
|
||||
The panel re-pulls on every recreate, fails quietly, and carries on with the
|
||||
local image. A new release then *looks* like it rolled out and did not. For
|
||||
something you intend to update, public is the right answer and host-pull is
|
||||
the stopgap.
|
||||
|
||||
## Installing
|
||||
|
||||
1. **Runes → Carve a rune (upload)** and pick
|
||||
@@ -12,6 +60,16 @@ verdande ships with a Rune manifest for
|
||||
3. Fill in the settings below.
|
||||
4. Start it, open the URL, create the first account.
|
||||
|
||||
!!! note "Updating the rune is a manual step"
|
||||
A rune does not update itself when this manifest changes. The panel writes
|
||||
the rune row from a UI action only — neither a restart nor a timer does it —
|
||||
so pick up a new manifest with **Runes → verdande → Update**, or install it
|
||||
again from Browse GitHub.
|
||||
|
||||
The catalogue list is cached in memory for ten minutes, so two attempts in a
|
||||
row can both hand you the old version. Recent panel versions show the list's
|
||||
age with a *fetch again* link.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | What to put |
|
||||
|
||||
@@ -5,6 +5,13 @@ cache, no queue and no second process.
|
||||
|
||||
## Docker
|
||||
|
||||
!!! note "If the pull is refused"
|
||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||
back to private, `docker login ghcr.io` with a token carrying the
|
||||
`read:packages` scope before pulling — and see
|
||||
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||
fails differently and much more confusingly under Yggdrasil.
|
||||
|
||||
```bash
|
||||
docker run -d --name verdande \
|
||||
--restart unless-stopped \
|
||||
|
||||
@@ -156,6 +156,72 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, toUserJSON(userFrom(r.Context())))
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Timezone *string `json:"timezone"`
|
||||
Locale *string `json:"locale"`
|
||||
}
|
||||
|
||||
// handleUpdateProfile changes name, timezone and language.
|
||||
//
|
||||
// The timezone is not cosmetic: every date in the app is resolved in it, so "i
|
||||
// morgen kl 9" means a different instant after this call than before. Which is
|
||||
// exactly why it has to be changeable — an account created from an invite
|
||||
// inherits a default, and somebody in another country would otherwise have every
|
||||
// due date land an hour out with no way to say so.
|
||||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var req updateProfileRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
name, timezone, locale := user.Name, user.Timezone, user.Locale
|
||||
fields := map[string]string{}
|
||||
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
fields["name"] = "required"
|
||||
} else if utf8.RuneCountInString(name) > 80 {
|
||||
fields["name"] = "must be at most 80 characters"
|
||||
}
|
||||
}
|
||||
if req.Timezone != nil {
|
||||
timezone = strings.TrimSpace(*req.Timezone)
|
||||
// Checked against the running system's zone database rather than a list
|
||||
// kept here: a hardcoded list goes stale every time a country moves its
|
||||
// clocks, and this one is the same database the dates are resolved with.
|
||||
if _, err := time.LoadLocation(timezone); err != nil || timezone == "" {
|
||||
fields["timezone"] = "must be a zone like Europe/Copenhagen"
|
||||
}
|
||||
}
|
||||
if req.Locale != nil {
|
||||
locale = strings.TrimSpace(*req.Locale)
|
||||
// The locale picks which quick-add grammar parses a line, so an unknown
|
||||
// one would quietly stop "i morgen" being understood.
|
||||
if locale != "da" && locale != "en" {
|
||||
fields["locale"] = "must be da or en"
|
||||
}
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
|
||||
s.storeError(w, "update profile", err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.db.UserByID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "reload user", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
||||
}
|
||||
|
||||
// --- creating accounts --------------------------------------------------------
|
||||
|
||||
type signupRequest struct {
|
||||
|
||||
@@ -686,3 +686,78 @@ func mustHash(t *testing.T, password string) string {
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// A database that cannot be read must not look like a failed login.
|
||||
//
|
||||
// authenticate() cannot tell the two apart on its own: both come back as an error
|
||||
// from the session lookup. Answering 401 for both means a disk problem presents as
|
||||
// every session ending at once — the frontend clears its state and shows the
|
||||
// sign-in screen to everybody — and the logs record a wave of failed logins rather
|
||||
// than the fault. Found by the end-to-end suite, where a deleted database file
|
||||
// turned into exactly that.
|
||||
func TestDatabaseFailureIsNotALogout(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Still signed in, and the session cookie is in the jar.
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("me: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Pull the database out from under the server. Every query now fails with
|
||||
// something that is not "no such session".
|
||||
if err := ts.db.Close(); err != nil {
|
||||
t.Fatalf("close db: %v", err)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
t.Fatal("a broken database signed the user out instead of reporting a fault")
|
||||
}
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("status %d, want 500", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeInternal {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same contract: no cookie at all is still a plain 401.
|
||||
func TestMissingCredentialsAreStillUnauthorized(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
resp, body := anonymous.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("status %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeUnauthorized {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// And a cookie that is not a session is a 401 too, not a 500 — an unknown token is
|
||||
// a wrong credential, which is the client's problem and not the server's.
|
||||
func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.AddCookie(&http.Cookie{Name: "verdande_session", Value: "ikke-en-rigtig-token"})
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("status %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The comment above the code list in errors.go says they are kept in one place
|
||||
// "so the frontend's mapping can be checked against it rather than discovered
|
||||
// from whatever a handler happened to write". Nothing checked it, and the
|
||||
// frontend was quietly missing entries — which is not a cosmetic gap: a code
|
||||
// with no message falls through to the server's English log prose, and a code
|
||||
// mapped to the wrong sentence tells the person something untrue. Connecting
|
||||
// Gmail without an OAuth client said "Det er der allerede."
|
||||
|
||||
var (
|
||||
codeConst = regexp.MustCompile(`(?m)^\s*Code\w+\s*=\s*"([a-z_]+)"`)
|
||||
messageKey = regexp.MustCompile(`(?m)^\s*([a-z_]+):\s*$|^\s*([a-z_]+):\s*'`)
|
||||
messagesTop = "const MESSAGES = {"
|
||||
)
|
||||
|
||||
func TestEveryErrorCodeHasAMessageInTheFrontend(t *testing.T) {
|
||||
codes := serverErrorCodes(t)
|
||||
messages := frontendMessageKeys(t)
|
||||
|
||||
var missing []string
|
||||
for _, code := range codes {
|
||||
if !messages[code] {
|
||||
missing = append(missing, code)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
for _, code := range missing {
|
||||
t.Errorf("%q is returned by the API and has no message in web/src/lib/api.js — "+
|
||||
"a person would be shown the server's English log prose", code)
|
||||
}
|
||||
}
|
||||
|
||||
func serverErrorCodes(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile("errors.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read errors.go: %v", err)
|
||||
}
|
||||
var codes []string
|
||||
for _, m := range codeConst.FindAllStringSubmatch(string(source), -1) {
|
||||
codes = append(codes, m[1])
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
t.Fatal("no codes were read out of errors.go — the scanner and the file disagree")
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// frontendMessageKeys reads the keys of the MESSAGES object. A small scanner
|
||||
// rather than a JS parser, for the same reason the OpenAPI check uses one: it
|
||||
// needs one thing out of a file this test also owns.
|
||||
func frontendMessageKeys(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile(filepath.Join("..", "..", "web", "src", "lib", "api.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("read api.js: %v", err)
|
||||
}
|
||||
body := string(source)
|
||||
|
||||
start := strings.Index(body, messagesTop)
|
||||
if start < 0 {
|
||||
t.Fatalf("could not find %q in api.js", messagesTop)
|
||||
}
|
||||
body = body[start+len(messagesTop):]
|
||||
end := strings.Index(body, "\n};")
|
||||
if end < 0 {
|
||||
t.Fatal("the MESSAGES object does not end where this scanner expects")
|
||||
}
|
||||
|
||||
keys := map[string]bool{}
|
||||
for _, line := range strings.Split(body[:end], "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
name, _, ok := strings.Cut(line, ":")
|
||||
if ok {
|
||||
keys[strings.TrimSpace(name)] = true
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("no message keys were read out of api.js")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// The four that used to be indistinguishable from "that already exists".
|
||||
func TestTheMisleadingConflictsHaveTheirOwnCodes(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
CodeGmailNotConfigured,
|
||||
CodeAINotConfigured,
|
||||
CodeTOTPNotEnabled,
|
||||
CodeInboxProtected,
|
||||
} {
|
||||
if code == CodeConflict {
|
||||
t.Errorf("%q is still plain conflict", code)
|
||||
}
|
||||
}
|
||||
|
||||
messages := frontendMessageKeys(t)
|
||||
for _, code := range []string{"gmail_not_configured", "ai_not_configured",
|
||||
"totp_not_enabled", "inbox_protected"} {
|
||||
if !messages[code] {
|
||||
t.Errorf("%q has no Danish message", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Inbox is the one project that cannot be deleted, and saying "that already
|
||||
// exists" about it helps nobody.
|
||||
func TestDeletingTheInboxSaysWhy(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
list, _ := projects["projects"].([]any)
|
||||
|
||||
var inboxID string
|
||||
for _, raw := range list {
|
||||
p := raw.(map[string]any)
|
||||
if p["is_inbox"] == true {
|
||||
inboxID = p["id"].(string)
|
||||
}
|
||||
}
|
||||
if inboxID == "" {
|
||||
t.Fatal("no inbox was found")
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "DELETE", "/api/v1/projects/"+inboxID, nil)
|
||||
if resp.StatusCode != 409 {
|
||||
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeInboxProtected {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeInboxProtected)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,17 @@ const (
|
||||
CodeRateLimited = "rate_limited"
|
||||
CodeInternal = "internal_error"
|
||||
CodePayloadTooLarge = "payload_too_large"
|
||||
|
||||
// Four situations that used to answer plain `conflict`, which the frontend
|
||||
// renders as "that already exists". For these that is not merely vague, it is
|
||||
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
|
||||
// second factor that is off is not one that is on. A code exists so the
|
||||
// interface can say the true thing — the server's own prose is English for a
|
||||
// log, and deciding what a person reads is the frontend's job.
|
||||
CodeGmailNotConfigured = "gmail_not_configured"
|
||||
CodeAINotConfigured = "ai_not_configured"
|
||||
CodeTOTPNotEnabled = "totp_not_enabled"
|
||||
CodeInboxProtected = "inbox_protected"
|
||||
)
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
|
||||
@@ -30,8 +30,8 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.gmailConfig()
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"Gmail er ikke sat op på denne server — administratoren skal registrere en OAuth-klient hos Google")
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
"no Google OAuth client is registered on this server")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
|
||||
type mailAddressResponse struct {
|
||||
Address string `json:"address"`
|
||||
// Configured says whether a mail server has been set up at all. Without one
|
||||
// the address is still minted and still valid — the token is real, and the
|
||||
// endpoint that receives mail works — but nothing on the internet routes to
|
||||
// it yet, and an interface that shows the address without saying so is
|
||||
// promising something that will not happen.
|
||||
Configured bool `json:"configured"`
|
||||
}
|
||||
|
||||
// handleGetMailAddress returns the personal address that turns an email into a
|
||||
@@ -34,7 +40,10 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, "mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
Address: s.mailAddress(token),
|
||||
Configured: s.cfg.SMTP.Host != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -47,7 +56,10 @@ func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request)
|
||||
s.internal(w, "set mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
Address: s.mailAddress(token),
|
||||
Configured: s.cfg.SMTP.Host != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) mailAddress(token string) string {
|
||||
@@ -55,7 +67,15 @@ func (s *Server) mailAddress(token string) string {
|
||||
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
|
||||
// The mail server's own domain, which is the one that can actually route
|
||||
// this — the web address may well be a tunnel hostname that receives no mail.
|
||||
domain = from[strings.Index(from, "@")+1:]
|
||||
//
|
||||
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
|
||||
// verdande@localhost, so an instance with no mail server at all was
|
||||
// handing out todo+…@localhost: an address that looks real, cannot
|
||||
// possibly receive anything, and gives no hint which of the two it is.
|
||||
// The public hostname is at least a domain that exists.
|
||||
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
|
||||
domain = d
|
||||
}
|
||||
}
|
||||
return "todo+" + token + "@" + domain
|
||||
}
|
||||
@@ -251,8 +271,8 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
// Not an error the user did anything to cause: the feature is simply off.
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||
"no AI provider is configured")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,8 +319,8 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||
"no AI provider is configured")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -771,3 +771,161 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
|
||||
t.Error("the current version is hidden from members")
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP with the key in the URL --------------------------------------------------
|
||||
|
||||
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
|
||||
// token — so this is the only shape that can be configured from it.
|
||||
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var userID string
|
||||
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ts.apiToken(t, userID)
|
||||
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
t.Fatalf("the response was not JSON-RPC: %v", err)
|
||||
}
|
||||
result, _ := decoded["result"].(map[string]any)
|
||||
if result == nil || result["protocolVersion"] == nil {
|
||||
t.Errorf("no protocol version was announced: %v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
|
||||
// SPA fallback and answered 200 with the app shell. A connector pointed at it
|
||||
// reported success and then failed to parse a page of HTML.
|
||||
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A revoked key stops working, like any other.
|
||||
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var userID string
|
||||
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ts.apiToken(t, userID)
|
||||
|
||||
var id string
|
||||
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The session cookie must NOT work here. This route sits outside the CSRF check,
|
||||
// so a cookie-authenticated POST would be a cross-site request that acts as
|
||||
// whoever is signed in — the exact hole the key-only rule exists to close.
|
||||
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// ts.client holds the session from bootstrap. No key in the URL.
|
||||
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
|
||||
resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- mail to task ------------------------------------------------------------------
|
||||
|
||||
// The address used to come out as todo+…@localhost on any instance without a
|
||||
// mail server, because VERDANDE_SMTP_FROM defaults to verdande@localhost and the
|
||||
// domain was taken from it unconditionally. That address looks real, cannot
|
||||
// receive anything, and gives no hint which of the two it is.
|
||||
func TestMailAddressDoesNotClaimToBeLocalhost(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://todo.example.dk"
|
||||
// Left at the default, as an instance with no mail server has it.
|
||||
c.SMTP.From = "verdande@localhost"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
address, _ := body["address"].(string)
|
||||
|
||||
if strings.HasSuffix(address, "@localhost") {
|
||||
t.Errorf("address = %q — that can never receive mail", address)
|
||||
}
|
||||
if !strings.HasSuffix(address, "@todo.example.dk") {
|
||||
t.Errorf("address = %q, want the public hostname", address)
|
||||
}
|
||||
if body["configured"] != false {
|
||||
t.Error("no SMTP host is set, so configured should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// A real sender address still wins: the mail server's own domain is the one that
|
||||
// can actually route this, and the web address may be a tunnel hostname.
|
||||
func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://tunnel-abc123.example.net"
|
||||
c.SMTP.Host = "mail.firma.dk"
|
||||
c.SMTP.From = "[email protected]"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
address, _ := body["address"].(string)
|
||||
|
||||
if !strings.HasSuffix(address, "@firma.dk") {
|
||||
t.Errorf("address = %q, want the sender's domain", address)
|
||||
}
|
||||
if body["configured"] != true {
|
||||
t.Error("an SMTP host is set, so configured should be true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/mcp"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
@@ -124,6 +126,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleMCPWithKey is the same endpoint for a client that can only be given a
|
||||
// URL: `POST /mcp?key=vrd_…`.
|
||||
//
|
||||
// Claude's custom-connector dialog asks for a name, an address and — under
|
||||
// advanced settings — an OAuth client id and secret. There is no field for a
|
||||
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
|
||||
// address is. The calendar feed already carries its credential in the URL for
|
||||
// the same reason, and the request logger records the path without the query, so
|
||||
// the key does not end up in the log.
|
||||
//
|
||||
// The key is the *only* thing accepted here. Reading a session cookie instead
|
||||
// would make this a state-changing POST outside the CSRF check — which is to say
|
||||
// a cross-site request that acts as whoever is signed in.
|
||||
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" || !auth.IsAPIToken(key) {
|
||||
// 401 rather than 404: unlike a task, the existence of this endpoint is
|
||||
// not a secret — it is written in the documentation.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"add ?key=<api token> to the URL")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "mcp key", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
|
||||
}
|
||||
|
||||
// --- tool implementations ------------------------------------------------------------
|
||||
|
||||
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
|
||||
@@ -78,6 +78,14 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, err := s.authenticate(r)
|
||||
if err != nil {
|
||||
// A database that could not be read is not a person who is not signed
|
||||
// in. Answering 401 for both means an infrastructure problem shows up
|
||||
// as everybody being logged out at once — the frontend clears its state
|
||||
// and shows the sign-in screen — and nothing in the logs says why.
|
||||
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "authenticate", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
@@ -123,6 +131,11 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
||||
// Both are accepted everywhere. An API token belongs to a person and carries their
|
||||
// permissions exactly — there is no separate service identity to reason about, and
|
||||
// a script therefore cannot reach anything its owner could not.
|
||||
// errNoCredentials means the request carried nothing to authenticate with, as
|
||||
// opposed to carrying something that turned out to be wrong — or to the lookup
|
||||
// itself failing, which callers must not treat as a failed login.
|
||||
var errNoCredentials = errors.New("no credentials")
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, error) {
|
||||
if token := bearerToken(r); token != "" && auth.IsAPIToken(token) {
|
||||
user, err := s.db.UserByAPIToken(r.Context(), token)
|
||||
@@ -134,7 +147,7 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err
|
||||
|
||||
cookie, err := r.Cookie(s.cookieName())
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, nil, errors.New("no credentials")
|
||||
return nil, nil, errNoCredentials
|
||||
}
|
||||
session, user, err := s.db.SessionByToken(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// docs/openapi.yaml is written by hand, which means it can drift from the router
|
||||
// the moment somebody adds an endpoint. This walks the real router and compares
|
||||
// the two in both directions: a route with nothing written about it, and a path
|
||||
// documented that no longer exists.
|
||||
//
|
||||
// The spec is read with a small scanner rather than a YAML library. It needs two
|
||||
// things — which paths are listed and which methods each one has — and both live
|
||||
// at fixed indentation in a document this file also owns. That is worth more than
|
||||
// a dependency in a project whose whole shape is "one static binary".
|
||||
|
||||
// specRoutes reads "method path" pairs out of the `paths:` section.
|
||||
func specRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Open(filepath.Join("..", "..", "docs", "openapi.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("open spec: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
methods := map[string]bool{
|
||||
"get": true, "post": true, "put": true, "patch": true, "delete": true,
|
||||
"head": true, "options": true, "trace": true,
|
||||
}
|
||||
|
||||
routes := map[string]bool{}
|
||||
inPaths := false
|
||||
path := ""
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// A top-level key ends the paths section.
|
||||
if !strings.HasPrefix(line, " ") {
|
||||
inPaths = strings.HasPrefix(line, "paths:")
|
||||
continue
|
||||
}
|
||||
if !inPaths {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
// Two spaces: a path.
|
||||
case strings.HasPrefix(line, " /"):
|
||||
path = strings.TrimSuffix(strings.TrimSpace(line), ":")
|
||||
// Four spaces: a key under it, which is a method or `parameters`.
|
||||
case path != "" && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " "):
|
||||
key := strings.TrimSuffix(strings.TrimSpace(line), ":")
|
||||
if methods[key] {
|
||||
routes[strings.ToUpper(key)+" "+path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
t.Fatal("no paths were read out of the spec — the scanner and the file disagree")
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
// undocumented is what the spec is not expected to describe.
|
||||
func undocumented(method, path string) bool {
|
||||
switch {
|
||||
// The SPA fallback and static assets: not an API.
|
||||
case path == "/*", path == "/":
|
||||
return true
|
||||
// CalDAV speaks PROPFIND and REPORT, which OpenAPI has no way to describe.
|
||||
// The discovery URL is in the spec; the rest is documented in docs/caldav.md.
|
||||
case strings.HasPrefix(path, "/caldav"):
|
||||
return true
|
||||
// chi registers OPTIONS and HEAD of its own accord in places; neither carries
|
||||
// behaviour worth writing down.
|
||||
case method == http.MethodOptions || method == http.MethodHead:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestOpenAPICoversEveryRoute(t *testing.T) {
|
||||
spec := specRoutes(t)
|
||||
|
||||
// The server itself rather than the test harness's httptest wrapper: this
|
||||
// needs the router, and nothing here sends a request.
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
srv := New(&config.Config{
|
||||
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(),
|
||||
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
routed := map[string]bool{}
|
||||
err = chi.Walk(srv.router,
|
||||
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
// chi writes the index of a sub-router as a trailing slash; the spec
|
||||
// names it without one.
|
||||
if route != "/" {
|
||||
route = strings.TrimSuffix(route, "/")
|
||||
}
|
||||
if !undocumented(method, route) {
|
||||
routed[method+" "+route] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
|
||||
var missing, stale []string
|
||||
for route := range routed {
|
||||
if !spec[route] {
|
||||
missing = append(missing, route)
|
||||
}
|
||||
}
|
||||
for route := range spec {
|
||||
if !routed[route] {
|
||||
stale = append(stale, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
|
||||
for _, route := range missing {
|
||||
t.Errorf("routed but not in docs/openapi.yaml: %s", route)
|
||||
}
|
||||
for _, route := range stale {
|
||||
t.Errorf("in docs/openapi.yaml but not routed: %s", route)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
|
||||
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "the Inbox cannot be deleted")
|
||||
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "delete project", err)
|
||||
|
||||
@@ -90,6 +90,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// authenticated by the token in the recipient address.
|
||||
r.Post("/inbound/mail", s.handleInboundMail)
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
||||
// it at all. Same reasoning as the calendar feed above: a client that cannot
|
||||
// be told to send a header means the token in the URL is the credential.
|
||||
//
|
||||
// Outside /api/v1 deliberately, because it must not be reachable with a
|
||||
// session cookie. A cookie-authenticated POST with no CSRF check is a
|
||||
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
|
||||
// nothing else.
|
||||
r.Post("/mcp", s.handleMCPWithKey)
|
||||
|
||||
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
||||
// top-level navigation rather than a fetch, but still behind the session — the
|
||||
// tokens it exchanges belong to whoever is signed in.
|
||||
@@ -131,6 +143,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/me", s.handleMe)
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
@@ -190,6 +203,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/mail-address", s.handleGetMailAddress)
|
||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||
|
||||
// Personal API tokens. Behind requireSession rather than the ambient
|
||||
// auth: a token must not be able to mint or revoke another.
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/", s.handleListAPITokens)
|
||||
r.Post("/", s.handleCreateAPIToken)
|
||||
r.Delete("/{tokenID}", s.handleDeleteAPIToken)
|
||||
})
|
||||
|
||||
r.Route("/ai", func(r chi.Router) {
|
||||
r.Get("/settings", s.handleGetAISettings)
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
@@ -326,7 +348,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// The version travels with the health check because this is the one endpoint
|
||||
// that answers without a session. "Which version is actually running?" is
|
||||
// otherwise a question you cannot ask from outside — and after a deploy it is
|
||||
// the first question there is.
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": Version})
|
||||
}
|
||||
|
||||
// serveWeb serves the PWA, falling back to index.html so that client-side routes
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Personal API tokens: what a script, a CalDAV client or an MCP connector
|
||||
// authenticates with. The store has held them since the first migration; this is
|
||||
// the surface that lets somebody actually make one.
|
||||
//
|
||||
// Every route here needs a *session*, not merely an authenticated caller — see
|
||||
// requireSession. Managing credentials is something a person does in a browser.
|
||||
|
||||
type apiTokenJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Prefix is the first few characters, which is all that is ever shown again.
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// createdTokenJSON is the only response that carries the token itself, and only
|
||||
// the once. The store keeps a hash, so there is nothing to reveal later even if
|
||||
// the interface offered to.
|
||||
type createdTokenJSON struct {
|
||||
apiTokenJSON
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list api tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]apiTokenJSON, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
j := apiTokenJSON{
|
||||
ID: t.ID, Name: t.Name, Prefix: t.Prefix,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if t.LastUsedAt != nil {
|
||||
j.LastUsedAt = t.LastUsedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t.ExpiresAt != nil {
|
||||
j.ExpiresAt = t.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
|
||||
}
|
||||
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
// ExpiresInDays is optional; 0 means the token does not expire, which is what
|
||||
// a calendar client subscribed for years actually needs.
|
||||
ExpiresInDays int `json:"expires_in_days"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req createTokenRequest
|
||||
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 len([]rune(name)) > 80 {
|
||||
writeFieldErrors(w, map[string]string{"name": "must be at most 80 characters"})
|
||||
return
|
||||
}
|
||||
// A negative lifetime would mint a token that is already dead — accepted
|
||||
// silently it looks like the feature is broken rather than the input.
|
||||
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
|
||||
writeFieldErrors(w, map[string]string{
|
||||
"expires_in_days": "must be between 0 and 3650, where 0 means no expiry",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var expires *time.Time
|
||||
if req.ExpiresInDays > 0 {
|
||||
at := time.Now().UTC().AddDate(0, 0, req.ExpiresInDays)
|
||||
expires = &at
|
||||
}
|
||||
|
||||
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
|
||||
if err != nil {
|
||||
s.internal(w, "create api token", err)
|
||||
return
|
||||
}
|
||||
|
||||
j := createdTokenJSON{
|
||||
apiTokenJSON: apiTokenJSON{
|
||||
ID: token.ID, Name: token.Name, Prefix: token.Prefix,
|
||||
CreatedAt: token.CreatedAt.Format(time.RFC3339),
|
||||
},
|
||||
Token: plaintext,
|
||||
}
|
||||
if expires != nil {
|
||||
j.ExpiresAt = expires.Format(time.RFC3339)
|
||||
}
|
||||
s.log.Info("api token created", "user", userFrom(r.Context()).ID, "name", name)
|
||||
writeJSON(w, http.StatusCreated, j)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete api token", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// requireSession refuses a caller who authenticated with an API token.
|
||||
//
|
||||
// authenticate() accepts a bearer token everywhere else on purpose: a token
|
||||
// carries its owner's permissions exactly. Minting tokens is the one thing that
|
||||
// must not follow that rule, because it is how a single leaked token becomes
|
||||
// permanent access — the thief issues a second one and revoking the first
|
||||
// changes nothing. Deleting is held to the same standard so a token cannot
|
||||
// revoke the session-holder's other credentials either.
|
||||
func (s *Server) requireSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if sessionFrom(r.Context()) == nil {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden,
|
||||
"API tokens are managed while signed in, not with an API token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPITokenLifecycle(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/tokens", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if tokens, _ := body["tokens"].([]any); len(tokens) != 0 {
|
||||
t.Errorf("a fresh account already has tokens: %v", tokens)
|
||||
}
|
||||
|
||||
resp, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{
|
||||
"name": "min server", "expires_in_days": 30,
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: status %d, body %v", resp.StatusCode, created)
|
||||
}
|
||||
|
||||
plaintext, _ := created["token"].(string)
|
||||
if !strings.HasPrefix(plaintext, "vrd_") {
|
||||
t.Fatalf("token %q does not carry the scanner-visible prefix", plaintext)
|
||||
}
|
||||
if created["expires_at"] == nil {
|
||||
t.Error("an expiry was asked for and none came back")
|
||||
}
|
||||
prefix, _ := created["prefix"].(string)
|
||||
if !strings.HasPrefix(plaintext, prefix) {
|
||||
t.Errorf("prefix %q is not the start of the token", prefix)
|
||||
}
|
||||
|
||||
// The token authenticates as its owner.
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
me, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("bearer request: %v", err)
|
||||
}
|
||||
defer me.Body.Close()
|
||||
if me.StatusCode != http.StatusOK {
|
||||
t.Errorf("the new token does not authenticate: status %d", me.StatusCode)
|
||||
}
|
||||
|
||||
// Listing shows it, without the secret.
|
||||
_, body = ts.do(t, "GET", "/api/v1/tokens", nil)
|
||||
tokens, _ := body["tokens"].([]any)
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("want one token listed, got %v", tokens)
|
||||
}
|
||||
listed := tokens[0].(map[string]any)
|
||||
if _, leaked := listed["token"]; leaked {
|
||||
t.Error("the listing carries the token itself")
|
||||
}
|
||||
if listed["name"] != "min server" {
|
||||
t.Errorf("name = %v", listed["name"])
|
||||
}
|
||||
|
||||
id, _ := created["id"].(string)
|
||||
resp, _ = ts.do(t, "DELETE", "/api/v1/tokens/"+id, nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And is dead the moment it is deleted.
|
||||
req, _ = http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
me, err = (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("bearer request: %v", err)
|
||||
}
|
||||
defer me.Body.Close()
|
||||
if me.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a deleted token still works: status %d", me.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A token must not be able to mint another one. Otherwise a leaked token is
|
||||
// permanent: revoking it leaves behind the one it issued.
|
||||
func TestAPITokenCannotMintAnother(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "første"})
|
||||
plaintext, _ := created["token"].(string)
|
||||
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/tokens"},
|
||||
{"POST", "/api/v1/tokens"},
|
||||
{"DELETE", "/api/v1/tokens/" + created["id"].(string)},
|
||||
} {
|
||||
req, err := http.NewRequest(tc.method, ts.URL+tc.path, strings.NewReader(`{"name":"anden"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s with a bearer token: status %d, want 403",
|
||||
tc.method, tc.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenValidation(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
field string
|
||||
}{
|
||||
{"no name", map[string]any{"name": " "}, "name"},
|
||||
{"negative lifetime", map[string]any{"name": "x", "expires_in_days": -1}, "expires_in_days"},
|
||||
{"absurd lifetime", map[string]any{"name": "x", "expires_in_days": 4000}, "expires_in_days"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tokens", tc.body)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, want 422", resp.StatusCode)
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields[tc.field] == nil {
|
||||
t.Errorf("no error on %q: %v", tc.field, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's token id must delete nothing, and must not confirm it exists.
|
||||
func TestAPITokenDeleteIsScopedToOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "min"})
|
||||
id := created["id"].(string)
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
resp, _ := other.do(t, "DELETE", "/api/v1/tokens/"+id, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404 for a token belonging to somebody else", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Still there for its owner.
|
||||
_, body := ts.do(t, "GET", "/api/v1/tokens", nil)
|
||||
if tokens, _ := body["tokens"].([]any); len(tokens) != 1 {
|
||||
t.Errorf("the owner's token was affected: %v", tokens)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
writeError(w, http.StatusConflict, CodeTOTPNotEnabled,
|
||||
"two-factor authentication is not switched on")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@ func Open(path string) (*DB, error) {
|
||||
"_pragma=busy_timeout(5000)",
|
||||
"_pragma=foreign_keys(ON)",
|
||||
"_pragma=synchronous(NORMAL)",
|
||||
// Every write transaction takes the write lock up front.
|
||||
//
|
||||
// Without this, `BEGIN` is deferred: a transaction starts as a reader and
|
||||
// asks for the write lock at its first UPDATE. SQLite refuses that upgrade
|
||||
// with SQLITE_BUSY *immediately* and does not apply busy_timeout to it —
|
||||
// backing off cannot help, because the transaction is already holding a
|
||||
// read snapshot that the other writer is invalidating. So the timeout above
|
||||
// looks like it covers writer contention and covers none of it: two people
|
||||
// saving at the same moment get a 500 in two milliseconds.
|
||||
//
|
||||
// `immediate` takes the lock at BEGIN instead, where busy_timeout does
|
||||
// apply and the second writer waits its turn. The driver leaves read-only
|
||||
// transactions deferred, so readers still run concurrently.
|
||||
"_txlock=immediate",
|
||||
// Keep the temp b-trees FTS5 and ORDER BY build in memory rather than
|
||||
// spilling them into the data volume.
|
||||
"_pragma=temp_store(MEMORY)",
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -95,6 +96,51 @@ func TestPragmasAreSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two people saving at the same moment must both succeed.
|
||||
//
|
||||
// This is a regression test for a specific and badly-behaved failure: with the
|
||||
// default deferred BEGIN, a write transaction starts as a reader and asks for the
|
||||
// write lock partway through. SQLite refuses that upgrade with SQLITE_BUSY at once
|
||||
// and does *not* apply busy_timeout to it — so the contention surfaced as a 500
|
||||
// two milliseconds after the request arrived, while every single-threaded test
|
||||
// passed. `_txlock=immediate` is what makes the timeout actually cover writers.
|
||||
func TestConcurrentWritersDoNotHitSQLiteBusy(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
seedTaskFixtures(t, db)
|
||||
|
||||
const writers = 8
|
||||
errs := make(chan error, writers)
|
||||
|
||||
start := make(chan struct{})
|
||||
for i := range writers {
|
||||
go func(i int) {
|
||||
<-start // all of them ask for the write lock at once
|
||||
errs <- db.CreateTask(ctx, &Task{
|
||||
ProjectID: "p1",
|
||||
Content: fmt.Sprintf("opgave %d", i),
|
||||
CreatedBy: "u1",
|
||||
}, nil)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
|
||||
for range writers {
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("concurrent write failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM tasks WHERE project_id = 'p1'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != writers {
|
||||
t.Errorf("wrote %d tasks, want %d", n, writers)
|
||||
}
|
||||
}
|
||||
|
||||
// The FTS index is kept in step with `tasks` by triggers rather than by application
|
||||
// code, so a task written by any path at all is searchable. That contract is worth
|
||||
// a test of its own.
|
||||
|
||||
@@ -146,6 +146,24 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfile changes the things a person may change about themselves.
|
||||
//
|
||||
// The email address is not among them: it identifies the account, it is what
|
||||
// invites were sent to, and changing it is a re-verification flow rather than a
|
||||
// field. Neither is is_admin — an account cannot promote itself.
|
||||
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
||||
name, timezone, locale, time.Now().Unix(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||
var value any
|
||||
if secret != "" {
|
||||
|
||||
@@ -56,6 +56,8 @@ nav:
|
||||
- Mail to task: mail.md
|
||||
- Claude (MCP): mcp.md
|
||||
- AI features: ai.md
|
||||
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
|
||||
# file, and api.md links to it. A nav entry expects a page to render.
|
||||
- Reference:
|
||||
- API: api.md
|
||||
|
||||
|
||||
+9
-1
@@ -11,7 +11,7 @@ gameskill:
|
||||
invites and reminders, then start it and open the URL to create the first
|
||||
admin account.
|
||||
author: "kristianwind"
|
||||
version: 1
|
||||
version: 2
|
||||
icon: "app"
|
||||
|
||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||
@@ -70,6 +70,14 @@ gameskill:
|
||||
pattern: '^[0-9]+(ns|us|ms|s|m|h)$'
|
||||
hint: "must be a duration like 720h, 30m or 45s"
|
||||
|
||||
# Off by default because it is an outbound request nobody asked for. It sends
|
||||
# no identifier and reads one public URL — GitHub's releases API — but that is
|
||||
# a choice to make rather than a default to inherit.
|
||||
- key: VERDANDE_UPDATE_CHECK
|
||||
name: "Look for new releases (asks GitHub once a day; sends nothing about you)"
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
install:
|
||||
# The image has no shell, so the install step borrows one. It only has to make
|
||||
# the directory layout exist; verdande creates and migrates its own database on
|
||||
|
||||
@@ -108,6 +108,10 @@ func run(base, cookie, out string, width, height int) error {
|
||||
{name: "today", path: "/", theme: "dark", wait: 2500 * time.Millisecond},
|
||||
{name: "today-light", path: "/", theme: "light", wait: 1500 * time.Millisecond},
|
||||
{name: "upcoming", path: "/upcoming", theme: "dark", wait: 2000 * time.Millisecond},
|
||||
// The integrations page rather than the account one: it shows the feed
|
||||
// address and the mail-to-task address, which are the parts people ask
|
||||
// what they look like. Account settings are a password form.
|
||||
{name: "settings", path: "/indstillinger/integrationer", theme: "dark", wait: 2000 * time.Millisecond},
|
||||
}
|
||||
if project := os.Getenv("SHOT_PROJECT"); project != "" {
|
||||
shots = append(shots,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Creates the first account and saves the session for every other test.
|
||||
*
|
||||
* This is also the sign-in smoke test: if the bootstrap form, the session cookie
|
||||
* or the app shell is broken, nothing below runs and the failure names this file.
|
||||
*/
|
||||
|
||||
export const USER = {
|
||||
name: 'Kristian',
|
||||
email: '[email protected]',
|
||||
password: 'et langt kodeord til test'
|
||||
};
|
||||
|
||||
const AUTH_FILE = 'e2e/.auth/user.json';
|
||||
|
||||
setup('opret den første konto og log ind', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Opret den første konto')).toBeVisible();
|
||||
|
||||
// Regexes, not exact strings: the password label carries a hint beside it, so
|
||||
// its accessible name is the label plus that sentence.
|
||||
await page.getByLabel(/Navn/).fill(USER.name);
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
// The shell, not just a redirect: a 200 that renders nothing would pass a
|
||||
// URL assertion and fail a person.
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
await page.context().storageState({ path: AUTH_FILE });
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The four flows a broken build must not survive: add a task, tick it off, reach
|
||||
* every page in the sidebar, and open a task.
|
||||
*
|
||||
* Every test asserts on *rendered* content rather than on a URL. A route that no
|
||||
* longer exists still answers 200 here — the Go binary serves the SPA shell for
|
||||
* anything it does not recognise — so `expect(page).toHaveURL()` would pass on
|
||||
* precisely the bug this suite exists to catch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||
* or an API call that came back a failure.
|
||||
*
|
||||
* Failed responses are recorded with their URL and status. "A console error
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||
// so it is noise next to the response listener below.
|
||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||
trouble.push(message.text());
|
||||
}
|
||||
});
|
||||
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()}`);
|
||||
}
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
|
||||
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
// "i dag" rather than "i morgen": this is the Today view, and a task parsed
|
||||
// onto tomorrow is correctly absent from it — which would look like the quick
|
||||
// add had failed.
|
||||
await box.fill('køb kaffe i dag p1');
|
||||
await box.press('Enter');
|
||||
|
||||
// The server parses the line, so this asserts the whole round trip: "i dag"
|
||||
// and "p1" are gone from the title because they became a date and a priority.
|
||||
const task = page.getByText('køb kaffe', { exact: true });
|
||||
await expect(task).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Markér som færdig' }).first().click();
|
||||
await expect(task).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
|
||||
// Wait for one link before counting them. `all()` is a snapshot with no
|
||||
// auto-waiting — it returns whatever matches at that instant — so counting
|
||||
// straight after goto() measures how fast the machine is. It passed on a
|
||||
// laptop and found zero links on a CI runner.
|
||||
await expect(sidebar.getByRole('link').first()).toBeVisible();
|
||||
|
||||
const hrefs = await sidebar
|
||||
.getByRole('link')
|
||||
.evaluateAll((links) => links.map((a) => a.getAttribute('href')));
|
||||
expect(hrefs.length).toBeGreaterThanOrEqual(4); // I dag, Kommende, Indbakke, Indstillinger
|
||||
|
||||
// Re-resolved by href on each pass rather than held as handles: navigating
|
||||
// re-renders the sidebar, and a handle from before the click is a handle to
|
||||
// an element that no longer exists.
|
||||
for (const href of hrefs) {
|
||||
await sidebar.locator(`a[href="${href}"]`).first().click();
|
||||
|
||||
// Something with a heading rendered. A route written outside the routes
|
||||
// tree lands on the SPA shell and renders nothing — which is exactly how
|
||||
// the label route broke, and it answered 200 the whole time.
|
||||
await expect(
|
||||
page.locator('main h1, main h2').first(),
|
||||
`${href} renderede ingenting`
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
// Dated, so it lands in this view: an undated task goes to the Inbox and is
|
||||
// correctly absent here.
|
||||
await box.fill('skriv rapport i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
await page.getByText('skriv rapport', { exact: true }).click();
|
||||
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// A sub-task, a comment and a description at once — which is also the shape
|
||||
// that used to fail: three concurrent writes met SQLite's deferred BEGIN and
|
||||
// one of them came back a 500.
|
||||
await drawer.getByLabel('Beskrivelse').fill('Inden fredag.');
|
||||
await drawer.getByLabel('Ny undertask').fill('lav dispositionen');
|
||||
await drawer.getByLabel('Ny undertask').press('Enter');
|
||||
await drawer.getByLabel('Ny kommentar').fill('Anders har materialet.');
|
||||
await drawer.getByRole('button', { name: 'Skriv' }).click();
|
||||
|
||||
await expect(drawer.getByText('lav dispositionen')).toBeVisible();
|
||||
await expect(drawer.getByText('Anders har materialet.')).toBeVisible();
|
||||
|
||||
// Nothing rolled back: a toast here means the server refused a write.
|
||||
await expect(page.locator('.toast')).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(drawer).toBeHidden();
|
||||
|
||||
// The description reached the row behind the drawer, so it was really saved.
|
||||
await expect(page.getByText('Inden fredag.')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
const tabs = page.getByRole('navigation', { name: 'Indstillinger' });
|
||||
await expect(tabs).toBeVisible();
|
||||
|
||||
for (const label of [
|
||||
'Konto',
|
||||
'Notifikationer',
|
||||
'Integrationer',
|
||||
'AI',
|
||||
'API-tokens',
|
||||
'Data og skabeloner'
|
||||
]) {
|
||||
await tabs.getByRole('link', { name: label, exact: true }).click();
|
||||
await expect(
|
||||
page.locator('section.panel').first(),
|
||||
`fanen ${label} renderede ingen sektion`
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The PWA's own files. Both of the bugs found last in this project were of this
|
||||
* kind — a manifest promising icons that were never generated — and they are
|
||||
* invisible to every other test in the repository.
|
||||
*/
|
||||
test('manifestet og service workeren findes, og ikonerne findes også', async ({ request }) => {
|
||||
const manifestResponse = await request.get('/manifest.webmanifest');
|
||||
expect(manifestResponse.ok()).toBeTruthy();
|
||||
|
||||
const manifest = await manifestResponse.json();
|
||||
for (const icon of manifest.icons ?? []) {
|
||||
const iconResponse = await request.get(icon.src);
|
||||
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
|
||||
}
|
||||
|
||||
// The service worker is what Web Push needs; without it a subscription cannot
|
||||
// be created at all.
|
||||
const worker = await request.get('/sw.js');
|
||||
expect(worker.ok()).toBeTruthy();
|
||||
expect(await worker.text()).toContain("addEventListener('push'");
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
* It did: `/mcp` was not a route, so the SPA fallback answered 200 with a page
|
||||
* of HTML. A connector pointed at it reported a successful connection and then
|
||||
* failed to parse the response — which looks like a broken client rather than a
|
||||
* missing route. A 401 in JSON is the honest answer.
|
||||
*/
|
||||
test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
|
||||
const response = await request.post('/mcp', {
|
||||
data: { jsonrpc: '2.0', id: 1, method: 'initialize' }
|
||||
});
|
||||
|
||||
expect(response.status(), 'uden nøgle skal den afvise, ikke servere siden').toBe(401);
|
||||
expect(response.headers()['content-type']).toContain('application/json');
|
||||
expect((await response.json()).code).toBe('unauthorized');
|
||||
});
|
||||
Generated
+64
@@ -8,6 +8,7 @@
|
||||
"name": "verdande-web",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
@@ -477,6 +478,22 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1304,6 +1321,53 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
|
||||
+3
-1
@@ -7,9 +7,11 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./jsconfig.json"
|
||||
"check": "svelte-check --tsconfig ./jsconfig.json",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
/**
|
||||
* End-to-end smoke tests.
|
||||
*
|
||||
* These drive the *whole* application — the Go binary with the frontend embedded
|
||||
* in it — rather than the dev server. That is deliberate: the two bugs that
|
||||
* survived longest in this project were a route written outside the routes tree
|
||||
* and a manifest promising files that were never generated. Neither is visible to
|
||||
* a Go test, and neither is visible to a Vite build either. Only something that
|
||||
* opens the real binary and clicks the real links finds them.
|
||||
*
|
||||
* `go run` rather than a prebuilt binary: the build cache makes the second run
|
||||
* fast, and there is no separate build step to forget.
|
||||
*/
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repo = join(here, '..');
|
||||
const dataDir = join(here, '.playwright-data');
|
||||
|
||||
// A clean instance every run: the first test creates the first account, which only
|
||||
// works on an empty database.
|
||||
//
|
||||
// Only in the main process. Playwright re-imports this file in every worker, and a
|
||||
// wipe there deletes the database out from under the running server — which does
|
||||
// not fail loudly. Connections already open keep working against the unlinked
|
||||
// inode, so most requests still succeed and only the ones that need a *new*
|
||||
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
|
||||
if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// One worker, and not parallel: there is one server, one database and one
|
||||
// account. Parallelism here would only test whether the tests race.
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
reporter: process.env.CI ? [['github'], ['list']] : 'list',
|
||||
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
// Pinned, because the app reads both. The sign-up form sends
|
||||
// `navigator.language` as the account's locale — which decides whether
|
||||
// "i morgen" or "tomorrow" parses — and every due date is resolved in the
|
||||
// account's zone. On a CI runner in UTC with an en-US browser, an
|
||||
// unpinned suite tests a different application than the one people use.
|
||||
locale: 'da-DK',
|
||||
timezoneId: 'Europe/Copenhagen'
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.js/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
}
|
||||
],
|
||||
|
||||
webServer: {
|
||||
// The whole chain, every run.
|
||||
//
|
||||
// The frontend is embedded in the binary at compile time, from a copy under
|
||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||
// test whatever frontend was copied there last, which during development is
|
||||
// nearly always the wrong one. Testing a stale build is worse than not
|
||||
// testing: it reports green on code that is not running.
|
||||
command: [
|
||||
'npm run build',
|
||||
'rm -rf ../cmd/verdande/webbuild',
|
||||
'cp -r build ../cmd/verdande/webbuild',
|
||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||
].join(' && '),
|
||||
cwd: here,
|
||||
url: `${baseURL}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
// The first run compiles the binary and builds nothing else; a cold Go
|
||||
// build on a CI runner is comfortably under two minutes.
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
VERDANDE_BASE_URL: baseURL,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
}
|
||||
}
|
||||
});
|
||||
+107
-1
@@ -34,9 +34,29 @@ const MESSAGES = {
|
||||
validation_failed: 'Tjek felterne herunder.',
|
||||
payload_too_large: 'Det er for stort.',
|
||||
internal_error: 'Noget gik galt. Prøv igen.',
|
||||
offline: 'Ingen forbindelse til serveren.'
|
||||
bad_request: 'Anmodningen kunne ikke læses.',
|
||||
// Client-only: a network failure is not an HTTP status, so the server has no
|
||||
// code for it.
|
||||
offline: 'Ingen forbindelse til serveren.',
|
||||
|
||||
// Situations that used to arrive as a plain `conflict` and were therefore
|
||||
// shown as "Det er der allerede." — which was not merely vague but wrong. An
|
||||
// unconfigured Gmail is not a Gmail that is already connected.
|
||||
gmail_not_configured:
|
||||
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
|
||||
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
|
||||
totp_not_enabled: 'To-faktor er ikke slået til.',
|
||||
inbox_protected: 'Indbakken kan ikke slettes.'
|
||||
};
|
||||
|
||||
/**
|
||||
* What to show a person for a failed request.
|
||||
*
|
||||
* A code with no message here falls back to the server's prose, which is English
|
||||
* and written for a log — not good, but better than swallowing the only
|
||||
* explanation there is. A code that turns up in that fallback is a code this
|
||||
* table is missing.
|
||||
*/
|
||||
export function humanMessage(err) {
|
||||
if (!(err instanceof ApiError)) return MESSAGES.offline;
|
||||
return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error;
|
||||
@@ -90,8 +110,45 @@ async function request(method, path, body, options = {}) {
|
||||
const get = (path, options) => request('GET', path, undefined, options);
|
||||
const post = (path, body, options) => request('POST', path, body ?? {}, options);
|
||||
const patch = (path, body) => request('PATCH', path, body);
|
||||
const put = (path, body) => request('PUT', path, body);
|
||||
const del = (path) => request('DELETE', path);
|
||||
|
||||
/**
|
||||
* Uploads a file. Not routed through `request`, which serialises JSON and sets a
|
||||
* Content-Type — a multipart body has to set its own boundary, and the browser
|
||||
* only does that when nothing has claimed the header.
|
||||
*/
|
||||
async function upload(path, file, field = 'file') {
|
||||
const form = new FormData();
|
||||
form.append(field, file);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
|
||||
} catch {
|
||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
payload?.code ?? 'internal_error',
|
||||
payload?.error ?? res.statusText,
|
||||
payload?.fields
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// --- auth
|
||||
setupState: () => get('/auth/setup'),
|
||||
@@ -100,6 +157,7 @@ export const api = {
|
||||
loginTOTP: (code) => post('/auth/login/totp', { code }),
|
||||
logout: () => post('/auth/logout'),
|
||||
me: () => get('/auth/me'),
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
|
||||
@@ -175,6 +233,54 @@ export const api = {
|
||||
useTemplate: (id, data) => post(`/templates/${id}/use`, data),
|
||||
deleteTemplate: (id) => del(`/templates/${id}`),
|
||||
|
||||
// --- comments and attachments
|
||||
listComments: (taskId) => get(`/tasks/${taskId}/comments`),
|
||||
createComment: (taskId, body) => post(`/tasks/${taskId}/comments`, { body }),
|
||||
updateComment: (id, body) => patch(`/comments/${id}`, { body }),
|
||||
deleteComment: (id) => del(`/comments/${id}`),
|
||||
|
||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||
|
||||
// --- API tokens
|
||||
listTokens: () => get('/tokens'),
|
||||
createToken: (name, expiresInDays) =>
|
||||
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
||||
deleteToken: (id) => del(`/tokens/${id}`),
|
||||
|
||||
// --- notifications and push
|
||||
listNotifications: () => get('/notifications'),
|
||||
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
|
||||
|
||||
pushKey: () => get('/push/key'),
|
||||
subscribePush: (subscription) => post('/push/subscribe', subscription),
|
||||
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
|
||||
|
||||
// --- integrations
|
||||
getMailAddress: () => get('/mail-address'),
|
||||
rotateMailAddress: () => post('/mail-address/rotate'),
|
||||
|
||||
getGmail: () => get('/gmail'),
|
||||
setGmail: (data) => put('/gmail', data),
|
||||
disconnectGmail: () => del('/gmail'),
|
||||
authorizeGmail: () => post('/gmail/authorize'),
|
||||
syncGmail: () => post('/gmail/sync'),
|
||||
|
||||
getAISettings: () => get('/ai/settings'),
|
||||
setAISettings: (data) => put('/ai/settings', data),
|
||||
aiSummary: () => post('/ai/summary'),
|
||||
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
|
||||
|
||||
version: () => get('/version'),
|
||||
|
||||
// --- import and export
|
||||
importTodoist: (file) => upload('/import/todoist', file),
|
||||
importCSV: (data) => post('/import/csv', data),
|
||||
exportAccountURL: () => '/api/v1/export/account',
|
||||
exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`,
|
||||
exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`,
|
||||
|
||||
// --- views
|
||||
today: () => get('/today'),
|
||||
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
|
||||
|
||||
@@ -51,30 +51,29 @@
|
||||
draggingId = null;
|
||||
if (!id || !canEdit) return;
|
||||
|
||||
const task = app.tasks.find((t) => t.id === id);
|
||||
const task = app.get(id);
|
||||
if (!task || (task.section_id ?? '') === sectionId) return;
|
||||
|
||||
// Optimistic: the card is in the new column before the request goes out,
|
||||
// because a card that snaps back for 200ms reads as a failed drop.
|
||||
const previous = { ...task };
|
||||
const index = app.tasks.findIndex((t) => t.id === id);
|
||||
app.tasks[index] = { ...task, section_id: sectionId };
|
||||
app.replace(id, { ...task, section_id: sectionId });
|
||||
|
||||
try {
|
||||
// Dropped at the end of the column: the task after it is nothing, and
|
||||
// the one before it is whatever was last there.
|
||||
const existing = tasksIn(sectionId).filter((t) => t.id !== id);
|
||||
const after = existing.at(-1);
|
||||
const updated = await api.moveTask(id, {
|
||||
project_id: project.id,
|
||||
section_id: sectionId,
|
||||
after_id: after?.id ?? ''
|
||||
});
|
||||
const i = app.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) app.tasks[i] = updated;
|
||||
} catch (e) {
|
||||
const i = app.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) app.tasks[i] = previous;
|
||||
app.replace(
|
||||
id,
|
||||
await api.moveTask(id, {
|
||||
project_id: project.id,
|
||||
section_id: sectionId,
|
||||
after_id: after?.id ?? ''
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
app.replace(id, previous);
|
||||
app.toast('Kunne ikke flytte opgaven.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,21 @@
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
<a
|
||||
href="/indstillinger"
|
||||
class="settings"
|
||||
class:active={current.startsWith('/indstillinger')}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
Indstillinger
|
||||
</a>
|
||||
|
||||
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
|
||||
normal, and the app keeps working without it. -->
|
||||
{#if !app.connected}
|
||||
@@ -310,6 +325,17 @@
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.settings svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.offline {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
<script>
|
||||
/**
|
||||
* One task, opened.
|
||||
*
|
||||
* A drawer rather than a page: the list you came from stays visible behind it,
|
||||
* so ticking a sub-task off and closing lands you back where you were rather
|
||||
* than at the top of a re-fetched list.
|
||||
*
|
||||
* Text fields save on blur, not on every keystroke. A PATCH per character would
|
||||
* be forty requests for one sentence, and the last one to arrive is not
|
||||
* necessarily the last one sent.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
let content = $state('');
|
||||
let description = $state('');
|
||||
let priority = $state(4);
|
||||
let dueDate = $state('');
|
||||
let labels = $state('');
|
||||
|
||||
let subtasks = $state([]);
|
||||
let comments = $state([]);
|
||||
let attachments = $state([]);
|
||||
let reminders = $state([]);
|
||||
|
||||
let newSubtask = $state('');
|
||||
let newComment = $state('');
|
||||
let newReminder = $state('');
|
||||
let posting = $state(false);
|
||||
let uploading = $state(false);
|
||||
|
||||
// Re-seeded whenever a different task is opened. Keyed on the id rather than on
|
||||
// the object, so a websocket update to the same task does not throw away what
|
||||
// somebody is typing.
|
||||
let seededId = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!task || seededId === task.id) return;
|
||||
seededId = task.id;
|
||||
|
||||
content = task.content;
|
||||
description = task.description ?? '';
|
||||
priority = task.priority;
|
||||
dueDate = task.due_date ?? '';
|
||||
labels = (task.labels ?? []).join(', ');
|
||||
|
||||
subtasks = [];
|
||||
comments = [];
|
||||
attachments = [];
|
||||
reminders = [];
|
||||
|
||||
const id = task.id;
|
||||
api.listTasks({ parent_id: id, completed: 'include' })
|
||||
.then((r) => id === seededId && (subtasks = r.tasks))
|
||||
.catch(() => {});
|
||||
api.listComments(id)
|
||||
.then((r) => {
|
||||
if (id !== seededId) return;
|
||||
comments = r.comments;
|
||||
attachments = r.attachments ?? [];
|
||||
})
|
||||
.catch(() => {});
|
||||
api.listReminders(id)
|
||||
.then((r) => id === seededId && (reminders = r.reminders))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
/** Sends a patch through the store, so the row behind the drawer updates too. */
|
||||
async function save(patch) {
|
||||
await app.update(task.id, patch);
|
||||
}
|
||||
|
||||
function saveContent() {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || trimmed === task.content) {
|
||||
content = task.content;
|
||||
return;
|
||||
}
|
||||
save({ content: trimmed });
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
if (description === (task.description ?? '')) return;
|
||||
save({ description });
|
||||
}
|
||||
|
||||
function saveLabels() {
|
||||
const list = labels
|
||||
.split(',')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (list.join() === (task.labels ?? []).join()) return;
|
||||
save({ labels: list });
|
||||
}
|
||||
|
||||
// --- sub-tasks ----------------------------------------------------------------
|
||||
|
||||
async function addSubtask(event) {
|
||||
event.preventDefault();
|
||||
const text = newSubtask.trim();
|
||||
if (!text) return;
|
||||
newSubtask = '';
|
||||
try {
|
||||
const created = await api.createTask({
|
||||
content: text,
|
||||
project_id: task.project_id,
|
||||
parent_id: task.id
|
||||
});
|
||||
subtasks = [...subtasks, created];
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSubtask(subtask) {
|
||||
const previous = subtasks;
|
||||
subtasks = subtasks.map((s) =>
|
||||
s.id === subtask.id ? { ...s, completed: !s.completed } : s
|
||||
);
|
||||
try {
|
||||
const updated = subtask.completed
|
||||
? await api.reopenTask(subtask.id)
|
||||
: await api.completeTask(subtask.id);
|
||||
subtasks = subtasks.map((s) => (s.id === updated.id ? updated : s));
|
||||
} catch (e) {
|
||||
subtasks = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSubtask(subtask) {
|
||||
const previous = subtasks;
|
||||
subtasks = subtasks.filter((s) => s.id !== subtask.id);
|
||||
try {
|
||||
await api.deleteTask(subtask.id);
|
||||
} catch (e) {
|
||||
subtasks = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- comments and files ---------------------------------------------------------
|
||||
|
||||
async function addComment(event) {
|
||||
event.preventDefault();
|
||||
const body = newComment.trim();
|
||||
if (!body) return;
|
||||
posting = true;
|
||||
try {
|
||||
const created = await api.createComment(task.id, body);
|
||||
comments = [...comments, created];
|
||||
newComment = '';
|
||||
} catch (e) {
|
||||
app.toast(e.fields?.body ?? humanMessage(e));
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeComment(comment) {
|
||||
if (!confirm('Slet kommentaren?')) return;
|
||||
const previous = comments;
|
||||
comments = comments.filter((c) => c.id !== comment.id);
|
||||
try {
|
||||
await api.deleteComment(comment.id);
|
||||
} catch (e) {
|
||||
comments = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
event.target.value = '';
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
attachments = [...attachments, await api.uploadAttachment(task.id, file)];
|
||||
} catch (e) {
|
||||
app.toast(e.fields?.file ?? humanMessage(e));
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAttachment(attachment) {
|
||||
const previous = attachments;
|
||||
attachments = attachments.filter((a) => a.id !== attachment.id);
|
||||
try {
|
||||
await api.deleteAttachment(attachment.id);
|
||||
} catch (e) {
|
||||
attachments = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminders --------------------------------------------------------------------
|
||||
|
||||
async function addReminder(event) {
|
||||
event.preventDefault();
|
||||
if (!newReminder) return;
|
||||
try {
|
||||
// The input gives local wall-clock time; the API wants an instant.
|
||||
const created = await api.createReminder(task.id, {
|
||||
remind_at: new Date(newReminder).toISOString()
|
||||
});
|
||||
reminders = [...reminders, created];
|
||||
newReminder = '';
|
||||
} catch (e) {
|
||||
app.toast(e.fields?.remind_at ?? humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeReminder(reminder) {
|
||||
const previous = reminders;
|
||||
reminders = reminders.filter((r) => r.id !== reminder.id);
|
||||
try {
|
||||
await api.deleteReminder(reminder.id);
|
||||
} catch (e) {
|
||||
reminders = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
if (event.key === 'Escape') onclose?.();
|
||||
}
|
||||
|
||||
const kb = (bytes) =>
|
||||
bytes < 1024 * 1024
|
||||
? `${Math.max(1, Math.round(bytes / 1024))} kB`
|
||||
: `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
|
||||
const stamp = (iso) =>
|
||||
new Date(iso).toLocaleString('da-DK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="scrim" onclick={onclose} role="presentation"></div>
|
||||
|
||||
<aside class="drawer" aria-label="Opgave">
|
||||
<header>
|
||||
<button class="close" onclick={onclose} aria-label="Luk">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="field">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
class="title"
|
||||
bind:value={content}
|
||||
onblur={saveContent}
|
||||
aria-label="Opgavens tekst"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">Beskrivelse</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows="4"
|
||||
bind:value={description}
|
||||
onblur={saveDescription}
|
||||
placeholder="Detaljer, links, hvad der skal huskes"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="priority">Prioritet</label>
|
||||
<select
|
||||
id="priority"
|
||||
bind:value={priority}
|
||||
onchange={() => save({ priority: Number(priority) })}
|
||||
>
|
||||
<option value={1}>P1 — haster</option>
|
||||
<option value={2}>P2</option>
|
||||
<option value={3}>P3</option>
|
||||
<option value={4}>Ingen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="due">Forfalder</label>
|
||||
<input
|
||||
id="due"
|
||||
type="date"
|
||||
bind:value={dueDate}
|
||||
onchange={() => save({ due_date: dueDate })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="labels">Etiketter</label>
|
||||
<input
|
||||
id="labels"
|
||||
bind:value={labels}
|
||||
onblur={saveLabels}
|
||||
placeholder="adskilt af komma"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if task.recurrence_text}
|
||||
<p class="repeats">Gentages {task.recurrence_text}.</p>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3>Undertasks</h3>
|
||||
|
||||
{#each subtasks as subtask (subtask.id)}
|
||||
<div class="subtask" class:done={subtask.completed}>
|
||||
<button
|
||||
class="check"
|
||||
class:checked={subtask.completed}
|
||||
onclick={() => toggleSubtask(subtask)}
|
||||
aria-label={subtask.completed ? 'Genåbn' : 'Markér som færdig'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 12.5l4 4 8-8.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="subtask-text">{subtask.content}</span>
|
||||
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label="Slet">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form onsubmit={addSubtask}>
|
||||
<input bind:value={newSubtask} placeholder="Tilføj en undertask" aria-label="Ny undertask" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Filer</h3>
|
||||
|
||||
{#each attachments as attachment (attachment.id)}
|
||||
<div class="file">
|
||||
<!-- Always a download, never rendered inline: an uploaded SVG shown
|
||||
in place would run its own script on this origin. -->
|
||||
<a href={attachment.url} download={attachment.filename}>{attachment.filename}</a>
|
||||
<span class="size">{kb(attachment.size)}</span>
|
||||
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label="Slet">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<label class="upload">
|
||||
<input type="file" onchange={upload} disabled={uploading} />
|
||||
<span>{uploading ? 'Lægger op …' : 'Vedhæft en fil'}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Påmindelser</h3>
|
||||
|
||||
{#each reminders as reminder (reminder.id)}
|
||||
<div class="reminder">
|
||||
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
|
||||
{#if reminder.sent}<span class="sent">sendt</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label="Slet">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form onsubmit={addReminder}>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={newReminder}
|
||||
aria-label="Ny påmindelse"
|
||||
/>
|
||||
<button class="add" type="submit">Tilføj</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Kommentarer</h3>
|
||||
|
||||
{#each comments as comment (comment.id)}
|
||||
<article class="comment">
|
||||
<span class="avatar" style="background: {comment.user_color}">
|
||||
{comment.user_name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<div class="said">
|
||||
<span class="who">
|
||||
{comment.user_name}
|
||||
<span class="when">{stamp(comment.created_at)}</span>
|
||||
</span>
|
||||
<p>{comment.body}</p>
|
||||
{#each comment.attachments ?? [] as attachment (attachment.id)}
|
||||
<a class="inline-file" href={attachment.url} download={attachment.filename}>
|
||||
{attachment.filename}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if comment.user_id === app.user?.id}
|
||||
<button class="remove" onclick={() => removeComment(comment)} aria-label="Slet">
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
<form onsubmit={addComment}>
|
||||
<textarea
|
||||
rows="2"
|
||||
bind:value={newComment}
|
||||
placeholder="Skriv en kommentar"
|
||||
aria-label="Ny kommentar"
|
||||
></textarea>
|
||||
<button class="add" type="submit" disabled={posting}>Skriv</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
z-index: 70;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(480px, 100vw);
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--ground);
|
||||
border-left: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slide var(--medium) var(--ease-out);
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
from {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--s3) var(--s4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.close svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.75;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--s4) var(--s4) var(--s8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
padding-bottom: max(var(--s8), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s3);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The title is the one field that should not look like a field until you are in
|
||||
it — it is a heading that happens to be editable. */
|
||||
.title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.015em;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
border-color: var(--line);
|
||||
padding-left: var(--s3);
|
||||
}
|
||||
|
||||
.title:focus {
|
||||
background: var(--surface);
|
||||
padding-left: var(--s3);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.repeats {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.subtask,
|
||||
.file,
|
||||
.reminder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.subtask-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subtask.done .subtask-text {
|
||||
color: var(--ink-faint);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid var(--line-strong);
|
||||
border-radius: var(--radius-full);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition:
|
||||
background var(--fast) var(--ease),
|
||||
border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.check:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.check.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.check svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
fill: none;
|
||||
stroke: var(--accent-ink);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.file a,
|
||||
.inline-file {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file a:hover,
|
||||
.inline-file:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file a {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.size,
|
||||
.sent,
|
||||
.when {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.reminder span:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-faint);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.upload {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.upload input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: flex-start;
|
||||
margin-top: var(--s1);
|
||||
}
|
||||
|
||||
.add {
|
||||
flex: none;
|
||||
padding: var(--s2) var(--s3);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.add:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: flex-start;
|
||||
padding: var(--s2) 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-full);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.said {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.who {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.said p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.drawer {
|
||||
width: 100vw;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script>
|
||||
/**
|
||||
* A list of tasks that can be reordered by dragging.
|
||||
*
|
||||
* The board view drags cards between columns; this drags rows within one. Both
|
||||
* use the native HTML drag-and-drop API for the same reason — it works with a
|
||||
* trackpad, a mouse and the browser's own affordances without reimplementing
|
||||
* any of them.
|
||||
*
|
||||
* Where the board shows the target *column*, a list has to show the target
|
||||
* *gap*: the whole decision here is "above this one or below it", so a line is
|
||||
* drawn where the row would land.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
|
||||
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
||||
|
||||
let draggingId = $state(null);
|
||||
/** The row the line is drawn at, and which side of it. */
|
||||
let overId = $state(null);
|
||||
let overBelow = $state(false);
|
||||
|
||||
function onDragStart(event, task) {
|
||||
if (!canEdit) return;
|
||||
draggingId = task.id;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
||||
event.dataTransfer.setData('text/plain', task.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, task) {
|
||||
if (!canEdit || !draggingId || draggingId === task.id) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Which half of the row the pointer is in decides the gap. Comparing against
|
||||
// the row's own midpoint rather than counting rows is what keeps the line
|
||||
// where the eye expects it when rows have different heights.
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
overId = task.id;
|
||||
overBelow = event.clientY > box.top + box.height / 2;
|
||||
}
|
||||
|
||||
function clearOver() {
|
||||
overId = null;
|
||||
}
|
||||
|
||||
async function onDrop(event, target) {
|
||||
event.preventDefault();
|
||||
const id = 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);
|
||||
const without = ordered.filter((t) => t.id !== id);
|
||||
const at = without.findIndex((t) => t.id === target.id);
|
||||
if (at < 0) return;
|
||||
|
||||
// The gap the row lands in, named by where they end up on screen — the API's
|
||||
// after_id is the task above, which is the opposite of what "after" reads as
|
||||
// in a vertical list.
|
||||
const index = below ? at + 1 : at;
|
||||
const above = without[index - 1];
|
||||
const beneath = without[index];
|
||||
|
||||
const task = app.get(id);
|
||||
if (!task) return;
|
||||
const previous = { ...task };
|
||||
|
||||
// Optimistic: the row is in its new place before the request goes out. The
|
||||
// midpoint is a local guess — the server owns the real value and sends it
|
||||
// back, and if the gap has run out of precision it respaces the section and
|
||||
// tries again.
|
||||
const order =
|
||||
above && beneath
|
||||
? (above.sort_order + beneath.sort_order) / 2
|
||||
: above
|
||||
? above.sort_order + 1024
|
||||
: (beneath?.sort_order ?? 1024) - 1024;
|
||||
|
||||
app.replace(id, { ...task, sort_order: order });
|
||||
|
||||
try {
|
||||
app.replace(
|
||||
id,
|
||||
await api.moveTask(id, {
|
||||
project_id: projectId,
|
||||
section_id: sectionId,
|
||||
after_id: above?.id ?? '',
|
||||
before_id: beneath?.id ?? ''
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
app.replace(id, previous);
|
||||
app.toast('Kunne ikke flytte opgaven.');
|
||||
}
|
||||
}
|
||||
|
||||
let ordered = $derived([...tasks].sort((a, b) => a.sort_order - b.sort_order));
|
||||
</script>
|
||||
|
||||
{#each ordered as task (task.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="sortable"
|
||||
class:dragging={draggingId === task.id}
|
||||
class:drop-above={overId === task.id && !overBelow}
|
||||
class:drop-below={overId === task.id && overBelow}
|
||||
draggable={canEdit}
|
||||
ondragstart={(e) => onDragStart(e, task)}
|
||||
ondragend={() => {
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
}}
|
||||
ondragover={(e) => onDragOver(e, task)}
|
||||
ondragleave={clearOver}
|
||||
ondrop={(e) => onDrop(e, task)}
|
||||
>
|
||||
<TaskRow {task} />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.sortable {
|
||||
position: relative;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
/* Only the handle-less body drags; the checkbox stays clickable because the
|
||||
drag needs a few pixels of movement before the browser calls it a drag. */
|
||||
.sortable[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sortable.dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* A line in the gap rather than a highlighted row: the row is not the target,
|
||||
the space between two rows is. */
|
||||
.sortable::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.sortable.drop-above::before {
|
||||
top: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sortable.drop-below::before {
|
||||
bottom: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -72,7 +72,9 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="body" onclick={() => onedit?.(task)}>
|
||||
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
|
||||
want the click to mean something else. -->
|
||||
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
|
||||
<span class="content">{task.content}</span>
|
||||
|
||||
{#if task.description}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Web Push, from the browser's side.
|
||||
*
|
||||
* Three parties have to agree before a notification arrives: the browser (which
|
||||
* needs permission and a registered service worker), the push service it chooses
|
||||
* (Google's, Mozilla's), and this server (which holds the VAPID pair and does the
|
||||
* RFC 8291 encryption). This module is the first of those, and nothing else.
|
||||
*/
|
||||
|
||||
import { api } from './api.js';
|
||||
|
||||
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
||||
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
||||
export function supported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
);
|
||||
}
|
||||
|
||||
export function permission() {
|
||||
return supported() ? Notification.permission : 'unsupported';
|
||||
}
|
||||
|
||||
async function registration() {
|
||||
// `ready` waits for an active worker; register() alone resolves while the
|
||||
// worker is still installing, and subscribing against that fails.
|
||||
await navigator.serviceWorker.register('/sw.js');
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
/** The current subscription, or null. Cheap: it reads local state, no network. */
|
||||
export async function current() {
|
||||
if (!supported()) return null;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return (await reg?.pushManager.getSubscription()) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks permission, subscribes, and tells the server.
|
||||
*
|
||||
* Throws with a message meant to be shown. The failures here are not the API's
|
||||
* error shape — a refused permission prompt is not an HTTP status — so they are
|
||||
* plain Errors with Danish text.
|
||||
*/
|
||||
export async function subscribe() {
|
||||
if (!supported()) throw new Error('Denne browser kan ikke vise notifikationer.');
|
||||
|
||||
const granted = await Notification.requestPermission();
|
||||
if (granted !== 'granted') {
|
||||
throw new Error(
|
||||
granted === 'denied'
|
||||
? 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.'
|
||||
: 'Notifikationer blev ikke slået til.'
|
||||
);
|
||||
}
|
||||
|
||||
const reg = await registration();
|
||||
const { public_key } = await api.pushKey();
|
||||
if (!public_key) throw new Error('Serveren har ingen VAPID-nøgle.');
|
||||
|
||||
const subscription = await reg.pushManager.subscribe({
|
||||
// Not optional in Chrome: a subscription that is not userVisibleOnly is
|
||||
// refused outright.
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key)
|
||||
});
|
||||
|
||||
const raw = subscription.toJSON();
|
||||
await api.subscribePush({
|
||||
endpoint: raw.endpoint,
|
||||
keys: { p256dh: raw.keys.p256dh, auth: raw.keys.auth }
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/** Unsubscribes here and forgets it on the server. */
|
||||
export async function unsubscribe() {
|
||||
const subscription = await current();
|
||||
if (!subscription) return;
|
||||
|
||||
// Server first: if the browser unsubscribes and then the request fails, the
|
||||
// server keeps pushing to an endpoint nothing is listening on, and only a
|
||||
// 410 from the push service will ever clean it up.
|
||||
await api.unsubscribePush(subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* The VAPID public key travels as base64url text and `applicationServerKey` wants
|
||||
* raw bytes. Neither `atob` nor the browser does this conversion — every Web Push
|
||||
* client on the internet carries some version of these six lines.
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64url) {
|
||||
const padding = '='.repeat((4 - (base64url.length % 4)) % 4);
|
||||
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
@@ -22,6 +22,13 @@ class AppState {
|
||||
toasts = $state([]);
|
||||
connected = $state(false);
|
||||
|
||||
/**
|
||||
* The task whose detail drawer is open, as an id rather than a copy — so an
|
||||
* edit made here, or arriving over the socket from somebody else, is reflected
|
||||
* in the drawer without a second source of truth to keep in step.
|
||||
*/
|
||||
detailId = $state(null);
|
||||
|
||||
#socket = null;
|
||||
#reconnectDelay = 1000;
|
||||
|
||||
@@ -33,6 +40,18 @@ class AppState {
|
||||
return this.projects.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
get detailTask() {
|
||||
return this.tasks.find((t) => t.id === this.detailId) ?? null;
|
||||
}
|
||||
|
||||
openDetail(id) {
|
||||
this.detailId = id;
|
||||
}
|
||||
|
||||
closeDetail() {
|
||||
this.detailId = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
@@ -85,13 +104,9 @@ class AppState {
|
||||
case 'task.updated':
|
||||
case 'task.completed':
|
||||
case 'task.reopened':
|
||||
case 'task.moved': {
|
||||
if (!task?.id) return;
|
||||
const i = this.tasks.findIndex((t) => t.id === task.id);
|
||||
if (i >= 0) this.tasks[i] = task;
|
||||
else this.tasks.push(task);
|
||||
case 'task.moved':
|
||||
this.upsert(task);
|
||||
break;
|
||||
}
|
||||
case 'task.deleted':
|
||||
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
|
||||
break;
|
||||
@@ -106,58 +121,78 @@ class AppState {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps one task for a new version of it. **The single place a task in the list
|
||||
* is changed** — the optimistic updates below, the reconciliation after them,
|
||||
* the rollbacks, the websocket, and the drag handlers in BoardView and TaskList
|
||||
* all come through here.
|
||||
*
|
||||
* It builds a new array rather than writing `tasks[i] = next`. In-place index
|
||||
* assignment did not reach the views: a ticked-off task kept its row, and a
|
||||
* change arriving over the socket did not show at all, while `push` and whole
|
||||
* -array assignment both worked. Rather than depend on which mutations the
|
||||
* reactivity happens to see through, every write here produces a new array,
|
||||
* which it certainly does see. The lists are at most a few hundred rows; the
|
||||
* copy is not worth a moment's thought next to the class of bug it removes.
|
||||
*/
|
||||
replace(id, next) {
|
||||
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a task, or replaces it if the list already has it.
|
||||
*
|
||||
* Adding is never unconditional, because the same task arrives twice: once as
|
||||
* the response to the request that created it, and once over the socket, which
|
||||
* publishes to the whole project including whoever did it. Either can land
|
||||
* first. Appending both puts two rows with one id into an `{#each}` keyed by
|
||||
* id, which is not a duplicated row but a thrown error that stops the view
|
||||
* rendering.
|
||||
*/
|
||||
upsert(task) {
|
||||
if (!task?.id) return;
|
||||
if (this.tasks.some((t) => t.id === task.id)) this.replace(task.id, task);
|
||||
else this.tasks = [...this.tasks, task];
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.tasks.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks a task off. The row is struck through and gone before the request
|
||||
* leaves — which is the single most-repeated action in the app, and the one
|
||||
* place latency would be felt all day.
|
||||
*/
|
||||
async complete(id) {
|
||||
const index = this.tasks.findIndex((t) => t.id === id);
|
||||
if (index < 0) return;
|
||||
const previous = this.tasks[index];
|
||||
|
||||
this.tasks[index] = { ...previous, completed: true };
|
||||
try {
|
||||
const updated = await api.completeTask(id);
|
||||
const i = this.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) this.tasks[i] = updated;
|
||||
} catch (e) {
|
||||
const i = this.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) this.tasks[i] = previous;
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
await this.#optimistic(id, { completed: true }, () => api.completeTask(id));
|
||||
}
|
||||
|
||||
async reopen(id) {
|
||||
const index = this.tasks.findIndex((t) => t.id === id);
|
||||
if (index < 0) return;
|
||||
const previous = this.tasks[index];
|
||||
|
||||
this.tasks[index] = { ...previous, completed: false };
|
||||
try {
|
||||
const updated = await api.reopenTask(id);
|
||||
const i = this.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) this.tasks[i] = updated;
|
||||
} catch (e) {
|
||||
const i = this.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) this.tasks[i] = previous;
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
await this.#optimistic(id, { completed: false }, () => api.reopenTask(id));
|
||||
}
|
||||
|
||||
async update(id, patch) {
|
||||
const index = this.tasks.findIndex((t) => t.id === id);
|
||||
if (index < 0) return;
|
||||
const previous = this.tasks[index];
|
||||
await this.#optimistic(id, patch, () => api.updateTask(id, patch));
|
||||
}
|
||||
|
||||
this.tasks[index] = { ...previous, ...patch };
|
||||
/**
|
||||
* Applies `patch` at once, then reconciles with whatever the server says.
|
||||
*
|
||||
* The rollback restores the version captured before the change rather than
|
||||
* inverting the patch: the server may have declined for a reason that has
|
||||
* nothing to do with the fields sent, and putting back what was there is the
|
||||
* only correction that is right in every case.
|
||||
*/
|
||||
async #optimistic(id, patch, request) {
|
||||
const previous = this.get(id);
|
||||
if (!previous) return;
|
||||
|
||||
this.replace(id, { ...previous, ...patch });
|
||||
try {
|
||||
const updated = await api.updateTask(id, patch);
|
||||
const i = this.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) this.tasks[i] = updated;
|
||||
this.replace(id, await request());
|
||||
} catch (e) {
|
||||
const i = this.tasks.findIndex((t) => t.id === id);
|
||||
if (i >= 0) this.tasks[i] = previous;
|
||||
this.replace(id, previous);
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
@@ -185,7 +220,7 @@ class AppState {
|
||||
async quickAdd(text, projectId) {
|
||||
try {
|
||||
const task = await api.quickAdd(text, projectId);
|
||||
this.tasks.push(task);
|
||||
this.upsert(task);
|
||||
return task;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
@@ -198,7 +233,7 @@ class AppState {
|
||||
async createProject(name) {
|
||||
try {
|
||||
const project = await api.createProject({ name });
|
||||
this.projects.push(project);
|
||||
this.projects = [...this.projects, project];
|
||||
return project;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -34,6 +35,9 @@
|
||||
return;
|
||||
}
|
||||
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
// The navigation shortcuts are for the list. With a task open, "t" means the
|
||||
// letter t — the drawer is a place you are, not a place you pass through.
|
||||
if (app.detailId) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'q':
|
||||
@@ -111,6 +115,12 @@
|
||||
|
||||
<CommandPalette bind:open={paletteOpen} />
|
||||
|
||||
<!-- Mounted here rather than in each view, so a task opens the same way from
|
||||
Today, a project, a label or a saved filter. -->
|
||||
{#if app.detailTask}
|
||||
<TaskDetail task={app.detailTask} onclose={() => app.closeDetail()} />
|
||||
{/if}
|
||||
|
||||
<!-- Toasts are for things that failed after the interface already said they
|
||||
had succeeded. Nothing else earns an interruption. -->
|
||||
<div class="toasts" role="status" aria-live="polite">
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script>
|
||||
/**
|
||||
* The settings surface.
|
||||
*
|
||||
* Six pages rather than one long scroll: the sections have nothing to do with
|
||||
* each other, and a person opening "API-tokens" should not have to walk past
|
||||
* their own password to get there.
|
||||
*
|
||||
* The form chrome lives here as `:global` rules. Scoped styles do not reach a
|
||||
* child route, and the alternative — six copies of the same twelve selectors —
|
||||
* is six places for them to drift apart.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const SECTIONS = [
|
||||
{ href: '/indstillinger', label: 'Konto' },
|
||||
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
|
||||
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
|
||||
{ href: '/indstillinger/ai', label: 'AI' },
|
||||
{ href: '/indstillinger/tokens', label: 'API-tokens' },
|
||||
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
||||
];
|
||||
|
||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
<header>
|
||||
<h1>Indstillinger</h1>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Indstillinger">
|
||||
{#each SECTIONS as section (section.href)}
|
||||
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
/* A scrolling strip rather than a wrapping row: six labels wrap to two lines on
|
||||
a phone, and a two-line tab bar looks like a mistake. */
|
||||
nav {
|
||||
display: flex;
|
||||
gap: var(--s1);
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s5);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
nav a {
|
||||
flex: none;
|
||||
padding: var(--s2) var(--s3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
white-space: nowrap;
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
/* --- shared form chrome, for the child routes ------------------------------- */
|
||||
|
||||
.settings :global(section.panel) {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.settings :global(.panel > header) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.settings :global(.panel h2) {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.settings :global(.panel .hint) {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings :global(.field) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.settings :global(.field > label) {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.settings :global(.field input),
|
||||
.settings :global(.field select),
|
||||
.settings :global(.field textarea) {
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.settings :global(.field input:focus),
|
||||
.settings :global(.field select:focus),
|
||||
.settings :global(.field textarea:focus) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* The field error sits under its own input rather than in a summary at the top:
|
||||
the server answers with a map of field to message, and putting each one where
|
||||
it belongs is the whole point of that shape. */
|
||||
.settings :global(.field .error) {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.settings :global(.field input[aria-invalid='true']) {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.settings :global(.row) {
|
||||
display: flex;
|
||||
gap: var(--s3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings :global(button.primary) {
|
||||
padding: var(--s2) var(--s4);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
transition: background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.settings :global(button.primary:hover) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.settings :global(button.primary:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings :global(button.secondary) {
|
||||
padding: var(--s2) var(--s4);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
transition:
|
||||
color var(--fast) var(--ease),
|
||||
border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.settings :global(button.secondary:hover) {
|
||||
color: var(--ink);
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.settings :global(button.danger) {
|
||||
padding: var(--s2) var(--s4);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
transition: background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.settings :global(button.danger:hover) {
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
/* Saved-confirmations are deliberately quiet and inline. A toast for "saved"
|
||||
would interrupt to say nothing went wrong. */
|
||||
.settings :global(.saved) {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.settings :global(.list) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings :global(.list > li) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.settings :global(.list > li:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.settings :global(.mono) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.settings :global(.empty) {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
// --- profile ---------------------------------------------------------------
|
||||
|
||||
let name = $state('');
|
||||
let timezone = $state('');
|
||||
let locale = $state('da');
|
||||
let profileErrors = $state({});
|
||||
let profileSaved = $state(false);
|
||||
let savingProfile = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Seeded once. Binding straight to app.user would overwrite what somebody is
|
||||
// halfway through typing every time the store reloads.
|
||||
$effect(() => {
|
||||
if (loaded || !app.user) return;
|
||||
name = app.user.name;
|
||||
timezone = app.user.timezone;
|
||||
locale = app.user.locale;
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
// Whatever the browser knows, plus the current value in case it is not in the
|
||||
// list — a server configured with a zone this browser has never heard of must
|
||||
// not have it silently replaced on the next save.
|
||||
let zones = $derived.by(() => {
|
||||
let all = [];
|
||||
try {
|
||||
all = Intl.supportedValuesOf('timeZone');
|
||||
} catch {
|
||||
all = ['Europe/Copenhagen', 'Europe/London', 'UTC'];
|
||||
}
|
||||
return timezone && !all.includes(timezone) ? [timezone, ...all] : all;
|
||||
});
|
||||
|
||||
async function saveProfile(event) {
|
||||
event.preventDefault();
|
||||
savingProfile = true;
|
||||
profileErrors = {};
|
||||
profileSaved = false;
|
||||
try {
|
||||
app.user = await api.updateProfile({ name, timezone, locale });
|
||||
profileSaved = true;
|
||||
} catch (e) {
|
||||
profileErrors = e.fields ?? {};
|
||||
if (!Object.keys(profileErrors).length) app.toast(humanMessage(e));
|
||||
} finally {
|
||||
savingProfile = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- password ---------------------------------------------------------------
|
||||
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let passwordErrors = $state({});
|
||||
let passwordSaved = $state(false);
|
||||
|
||||
async function changePassword(event) {
|
||||
event.preventDefault();
|
||||
passwordErrors = {};
|
||||
passwordSaved = false;
|
||||
try {
|
||||
await api.changePassword(currentPassword, newPassword);
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
passwordSaved = true;
|
||||
} catch (e) {
|
||||
passwordErrors = e.fields ?? {};
|
||||
if (!Object.keys(passwordErrors).length) app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- two-factor --------------------------------------------------------------
|
||||
|
||||
let totpSecret = $state('');
|
||||
let totpURI = $state('');
|
||||
let totpCode = $state('');
|
||||
let totpErrors = $state({});
|
||||
let recoveryCodes = $state([]);
|
||||
let remaining = $state(null);
|
||||
let disablePassword = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (app.user?.totp_enabled) {
|
||||
api.recoveryCodes().then((r) => (remaining = r.remaining)).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function beginTOTP() {
|
||||
try {
|
||||
const r = await api.totpSetup();
|
||||
totpSecret = r.secret;
|
||||
totpURI = r.uri;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTOTP(event) {
|
||||
event.preventDefault();
|
||||
totpErrors = {};
|
||||
try {
|
||||
const r = await api.totpConfirm(totpCode);
|
||||
recoveryCodes = r.recovery_codes;
|
||||
totpSecret = '';
|
||||
totpURI = '';
|
||||
totpCode = '';
|
||||
app.user = await api.me();
|
||||
} catch (e) {
|
||||
totpErrors = e.fields ?? {};
|
||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTOTP(event) {
|
||||
event.preventDefault();
|
||||
totpErrors = {};
|
||||
try {
|
||||
await api.totpDisable(disablePassword);
|
||||
disablePassword = '';
|
||||
recoveryCodes = [];
|
||||
remaining = null;
|
||||
app.user = await api.me();
|
||||
} catch (e) {
|
||||
totpErrors = e.fields ?? {};
|
||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate(event) {
|
||||
event.preventDefault();
|
||||
totpErrors = {};
|
||||
try {
|
||||
const r = await api.regenerateRecoveryCodes(disablePassword);
|
||||
recoveryCodes = r.recovery_codes;
|
||||
remaining = r.recovery_codes.length;
|
||||
disablePassword = '';
|
||||
} catch (e) {
|
||||
totpErrors = e.fields ?? {};
|
||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Profil</h2>
|
||||
<p class="hint">
|
||||
Tidszonen er ikke pynt: alle datoer i appen bliver afgjort i den, så
|
||||
“i morgen kl. 9” betyder noget andet, når du skifter den.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={saveProfile}>
|
||||
<div class="field">
|
||||
<label for="name">Navn</label>
|
||||
<input
|
||||
id="name"
|
||||
bind:value={name}
|
||||
aria-invalid={profileErrors.name ? 'true' : undefined}
|
||||
/>
|
||||
{#if profileErrors.name}<p class="error">{profileErrors.name}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">E-mail</label>
|
||||
<!-- Read-only: the address identifies the account and is what invitations
|
||||
were sent to. Changing it is a re-verification flow, not a field. -->
|
||||
<input id="email" value={app.user?.email ?? ''} readonly disabled />
|
||||
<p class="hint">Kan ikke ændres her.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="timezone">Tidszone</label>
|
||||
<select id="timezone" bind:value={timezone}>
|
||||
{#each zones as zone (zone)}
|
||||
<option value={zone}>{zone}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if profileErrors.timezone}<p class="error">{profileErrors.timezone}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="locale">Sprog i hurtig tilføjelse</label>
|
||||
<select id="locale" bind:value={locale}>
|
||||
<option value="da">Dansk</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<p class="hint">
|
||||
Afgør hvilken grammatik der tolker en linje — “hver mandag”
|
||||
eller “every monday”.
|
||||
</p>
|
||||
{#if profileErrors.locale}<p class="error">{profileErrors.locale}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={savingProfile}>Gem</button>
|
||||
{#if profileSaved}<span class="saved">Gemt.</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Adgangskode</h2>
|
||||
<p class="hint">
|
||||
At skifte den logger alle andre sessioner ud — hvilket for det meste er
|
||||
grunden til at gøre det.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={changePassword}>
|
||||
<div class="field">
|
||||
<label for="current">Nuværende adgangskode</label>
|
||||
<input
|
||||
id="current"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={currentPassword}
|
||||
aria-invalid={passwordErrors.current_password ? 'true' : undefined}
|
||||
/>
|
||||
{#if passwordErrors.current_password}
|
||||
<p class="error">{passwordErrors.current_password}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new">Ny adgangskode</label>
|
||||
<input
|
||||
id="new"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={newPassword}
|
||||
aria-invalid={passwordErrors.new_password ? 'true' : undefined}
|
||||
/>
|
||||
{#if passwordErrors.new_password}<p class="error">{passwordErrors.new_password}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit">Skift adgangskode</button>
|
||||
{#if passwordSaved}<span class="saved">Skiftet.</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>To-faktor</h2>
|
||||
<p class="hint">En engangskode fra en authenticator-app oven i adgangskoden.</p>
|
||||
</header>
|
||||
|
||||
{#if recoveryCodes.length}
|
||||
<!-- Shown once, and the interface has to say so: only the hashes are kept,
|
||||
so there is no later screen that could show them again. -->
|
||||
<div class="field">
|
||||
<p class="hint">
|
||||
<strong>Skriv dem ned nu.</strong> De vises kun denne ene gang — serveren
|
||||
har kun deres hash.
|
||||
</p>
|
||||
<ul class="codes mono">
|
||||
{#each recoveryCodes as code (code)}
|
||||
<li>{code}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={() => (recoveryCodes = [])}>Jeg har dem</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if app.user?.totp_enabled}
|
||||
<p class="hint">
|
||||
Slået til.{#if remaining !== null}
|
||||
{remaining} gendannelseskode{remaining === 1 ? '' : 'r'} tilbage.{/if}
|
||||
</p>
|
||||
|
||||
<form onsubmit={disableTOTP}>
|
||||
<div class="field">
|
||||
<label for="disable-pw">Adgangskode</label>
|
||||
<input
|
||||
id="disable-pw"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={disablePassword}
|
||||
aria-invalid={totpErrors.password ? 'true' : undefined}
|
||||
/>
|
||||
{#if totpErrors.password}<p class="error">{totpErrors.password}</p>{/if}
|
||||
<p class="hint">
|
||||
Kræves til begge handlinger nedenfor. At slå to-faktor fra er præcis
|
||||
det, en fremmed med din session ville gøre først.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={regenerate}>Nye gendannelseskoder</button>
|
||||
<button class="danger" type="submit">Slå to-faktor fra</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if totpSecret}
|
||||
<div class="field">
|
||||
<p class="hint">Scan i din authenticator-app, eller indtast nøglen manuelt.</p>
|
||||
<p class="mono secret">{totpSecret}</p>
|
||||
<p class="hint mono uri">{totpURI}</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={confirmTOTP}>
|
||||
<div class="field">
|
||||
<label for="totp">Koden fra appen</label>
|
||||
<input
|
||||
id="totp"
|
||||
bind:value={totpCode}
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
aria-invalid={totpErrors.code ? 'true' : undefined}
|
||||
/>
|
||||
{#if totpErrors.code}<p class="error">{totpErrors.code}</p>{/if}
|
||||
<p class="hint">
|
||||
To-faktor er først slået til, når en kode har bevist, at appen faktisk
|
||||
har nøglen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit">Bekræft</button>
|
||||
<button class="secondary" onclick={() => (totpSecret = '')}>Fortryd</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<button class="primary" onclick={beginTOTP}>Slå to-faktor til</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.codes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--s2);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.secret {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
letter-spacing: 0.08em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uri {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script>
|
||||
/** The AI provider: which one, which model, and the key. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let settings = $state(null);
|
||||
let apiKey = $state('');
|
||||
let saving = $state(false);
|
||||
let saved = $state(false);
|
||||
let errors = $state({});
|
||||
|
||||
let summary = $state('');
|
||||
let summarising = $state(false);
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: '', label: 'Slået fra' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'google', label: 'Google' },
|
||||
{ value: 'compatible', label: 'OpenAI-kompatibel (Ollama, LM Studio, …)' }
|
||||
];
|
||||
|
||||
// Suggestions, not a closed list: the model field is free text, because a new
|
||||
// model name must not require a new release of this app to be usable.
|
||||
const SUGGESTED_MODEL = {
|
||||
anthropic: 'claude-sonnet-5',
|
||||
openai: 'gpt-5',
|
||||
google: 'gemini-2.5-pro',
|
||||
compatible: 'llama3.1'
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.getAISettings()
|
||||
.then((s) => (settings = s))
|
||||
.catch((e) => app.toast(humanMessage(e)));
|
||||
});
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
saving = true;
|
||||
saved = false;
|
||||
errors = {};
|
||||
try {
|
||||
await api.setAISettings({
|
||||
provider: settings.provider,
|
||||
base_url: settings.base_url ?? '',
|
||||
model: settings.model ?? '',
|
||||
// An empty key means "leave the stored one alone" on the server. That
|
||||
// is why this field starts empty rather than pre-filled with dots.
|
||||
api_key: apiKey
|
||||
});
|
||||
apiKey = '';
|
||||
settings = await api.getAISettings();
|
||||
saved = true;
|
||||
} catch (e) {
|
||||
errors = e.fields ?? {};
|
||||
if (!Object.keys(errors).length) app.toast(humanMessage(e));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function weeklySummary() {
|
||||
summarising = true;
|
||||
summary = '';
|
||||
try {
|
||||
summary = (await api.aiSummary()).summary;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
summarising = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>AI</h2>
|
||||
<p class="hint">
|
||||
Bruges to steder: at dele en opgave op i undertasks, og at skrive et kort
|
||||
overblik over det, der er udestående. Nøglen ligger på din server og sendes
|
||||
kun til den udbyder, du vælger her.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if settings === null}
|
||||
<p class="empty">…</p>
|
||||
{:else}
|
||||
<form onsubmit={save}>
|
||||
<div class="field">
|
||||
<label for="provider">Udbyder</label>
|
||||
<select id="provider" bind:value={settings.provider}>
|
||||
{#each PROVIDERS as provider (provider.value)}
|
||||
<option value={provider.value}>{provider.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if errors.provider}<p class="error">{errors.provider}</p>{/if}
|
||||
</div>
|
||||
|
||||
{#if settings.provider}
|
||||
<div class="field">
|
||||
<label for="model">Model</label>
|
||||
<input
|
||||
id="model"
|
||||
bind:value={settings.model}
|
||||
placeholder={SUGGESTED_MODEL[settings.provider] ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if settings.provider === 'compatible'}
|
||||
<div class="field">
|
||||
<label for="base">Adresse</label>
|
||||
<input
|
||||
id="base"
|
||||
class="mono"
|
||||
bind:value={settings.base_url}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="key">API-nøgle</label>
|
||||
<input
|
||||
id="key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
bind:value={apiKey}
|
||||
placeholder={settings.has_key ? 'Der er gemt en nøgle — lad feltet stå tomt' : ''}
|
||||
/>
|
||||
<p class="hint">
|
||||
{#if settings.has_key}
|
||||
Feltet er tomt med vilje. Nøglen sendes aldrig tilbage til browseren
|
||||
— en indstillingsside, der fylder et kodeordsfelt ud igen, er en side,
|
||||
der en dag lækker en nøgle ud i et skærmbillede.
|
||||
{:else}
|
||||
Gemmes på serveren og vises aldrig igen.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={saving}>Gem</button>
|
||||
{#if saved}<span class="saved">Gemt.</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if settings?.provider && settings?.has_key}
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Ugentligt overblik</h2>
|
||||
<p class="hint">En kort note om, hvad der ser vigtigst ud lige nu.</p>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={weeklySummary} disabled={summarising}>
|
||||
{summarising ? 'Skriver …' : 'Skriv et overblik'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if summary}
|
||||
<p class="summary">{summary}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
padding: var(--s4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script>
|
||||
/** Import, export and project templates. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// --- Todoist import ------------------------------------------------------------
|
||||
|
||||
let importing = $state(false);
|
||||
let result = $state(null);
|
||||
|
||||
async function importTodoist(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
event.target.value = ''; // so the same file can be picked twice after a failure
|
||||
|
||||
importing = true;
|
||||
result = null;
|
||||
try {
|
||||
result = await api.importTodoist(file);
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
app.toast(e.fields?.file ?? humanMessage(e));
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- templates --------------------------------------------------------------
|
||||
|
||||
let templates = $state([]);
|
||||
let loadingTemplates = $state(true);
|
||||
|
||||
let saveFrom = $state('');
|
||||
let templateName = $state('');
|
||||
let savingTemplate = $state(false);
|
||||
|
||||
let usingId = $state('');
|
||||
let newProjectName = $state('');
|
||||
let startDate = $state('');
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.listTemplates()
|
||||
.then((r) => (templates = r.templates))
|
||||
.catch((e) => app.toast(humanMessage(e)))
|
||||
.finally(() => (loadingTemplates = false));
|
||||
});
|
||||
|
||||
let projects = $derived(app.projects.filter((p) => !p.is_inbox));
|
||||
|
||||
async function saveTemplate(event) {
|
||||
event.preventDefault();
|
||||
if (!saveFrom) return;
|
||||
savingTemplate = true;
|
||||
try {
|
||||
const created = await api.saveTemplate({
|
||||
project_id: saveFrom,
|
||||
name: templateName.trim() || app.projectById(saveFrom)?.name || 'Skabelon',
|
||||
description: ''
|
||||
});
|
||||
templates = [created, ...templates];
|
||||
saveFrom = '';
|
||||
templateName = '';
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
savingTemplate = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function useTemplate(event) {
|
||||
event.preventDefault();
|
||||
if (!usingId) return;
|
||||
try {
|
||||
const project = await api.useTemplate(usingId, {
|
||||
name: newProjectName.trim(),
|
||||
start_date: startDate
|
||||
});
|
||||
await app.refreshProjects();
|
||||
usingId = '';
|
||||
newProjectName = '';
|
||||
startDate = '';
|
||||
goto(`/projekt/${project.id}`);
|
||||
} catch (e) {
|
||||
app.toast(e.fields?.start_date ?? humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate(template) {
|
||||
if (!confirm(`Slet skabelonen "${template.name}"?`)) return;
|
||||
|
||||
const previous = templates;
|
||||
templates = templates.filter((t) => t.id !== template.id);
|
||||
try {
|
||||
await api.deleteTemplate(template.id);
|
||||
} catch (e) {
|
||||
templates = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
let exportProject = $state('');
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Importér fra Todoist</h2>
|
||||
<p class="hint">
|
||||
Vælg CSV-eksporten af ét projekt. Prioriteterne bliver konverteret, ikke
|
||||
kopieret: Todoist skriver 4 for det, grænsefladen kalder P1.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label for="todoist">CSV-fil</label>
|
||||
<input id="todoist" type="file" accept=".csv,text/csv" onchange={importTodoist} disabled={importing} />
|
||||
</div>
|
||||
|
||||
{#if importing}
|
||||
<p class="hint">Importerer …</p>
|
||||
{/if}
|
||||
|
||||
{#if result}
|
||||
<div class="result">
|
||||
<p class="hint">
|
||||
{result.tasks} opgave{result.tasks === 1 ? '' : 'r'}, {result.sections}
|
||||
sektion{result.sections === 1 ? '' : 'er'} og {result.comments}
|
||||
kommentar{result.comments === 1 ? '' : 'er'} hentet ind.
|
||||
</p>
|
||||
{#if result.warnings?.length}
|
||||
<!-- Reported rather than silently dropped: a partial import that says
|
||||
nothing is discovered weeks later. -->
|
||||
<ul class="warnings">
|
||||
{#each result.warnings as warning, i (i)}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if result.project_id}
|
||||
<div class="row">
|
||||
<a class="link" href="/projekt/{result.project_id}">Åbn projektet</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Eksport</h2>
|
||||
<p class="hint">
|
||||
Alt, i den rå form. Det er garantien for, at du kan gå igen — en
|
||||
eksport, der er blevet pyntet, er en eksport, der har mistet noget.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<!-- Plain links, not fetch: the browser's own download handling knows what to
|
||||
do with a Content-Disposition, and the session cookie rides along. -->
|
||||
<a class="link" href={api.exportAccountURL()} download>Hele kontoen som JSON</a>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="export-project">Ét projekt</label>
|
||||
<select id="export-project" bind:value={exportProject}>
|
||||
<option value="">Vælg et projekt</option>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if exportProject}
|
||||
<div class="row">
|
||||
<a class="link" href={api.exportProjectCSVURL(exportProject)} download>CSV</a>
|
||||
<a class="link" href={api.exportProjectICSURL(exportProject)} download>Kalenderfil</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Skabeloner</h2>
|
||||
<p class="hint">
|
||||
Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er
|
||||
relative, så de bliver regnet ud fra den startdato, du vælger.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={saveTemplate}>
|
||||
<div class="field">
|
||||
<label for="save-from">Gem et projekt som skabelon</label>
|
||||
<select id="save-from" bind:value={saveFrom}>
|
||||
<option value="">Vælg et projekt</option>
|
||||
{#each projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if saveFrom}
|
||||
<div class="field">
|
||||
<label for="template-name">Navn på skabelonen</label>
|
||||
<input
|
||||
id="template-name"
|
||||
bind:value={templateName}
|
||||
placeholder={app.projectById(saveFrom)?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={savingTemplate}>Gem som skabelon</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if loadingTemplates}
|
||||
<p class="empty">…</p>
|
||||
{:else if templates.length === 0}
|
||||
<p class="empty">Ingen skabeloner endnu.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each templates as template (template.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="title">{template.name}</span>
|
||||
<span class="meta">
|
||||
{template.task_count} opgave{template.task_count === 1 ? '' : 'r'}
|
||||
</span>
|
||||
</div>
|
||||
<button class="use" onclick={() => (usingId = usingId === template.id ? '' : template.id)}>
|
||||
Brug
|
||||
</button>
|
||||
<button class="remove" onclick={() => deleteTemplate(template)}>Slet</button>
|
||||
</li>
|
||||
|
||||
{#if usingId === template.id}
|
||||
<li class="use-form">
|
||||
<form onsubmit={useTemplate}>
|
||||
<div class="field">
|
||||
<label for="new-name">Navn på det nye projekt</label>
|
||||
<input id="new-name" bind:value={newProjectName} placeholder={template.name} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="start">Startdato</label>
|
||||
<input id="start" type="date" bind:value={startDate} />
|
||||
<p class="hint">Dag nul for skabelonens datoer. Tom betyder i dag.</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit">Opret projektet</button>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding: var(--s4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.warnings {
|
||||
margin: 0;
|
||||
padding-left: var(--s5);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.use,
|
||||
.remove {
|
||||
flex: none;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-faint);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.use:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.use-form {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.use-form form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script>
|
||||
/** Gmail, the calendar feed, and the mail-to-task address. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
// --- Gmail ------------------------------------------------------------------
|
||||
|
||||
let gmail = $state(null);
|
||||
let savingGmail = $state(false);
|
||||
let gmailSaved = $state(false);
|
||||
let syncing = $state(false);
|
||||
|
||||
/**
|
||||
* What the OAuth callback redirected back with. It arrives as a query parameter
|
||||
* because the callback is a top-level navigation from Google — there is no fetch
|
||||
* whose response could have carried it.
|
||||
*/
|
||||
const CALLBACK = {
|
||||
connected: { tone: 'ok', text: 'Gmail er forbundet.' },
|
||||
state: {
|
||||
tone: 'bad',
|
||||
text: 'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.'
|
||||
},
|
||||
expired: { tone: 'bad', text: 'Forsøget udløb undervejs. Prøv igen.' },
|
||||
invalid: { tone: 'bad', text: 'Svaret fra Google kunne ikke læses. Prøv igen.' },
|
||||
failed: { tone: 'bad', text: 'Google afviste ombytningen af koden.' },
|
||||
norefresh: {
|
||||
tone: 'bad',
|
||||
text: 'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.'
|
||||
},
|
||||
access_denied: { tone: 'bad', text: 'Adgangen blev afvist hos Google.' }
|
||||
};
|
||||
|
||||
let callback = $derived.by(() => {
|
||||
const value = $page.url.searchParams.get('gmail');
|
||||
if (!value) return null;
|
||||
return CALLBACK[value] ?? { tone: 'bad', text: `Gmail svarede: ${value}` };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.getGmail()
|
||||
.then((g) => (gmail = g))
|
||||
.catch((e) => app.toast(humanMessage(e)));
|
||||
});
|
||||
|
||||
async function connect() {
|
||||
try {
|
||||
const { url } = await api.authorizeGmail();
|
||||
location.href = url;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGmail(event) {
|
||||
event.preventDefault();
|
||||
savingGmail = true;
|
||||
gmailSaved = false;
|
||||
try {
|
||||
await api.setGmail({ trigger: gmail.trigger, label: gmail.label ?? '' });
|
||||
gmailSaved = true;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
savingGmail = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNow() {
|
||||
syncing = true;
|
||||
try {
|
||||
const result = await api.syncGmail();
|
||||
app.toast(
|
||||
result?.created ? `${result.created} opgave(r) hentet.` : 'Ingen nye beskeder.'
|
||||
);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectGmail() {
|
||||
if (!confirm('Afbryd forbindelsen til Gmail? Tokens bliver glemt.')) return;
|
||||
try {
|
||||
await api.disconnectGmail();
|
||||
gmail = await api.getGmail();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- calendar feed -------------------------------------------------------------
|
||||
|
||||
let feedURL = $state('');
|
||||
let mailAddress = $state('');
|
||||
/** Null until loaded, so the warning does not flash before the answer is in. */
|
||||
let mailConfigured = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
api
|
||||
.getMailAddress()
|
||||
.then((r) => {
|
||||
mailAddress = r.address;
|
||||
mailConfigured = r.configured;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
async function rotateFeed() {
|
||||
if (!confirm('Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.')) return;
|
||||
try {
|
||||
feedURL = (await api.rotateFeed()).url;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateMail() {
|
||||
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
|
||||
try {
|
||||
const r = await api.rotateMailAddress();
|
||||
mailAddress = r.address;
|
||||
mailConfigured = r.configured;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
app.toast('Kopieret.');
|
||||
} catch {
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Gmail</h2>
|
||||
<p class="hint">
|
||||
Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave.
|
||||
Envejs: at fjerne stjernen gør ikke noget ved opgaven.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if callback}
|
||||
<p class="callback" data-tone={callback.tone}>{callback.text}</p>
|
||||
{/if}
|
||||
|
||||
{#if gmail === null}
|
||||
<p class="empty">…</p>
|
||||
{:else if !gmail.connected}
|
||||
<div class="row">
|
||||
<button class="primary" onclick={connect}>Forbind Gmail</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Kræver at administratoren har registreret en OAuth-klient hos Google og sat
|
||||
<span class="mono">VERDANDE_GMAIL_CLIENT_ID</span> og
|
||||
<span class="mono">_SECRET</span>.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="hint">Forbundet som <strong>{gmail.email || 'ukendt konto'}</strong>.</p>
|
||||
|
||||
<form onsubmit={saveGmail}>
|
||||
<div class="field">
|
||||
<label for="trigger">Hvad laver en opgave</label>
|
||||
<select id="trigger" bind:value={gmail.trigger}>
|
||||
<option value="starred">Stjernemarkerede</option>
|
||||
<option value="label">En bestemt etiket</option>
|
||||
<option value="both">Begge dele</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if gmail.trigger === 'label' || gmail.trigger === 'both'}
|
||||
<div class="field">
|
||||
<label for="gmail-label">Etikettens navn i Gmail</label>
|
||||
<input id="gmail-label" bind:value={gmail.label} placeholder="fx Opgaver" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={savingGmail}>Gem</button>
|
||||
{#if gmailSaved}<span class="saved">Gemt.</span>{/if}
|
||||
<button class="secondary" onclick={syncNow} disabled={syncing}>Hent nu</button>
|
||||
<button class="danger" onclick={disconnectGmail}>Afbryd</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Kalenderfeed</h2>
|
||||
<p class="hint">
|
||||
Abonnér i Apple Kalender, Google eller Thunderbird. En kalenderklient kan
|
||||
ikke logge ind, så nøglen i adressen <em>er</em> hele adgangskoden — del den
|
||||
ikke.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label for="feed">Adresse</label>
|
||||
<input id="feed" class="mono" value={feedURL} readonly />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={() => copy(feedURL)}>Kopiér</button>
|
||||
<button class="danger" onclick={rotateFeed}>Nyt link</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Et nyt link bryder alle eksisterende abonnementer med det samme. Det er
|
||||
pointen: det er det, man gør, når det gamle er havnet et forkert sted.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Mail til opgave</h2>
|
||||
<p class="hint">
|
||||
Send eller videresend en mail hertil, og emnelinjen bliver tolket som en
|
||||
hurtig tilføjelse — “Fakturer Anders p1 #Firma” virker også her.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if mailConfigured === false}
|
||||
<p class="callback" data-tone="bad">
|
||||
Der er ingen mailserver sat op på denne server, så adressen herunder kan
|
||||
ikke modtage noget endnu. Sæt <span class="mono">VERDANDE_SMTP_HOST</span> og
|
||||
de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til
|
||||
<span class="mono">todo+*</span> videre til
|
||||
<span class="mono">/inbound/mail</span>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="mail">Din adresse</label>
|
||||
<input id="mail" class="mono" value={mailAddress} readonly />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={() => copy(mailAddress)}>Kopiér</button>
|
||||
<button class="danger" onclick={rotateMail}>Ny adresse</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>CalDAV</h2>
|
||||
<p class="hint">
|
||||
Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird
|
||||
kan også skrive tilbage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label for="caldav">Server</label>
|
||||
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
|
||||
<p class="hint">
|
||||
Brugernavn er din e-mail. Adgangskoden er en
|
||||
<a href="/indstillinger/tokens">API-token</a> — ikke din rigtige adgangskode.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.callback {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.callback[data-tone='ok'] {
|
||||
color: var(--accent);
|
||||
background: var(--accent-sunken);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.callback[data-tone='bad'] {
|
||||
color: var(--danger);
|
||||
background: var(--danger-sunken);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script>
|
||||
/** Notifications: what has happened, and whether the browser should tell you. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import * as push from '$lib/push.js';
|
||||
|
||||
// --- web push ----------------------------------------------------------------
|
||||
|
||||
let pushState = $state('loading');
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!push.supported()) {
|
||||
pushState = 'unsupported';
|
||||
return;
|
||||
}
|
||||
if (push.permission() === 'denied') {
|
||||
pushState = 'blocked';
|
||||
return;
|
||||
}
|
||||
push.current().then((sub) => (pushState = sub ? 'on' : 'off'));
|
||||
});
|
||||
|
||||
async function enablePush() {
|
||||
busy = true;
|
||||
try {
|
||||
await push.subscribe();
|
||||
pushState = 'on';
|
||||
} catch (e) {
|
||||
app.toast(e.message ?? humanMessage(e));
|
||||
if (push.permission() === 'denied') pushState = 'blocked';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePush() {
|
||||
busy = true;
|
||||
try {
|
||||
await push.unsubscribe();
|
||||
pushState = 'off';
|
||||
} catch (e) {
|
||||
app.toast(e.message ?? humanMessage(e));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- the list -----------------------------------------------------------------
|
||||
|
||||
let notifications = $state([]);
|
||||
let unread = $state(0);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.listNotifications()
|
||||
.then((r) => {
|
||||
notifications = r.notifications;
|
||||
unread = r.unread;
|
||||
})
|
||||
.catch((e) => app.toast(humanMessage(e)))
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
|
||||
async function markAllRead() {
|
||||
const previous = notifications;
|
||||
notifications = notifications.map((n) => ({ ...n, read: true }));
|
||||
unread = 0;
|
||||
try {
|
||||
await api.markNotificationsRead();
|
||||
} catch (e) {
|
||||
notifications = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function when(iso) {
|
||||
const then = new Date(iso);
|
||||
const minutes = Math.round((Date.now() - then) / 60000);
|
||||
if (minutes < 1) return 'lige nu';
|
||||
if (minutes < 60) return `${minutes} min. siden`;
|
||||
if (minutes < 60 * 24) return `${Math.round(minutes / 60)} t. siden`;
|
||||
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// --- version ------------------------------------------------------------------
|
||||
|
||||
let version = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api.version().then((v) => (version = v)).catch(() => {});
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Notifikationer på enheden</h2>
|
||||
<p class="hint">
|
||||
Påmindelser og beskeder fra andre, også når fanen er lukket. Beskeden
|
||||
krypteres til din browser undervejs — push-tjenesten kan ikke læse den.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if pushState === 'loading'}
|
||||
<p class="empty">…</p>
|
||||
{:else if pushState === 'unsupported'}
|
||||
<p class="hint">
|
||||
Denne browser understøtter det ikke. Safari kræver, at appen er lagt på
|
||||
hjemmeskærmen; over almindelig HTTP virker det kun på localhost.
|
||||
</p>
|
||||
{:else if pushState === 'blocked'}
|
||||
<p class="hint">
|
||||
Browseren har blokeret notifikationer for dette site. Det kan kun laves om i
|
||||
browserens egne indstillinger — en side kan ikke spørge igen, når svaret har
|
||||
været nej.
|
||||
</p>
|
||||
{:else if pushState === 'on'}
|
||||
<p class="hint">Slået til på denne enhed.</p>
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={disablePush} disabled={busy}>Slå fra</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<button class="primary" onclick={enablePush} disabled={busy}>
|
||||
Slå til på denne enhed
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">Gælder kun den browser, du sidder i nu.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Seneste</h2>
|
||||
{#if unread > 0}
|
||||
<p class="hint">{unread} ulæst{unread === 1 ? '' : 'e'}.</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">…</p>
|
||||
{:else if notifications.length === 0}
|
||||
<p class="empty">Ingenting endnu.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each notifications as note (note.id)}
|
||||
<li class:unread={!note.read}>
|
||||
<div class="what">
|
||||
<span class="title">{note.title}</span>
|
||||
{#if note.body}<span class="body">{note.body}</span>{/if}
|
||||
<span class="meta">
|
||||
{#if note.actor_name}{note.actor_name} · {/if}{when(note.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{#if note.task_id && note.project_id}
|
||||
<a class="go" href="/projekt/{note.project_id}">Åbn</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if unread > 0}
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={markAllRead}>Markér alle som læst</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if version}
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Version</h2>
|
||||
</header>
|
||||
|
||||
<p class="hint">
|
||||
Kører <span class="mono">{version.current}</span>.
|
||||
{#if version.disabled}
|
||||
Der bliver ikke set efter opdateringer.
|
||||
{:else if version.update_available}
|
||||
<strong>{version.latest} er ude.</strong>
|
||||
{:else if version.checked_at}
|
||||
Ingen nyere version fundet.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if version.update_available}
|
||||
{#if version.notes}
|
||||
<p class="notes">{version.notes}</p>
|
||||
{/if}
|
||||
{#if version.url}
|
||||
<div class="row">
|
||||
<a class="release" href={version.url} target="_blank" rel="noreferrer noopener">
|
||||
Se udgivelsen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* An accent bar rather than a bold row: the list is read top to bottom, and
|
||||
weight changes make it look ragged. */
|
||||
li.unread {
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.go,
|
||||
.release {
|
||||
flex: none;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.release {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.notes {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script>
|
||||
/**
|
||||
* Personal API tokens.
|
||||
*
|
||||
* The one screen in the app that shows a secret. It is shown exactly once, and
|
||||
* the copy has to say so — the server keeps a hash, so there is no later screen
|
||||
* that could reveal it even if this one offered to.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let tokens = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let name = $state('');
|
||||
let expiresInDays = $state(0);
|
||||
let errors = $state({});
|
||||
let creating = $state(false);
|
||||
|
||||
/** The plaintext of the token just minted. Held only in this variable. */
|
||||
let fresh = $state(null);
|
||||
let copied = $state(false);
|
||||
let connectorCopied = $state(false);
|
||||
|
||||
/** What Claude's connector dialog needs, which is a URL and nothing else. */
|
||||
let connectorURL = $derived(fresh ? `${location.origin}/mcp?key=${fresh.token}` : '');
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.listTokens()
|
||||
.then((r) => (tokens = r.tokens))
|
||||
.catch((e) => app.toast(humanMessage(e)))
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
|
||||
async function create(event) {
|
||||
event.preventDefault();
|
||||
errors = {};
|
||||
creating = true;
|
||||
try {
|
||||
const created = await api.createToken(name.trim(), Number(expiresInDays));
|
||||
fresh = created;
|
||||
copied = false;
|
||||
name = '';
|
||||
expiresInDays = 0;
|
||||
tokens = [{ ...created, token: undefined }, ...tokens];
|
||||
} catch (e) {
|
||||
errors = e.fields ?? {};
|
||||
if (!Object.keys(errors).length) app.toast(humanMessage(e));
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fresh.token);
|
||||
copied = true;
|
||||
} catch {
|
||||
// Insecure context, or permission refused. The token is on screen and
|
||||
// selectable, so this is a convenience that failed, not the feature.
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConnector() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(connectorURL);
|
||||
connectorCopied = true;
|
||||
} catch {
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token) {
|
||||
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
||||
return;
|
||||
|
||||
const previous = tokens;
|
||||
tokens = tokens.filter((t) => t.id !== token.id);
|
||||
try {
|
||||
await api.deleteToken(token.id);
|
||||
} catch (e) {
|
||||
tokens = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const date = (iso) =>
|
||||
iso ? new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' }) : null;
|
||||
|
||||
const expired = (iso) => iso && new Date(iso) < new Date();
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>API-tokens</h2>
|
||||
<p class="hint">
|
||||
Det, et script, en CalDAV-klient eller en MCP-forbindelse logger ind med. En
|
||||
token bærer dine rettigheder præcist — den kan intet, du ikke selv kan.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if fresh}
|
||||
<div class="fresh">
|
||||
<p class="hint">
|
||||
<strong>Kopiér den nu.</strong> Den vises kun denne ene gang — serveren har
|
||||
kun dens hash.
|
||||
</p>
|
||||
<p class="mono value">{fresh.token}</p>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={copy}>Kopiér</button>
|
||||
{#if copied}<span class="saved">Kopieret.</span>{/if}
|
||||
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
|
||||
</div>
|
||||
|
||||
<!-- Shown here and nowhere else, because it contains the token, and the
|
||||
token exists only in this moment. Claude's connector dialog takes a
|
||||
URL and nothing else — there is no field for a header — so this is
|
||||
the form it has to be in. -->
|
||||
<div class="also">
|
||||
<p class="hint">
|
||||
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
|
||||
i Claude? Så er det denne adresse, dialogen skal have:
|
||||
</p>
|
||||
<p class="mono value">{connectorURL}</p>
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
|
||||
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={create}>
|
||||
<div class="field">
|
||||
<label for="token-name">Navn</label>
|
||||
<input
|
||||
id="token-name"
|
||||
bind:value={name}
|
||||
placeholder="fx min bærbare, eller backup-scriptet"
|
||||
aria-invalid={errors.name ? 'true' : undefined}
|
||||
/>
|
||||
{#if errors.name}<p class="error">{errors.name}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="token-expiry">Udløber</label>
|
||||
<select id="token-expiry" bind:value={expiresInDays}>
|
||||
<option value={0}>Aldrig</option>
|
||||
<option value={30}>Om 30 dage</option>
|
||||
<option value={90}>Om 90 dage</option>
|
||||
<option value={365}>Om et år</option>
|
||||
</select>
|
||||
<p class="hint">
|
||||
En kalenderklient, der har abonneret i årevis, har brug for
|
||||
“aldrig”. Et script, du kører én gang, har ikke.
|
||||
</p>
|
||||
{#if errors.expires_in_days}<p class="error">{errors.expires_in_days}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={creating}>Lav en token</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">…</p>
|
||||
{:else if tokens.length === 0}
|
||||
<p class="empty">Ingen tokens endnu.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each tokens as token (token.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="token-name">{token.name}</span>
|
||||
<span class="meta mono">
|
||||
{token.prefix}…
|
||||
{#if token.last_used_at}
|
||||
· sidst brugt {date(token.last_used_at)}
|
||||
{:else}
|
||||
· aldrig brugt
|
||||
{/if}
|
||||
{#if token.expires_at}
|
||||
· {expired(token.expires_at) ? 'udløbet' : 'udløber'}
|
||||
{date(token.expires_at)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="revoke" onclick={() => revoke(token)} aria-label="Tilbagekald {token.name}">
|
||||
Tilbagekald
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.fresh {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
padding: var(--s4);
|
||||
background: var(--accent-sunken);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow-wrap: anywhere;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.also {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.also a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.revoke {
|
||||
flex: none;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.revoke:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
import BoardView from '$lib/components/BoardView.svelte';
|
||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||
@@ -168,18 +168,14 @@
|
||||
<CalendarView />
|
||||
{:else}
|
||||
<section>
|
||||
{#each unsectioned as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
||||
</section>
|
||||
|
||||
{#each sections as section (section.id)}
|
||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||
<section>
|
||||
<h2>{section.name}</h2>
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* verdande's service worker.
|
||||
*
|
||||
* It exists for Web Push and nothing else. There is deliberately no offline cache:
|
||||
* the app is a live view of a task list that other people are editing, and a cached
|
||||
* shell serving yesterday's data is worse than a browser saying it cannot connect.
|
||||
* Adding one later means versioning and invalidating it, which is a real feature
|
||||
* rather than a few lines here.
|
||||
*/
|
||||
|
||||
// Take over as soon as the browser is willing. Without these two, the first push
|
||||
// after granting permission arrives with no worker in control to show it.
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
// A push whose body is not our JSON still deserves to be shown: browsers
|
||||
// display their own "site updated in the background" notice otherwise, which
|
||||
// looks like a bug in the app.
|
||||
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
||||
}
|
||||
|
||||
const title = payload.title || 'verdande';
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: payload.body || '',
|
||||
icon: '/icon.svg',
|
||||
badge: '/icon.svg',
|
||||
// The tag collapses repeats: five comments on one task should replace each
|
||||
// other rather than stack into five separate notifications.
|
||||
tag: payload.tag || payload.url || 'verdande',
|
||||
data: { url: payload.url || '/' }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const target = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clientList = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
});
|
||||
|
||||
// Focus a tab that already has the app open rather than opening a second
|
||||
// one. Somebody with the app pinned does not want a new tab per reminder.
|
||||
for (const client of clientList) {
|
||||
if (new URL(client.url).origin === self.location.origin) {
|
||||
await client.focus();
|
||||
if ('navigate' in client) await client.navigate(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(target);
|
||||
})()
|
||||
);
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
// vite.config.js
|
||||
import { sveltekit } from "file:///Users/kw/Documents/Code/ToDoApp/web/node_modules/@sveltejs/kit/src/exports/vite/index.js";
|
||||
var vite_config_default = {
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// In development the app runs on 5173 and the API on 8080. Proxying rather
|
||||
// than pointing fetch at another origin keeps the session cookie
|
||||
// first-party, which is what production looks like — otherwise every
|
||||
// cookie and CSRF behaviour would differ between dev and the real thing.
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: false,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9rdy9Eb2N1bWVudHMvQ29kZS9Ub0RvQXBwL3dlYi92aXRlLmNvbmZpZy5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDUxNzMsXG5cdFx0Ly8gSW4gZGV2ZWxvcG1lbnQgdGhlIGFwcCBydW5zIG9uIDUxNzMgYW5kIHRoZSBBUEkgb24gODA4MC4gUHJveHlpbmcgcmF0aGVyXG5cdFx0Ly8gdGhhbiBwb2ludGluZyBmZXRjaCBhdCBhbm90aGVyIG9yaWdpbiBrZWVwcyB0aGUgc2Vzc2lvbiBjb29raWVcblx0XHQvLyBmaXJzdC1wYXJ0eSwgd2hpY2ggaXMgd2hhdCBwcm9kdWN0aW9uIGxvb2tzIGxpa2UgXHUyMDE0IG90aGVyd2lzZSBldmVyeVxuXHRcdC8vIGNvb2tpZSBhbmQgQ1NSRiBiZWhhdmlvdXIgd291bGQgZGlmZmVyIGJldHdlZW4gZGV2IGFuZCB0aGUgcmVhbCB0aGluZy5cblx0XHRwcm94eToge1xuXHRcdFx0Jy9hcGknOiB7XG5cdFx0XHRcdHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MCcsXG5cdFx0XHRcdGNoYW5nZU9yaWdpbjogZmFsc2UsXG5cdFx0XHRcdHdzOiB0cnVlXG5cdFx0XHR9XG5cdFx0fVxuXHR9XG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4UixTQUFTLGlCQUFpQjtBQUV4VCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUEsSUFLTixPQUFPO0FBQUEsTUFDTixRQUFRO0FBQUEsUUFDUCxRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||
Reference in New Issue
Block a user