Compare commits

..
12 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 afc46a5c20 fix(api): do the things the API says yes to
Release / Build and publish image (push) Failing after 9m58s
Three reports from somebody driving the app through the MCP connector,
all the same shape: an argument accepted, an OK returned, and nothing
done.

**project_id on an update never moved the task.** Both the REST handler
and the MCP tool read it, both checked the caller's rights on the
destination — which reads as "understood" — and then neither passed it
on, because store.TaskUpdate has no such field: a project change is a
move, since sort_order is per project. So the task stayed where it was
and the answer said OK. Moving a task between projects was impossible
except by creating it again and closing the old one, which is what the
person ended up doing. Both paths now go through one helper, so they
cannot drift apart again.

**An unknown #project vanished without a word.** The parser reads
"#3Dekoration" correctly; the lookup then fails, the task goes to the
Inbox, and the name is gone from the title with nothing anywhere saying
why. Still not an error — a thought should not be thrown away over a typo
— but quick-add now returns unknown_project, and the MCP tool returns a
note written for a model to relay. The old behaviour was kind in the web
interface, where you see the row appear in the wrong place and fix it. In
a chat nobody sees the row.

**Sub-tasks were unreachable over MCP.** create_task had no parent_id,
though the REST API has had it all along, so importing a parent with
seven children produced eight unrelated tasks with the parent's name
prefixed onto each. A sub-task joins its parent's project — the two
cannot disagree — and a second level is refused rather than silently
flattened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:24:58 +02:00
Kristian WindandClaude Opus 5 8c748c2a88 feat(sidebar): drag it wider, fold it away, and have it stay that way
The sidebar was 268px and always there. On a wide monitor that wastes the
room; on a laptop it takes room the list needs.

The width drags from a handle on the border — 7px of target over a 1px
line, because a 1px target is a target you miss — and the whole column
folds away from a button in the topbar. Both are remembered.

In localStorage rather than on the account: this is a property of the
screen you are sitting at, not of you. The same person on a laptop and a
wide monitor wants different answers, and syncing it would make one of
them wrong every time they switch.

The handle is also a slider for the keyboard. Arrow keys move it, Home
puts it back, and so does a double-click — a drag handle with no keyboard
equivalent is a setting some people simply cannot reach.

Folding is desktop-only, and the media query says so rather than leaving
it to chance: below the breakpoint the sidebar is a drawer that opens over
the page, and a collapsed drawer would open to nothing. Clamped to
200–480px in the store, so a stored value from an older build cannot make
it unusable either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:09:16 +02:00
Kristian WindandClaude Opus 5 c4e9e1519b feat(projects): drag the sidebar into the order you want
Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.

POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.

Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:59:31 +02:00
Kristian WindandClaude Opus 5 3f4af6c0ab fix: stop the app shell answering for /.well-known/
Release / Build and publish image (push) Failing after 9m56s
Adding the connector to Claude failed with "Couldn't register with
Verdande's sign-in service", on a server that has no sign-in service and
needs none.

Claude probes /.well-known/oauth-authorization-server before connecting to
an MCP server. The SPA fallback answered 200 with a page of HTML — which
reads as "yes, there is an authorization server here" — so it went on to
dynamic client registration and failed there instead of concluding the
obvious thing, which is that this server does not use OAuth and the key in
the URL is the whole credential.

/.well-known/ is reserved by RFC 8615 and is never a route in this app. A
404 is the true answer, and it is also the useful one: the client stops
asking and uses the URL as given.

This is the third time the catch-all has caused a confusing failure by
saying 200 to something that does not exist — /mcp itself was the second.
The fallback should hand back the shell for routes the app might have, not
for every path on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:21:35 +02:00
Kristian WindandClaude Opus 5 7ab68b0b58 fix(projects): keep the heading, and make the sidebar test mean what it says
Renaming replaced the project page's h1 with an input, so a page an owner
opened had no heading at all. The sidebar smoke test caught it on CI — and
it was right to: every other view here has a heading, and it is what a
screen reader announces on arrival.

The heading stays and becomes a field when clicked. Same rename, one more
click, and the page keeps its outline.

The more interesting half is why that passed locally. The test clicked a
link and immediately asserted a heading was visible — and `toBeVisible`
was being satisfied by the heading of the page it had just left, before
the SPA swapped it out. On this laptop the old h1 lingered long enough to
answer; on a slower runner it did not. So the test did not check that the
new page rendered, it checked that some page had at some point.

waitForURL closes that. Verified by putting the broken heading back with
the strengthened test in place: it now fails locally with the same message
CI gave.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:10:00 +02:00
Kristian WindandClaude Opus 5 8a6e1bc39e feat(projects): rename, delete, and a trash you can actually reach
A project could be created and shared and never renamed or deleted. Both
have worked through the API since the beginning; there was simply nothing
to click.

The heading is now the field — a heading that happens to be editable
rather than a form that looks like one — and saves on blur, with Escape
putting the old name back. Owner only, and the Inbox keeps its plain
heading because it cannot be either renamed away or deleted.

Deleting needed more than a button. It is soft, with thirty days to
change your mind, and restoring has been possible the whole time — from
an id, which is exactly what you no longer have once the project has gone
from the screen. A recovery window nobody can reach is not one. So
GET /trash/projects lists what you have deleted, with how many tasks went
with it and how long is left, and Settings → Data has the button.

Owned, not shared: a member of a project somebody else deleted has no
business bringing it back, and there is a test that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:02:41 +02:00
Kristian WindandClaude Opus 5 0dbea0ea4b feat: make it possible to see which version is actually running
Release / Build and publish image (push) Failing after 9m56s
"I have updated and restarted, but I cannot tell whether it worked" is
not a question the panel could answer. The version was on the startup
line and in the settings page, and nowhere you can reach from outside.

/healthz carries it now. That is the one endpoint answering without a
session, which makes it the only way to ask the question from a terminal
or from the panel itself. The "listening" line repeats it too, because
that is the line the Rune's done_regex matches and therefore the one
sitting at the bottom of the console after a deploy.

The update notice could also never have worked. It reads GitHub's
releases/latest, and this repository had only tags — a tag is not a
release, and that endpoint answers 404 for a repository without any. The
release workflow creates one now, after the image is pushed so a release
never announces a version that failed to build.

VERDANDE_UPDATE_CHECK is a Rune variable, so it can be switched on
without editing the container. Still off by default: it is an outbound
request, and that is a choice to make rather than a default to inherit.

Rune version 1 -> 2, so the panel offers the update.

Note that the check still cannot see anything while the repository is
private — releases/latest is 404 anonymously, exactly as the GHCR package
was. Making the repo public, or teaching the check to carry a token, is a
separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:35:36 +02:00
Kristian WindandClaude Opus 5 78ec9b08a1 fix: say the true thing when Gmail, AI or mail is not set up
Two bugs with one shape: the interface confidently told you something
that was not so.

Connecting Gmail without a registered OAuth client said "Det er der
allerede." Thirteen different situations answered a plain `conflict`, and
the frontend renders that as "that already exists" — which for four of
them is not vague but wrong. An unconfigured Gmail is not a Gmail that is
already connected; a second factor that is off is not one that is on; the
Inbox is not a project that already exists somewhere else. Those four
have their own codes now, and their own sentences.

The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM
defaults to verdande@localhost and the domain was taken from it
unconditionally, so an instance with no mail server handed out an address
that looks real, can never receive anything, and gives no hint which of
the two it is. The default no longer wins over the public hostname, and
the response now carries whether a mail server exists at all — because
even with the right domain, nothing routes to it until one does.

errors.go has always claimed the code list is kept in one place "so the
frontend's mapping can be checked against it". Nothing checked it, and
the mapping was missing bad_request. Now a test reads both files and
fails on a code with no message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:35:36 +02:00
Kristian WindandClaude Opus 5 7e28d8bb92 chore: untrack a vite temp file, and stop it coming back
Release / Build and publish image (push) Failing after 9m56s
vite.config.js.timestamp-*.mjs is written while bundling the config and
removed again — unless the process is killed, which is exactly what
happens during a hung build. One had been committed by accident; five
more appeared while working out why the build was hanging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 e63d852847 feat(mcp): let the connector address carry its own key
verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.

Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.

So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.

It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.

The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 d7e250f860 docs(rune): a rune does not update itself, and the server need not be deleted
Two corrections after checking against Yggdrasil's own behaviour rather
than guessing at it.

The advice to delete the failed server and make another was wrong. The
image reference is read from the rune at container-create time, and both
Start and Restart recreate the container — so once the image can be
fetched, pressing Start on the server you already have is enough.

And a rune does not pick up a changed manifest on its own. The panel
writes the rune row from a UI action only; neither a restart nor a timer
does it. So a new manifest needs Runes → verdande → Update, and the
catalogue list is cached in memory for ten minutes, which is long enough
that two attempts in a row can both hand back the old version.

The host-pull fallback now says what it costs: the panel re-pulls on
every recreate, fails quietly, and carries on with the local image, so a
new release looks like it rolled out when it did not. It is a stopgap for
keeping the package private, not a way to run this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:27:02 +02:00
Kristian WindandClaude Opus 5 a8a29a963d docs: the registry has to be public, and docker login was the wrong advice
Installing the Rune still failed after v0.2.0 was published, with the same
message as before:

    create container: Error response from daemon:
    No such image: ghcr.io/kristianwind/verdande:latest

The advice in these pages — log the host's Docker daemon in — cannot
work, and Yggdrasil's source says why. Its PullImage calls the Docker
SDK's ImagePull with an empty image.PullOptions{}; RegistryAuth is never
set, and there are no registry credentials anywhere in the panel. A
private image answers 401. pullImageRetry discards that error on purpose
("second failure surfaces at create"), and ContainerCreate — which only
ever looks locally — then reports the image as missing. The message is a
liar: the tag is in the registry the whole time.

docker login does not change this. It is client-side: the daemon stores
no credentials, the CLI sends them itself with each request, and
Yggdrasil is a different client that sends none.

So the package has to be public, and the pages now say that rather than
offering a login that does nothing. Keeping it private is still possible
but is now described as what it is: putting the image on the host by hand
before creating the server and again after every release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:55:56 +02:00
32 changed files with 2013 additions and 93 deletions
+15 -1
View File
@@ -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
+5
View File
@@ -40,3 +40,8 @@ node_modules/
# 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
-8
View File
@@ -126,14 +126,6 @@ and the manifest ships in this repository. It runs anywhere Docker does as well.
### With Docker directly
The package is private, and an anonymous pull fails with `No such image` rather
than with a permission error. Log the host's Docker daemon in once, with a GitHub
token carrying the `read:packages` scope:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
```
```bash
docker run -d --name verdande \
-p 8080:8080 \
+5 -1
View File
@@ -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
}
+6
View File
@@ -52,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. |
@@ -96,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
### Views, filters and labels
+27 -4
View File
@@ -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 |
+156 -3
View File
@@ -533,7 +533,11 @@ paths:
type: string
description: Where it goes when the text names no project.
responses:
"201": { $ref: "#/components/responses/Task" }
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/QuickAddResult" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/tasks/quick-add/preview:
@@ -756,6 +760,35 @@ paths:
"201": { $ref: "#/components/responses/Project" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/reorder:
post:
tags: [projects]
summary: Set the order projects appear in.
description: |
The whole list at once rather than one move at a time. A person has a
handful of projects, so sending the order you want is simpler than
midpoints and cannot land half-applied.
Projects you do not own are skipped: `sort_order` is a column on the
project rather than a preference per viewer, so a shared one stays where
its owner put it.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items: { type: string }
description: Project ids, top to bottom.
responses:
"204": { description: Reordered. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
@@ -785,6 +818,42 @@ paths:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/trash/projects:
get:
tags: [projects]
summary: Projects you have deleted and can still bring back.
description: |
Only your own. Restoring has been possible since the trash existed, but
the endpoint takes an id — and the id is what you no longer have once the
project has gone from the interface.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
projects:
type: array
items:
allOf:
- $ref: "#/components/schemas/Project"
- type: object
properties:
deleted_at: { type: string, format: date-time }
purge_after:
type: string
format: date-time
description: |
When it stops being recoverable. Sent rather than
left for the client to work out, because the
retention window is the server's and a second
copy of it will one day disagree.
task_count:
type: integer
description: Tasks that went with it, and would come back with it.
/api/v1/trash/projects/{projectID}/restore:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
@@ -1718,7 +1787,15 @@ paths:
broken volume reports unhealthy instead of pretending to work.
security: []
responses:
"200": { description: Healthy. }
"200":
description: Healthy. Carries the running version — the one thing you can ask without a session.
content:
application/json:
schema:
type: object
properties:
status: { type: string, const: ok }
version: { type: string, example: v0.3.1 }
"503": { description: Unhealthy. }
/ics/{token}:
@@ -1787,6 +1864,53 @@ paths:
responses:
"302": { description: "Back into the app, with the outcome as a query parameter." }
/mcp:
post:
tags: [integrations]
summary: The MCP endpoint, with the token in the query string.
description: |
The same server as `/api/v1/mcp`, for a client that can only be given a
URL. Claude's custom-connector dialog asks for a name, an address and
OAuth client credentials — there is no field for a bearer token — so the
API path cannot be configured from it.
The key is the only credential accepted here. A session cookie is
ignored: this route sits outside the CSRF check, and honouring a cookie
would make it a cross-site request that acts as whoever is signed in.
security: []
parameters:
- name: key
in: query
required: true
schema: { type: string, example: vrd_… }
description: A personal API token. Treat the whole address as a password.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [jsonrpc, method]
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
method: { type: string }
params: { type: object }
responses:
"200":
description: A JSON-RPC response.
content:
application/json:
schema:
type: object
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
result: { type: object }
error: { type: object }
"202": { description: A notification. No body. }
"401": { $ref: "#/components/responses/Unauthorized" }
/.well-known/caldav:
get:
tags: [integrations]
@@ -1899,6 +2023,13 @@ components:
type: object
properties:
address: { type: string, example: todo+abc123@example.dk }
configured:
type: boolean
description: |
Whether a mail server is set up at all. The address is real
either way, but with no mail server nothing on the internet
routes to it — and an interface that does not say so is
promising something that will not happen.
Gmail:
description: OK
content:
@@ -1948,6 +2079,10 @@ components:
- rate_limited
- internal_error
- payload_too_large
- gmail_not_configured
- ai_not_configured
- totp_not_enabled
- inbox_protected
error: { type: string }
fields:
type: object
@@ -2004,7 +2139,12 @@ components:
TaskInput:
type: object
properties:
project_id: { type: string, description: "Omitted on create, the task goes to the Inbox." }
project_id:
type: string
description: |
Omitted on create, the task goes to the Inbox. On update it moves the
task — which needs editor rights on the destination.
section_id: { type: string }
parent_id: { type: string }
content: { type: string }
@@ -2020,6 +2160,19 @@ components:
items: { type: string }
sort_order: { type: number }
QuickAddResult:
allOf:
- $ref: "#/components/schemas/Task"
- type: object
properties:
unknown_project:
type: string
description: |
Present when the text named a `#project` that does not exist. The
task is still created, in the Inbox — a thought is not thrown away
over a typo — but the caller is told, because the alternative is
that the name vanishes from the title and nothing says why.
ParsedTask:
type: object
description: What the quick-add parser made of a line.
+46 -16
View File
@@ -3,33 +3,53 @@
verdande ships with a Rune manifest for
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
## Before you start: the registry is private
The image lives at `ghcr.io/kristianwind/verdande` and the package is **private**,
so an anonymous pull fails with a message that reads as though the image does not
exist at all:
## If the image will not pull
```
create container: Error response from daemon:
No such image: ghcr.io/kristianwind/verdande:latest
```
That is a permission error wearing the wrong hat. The Docker daemon **on the
Yggdrasil host** has to be logged in once:
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
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
sudo docker login ghcr.io -u kristianwind
sudo docker pull ghcr.io/kristianwind/verdande:latest
```
`GHCR_TOKEN` is a GitHub personal access token with the **`read:packages`** scope
and nothing else. The login is stored in `~/.docker/config.json` for whichever
user runs the daemon — if Yggdrasil runs as root or through systemd, log in as
that user, not as yourself.
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.
!!! tip "Or make the package public"
Under **Packages → verdande → Package settings → Change visibility**. The
repository can stay private; only the built image becomes readable. Then no
login is needed anywhere and this section stops applying.
!!! 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
@@ -40,6 +60,16 @@ that user, not as yourself.
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 |
+6 -9
View File
@@ -5,15 +5,12 @@ cache, no queue and no second process.
## Docker
!!! warning "Log in to the registry first"
The package is private, and an anonymous pull fails with `No such image`
rather than with a permission error. Once per host:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
```
`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
!!! 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 \
+149
View File
@@ -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)
}
}
+11
View File
@@ -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) {
+2 -2
View File
@@ -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
}
+27 -7
View File
@@ -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
}
+382
View File
@@ -771,3 +771,385 @@ 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 = "verdande@firma.dk"
})
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")
}
}
// A 200 with the app shell is an answer that says "yes, that exists" to anything
// that asks. For /.well-known/ that is actively harmful: it is a reserved
// namespace, and a client probing it is asking a yes-or-no question.
//
// Claude asks /.well-known/oauth-authorization-server before connecting to an
// MCP server. It read the shell as "there is an authorization server here" and
// then failed trying to register with it, reporting a broken sign-in service on
// a server that has none and needs none.
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, path := range []string{
"/.well-known/oauth-authorization-server",
"/.well-known/oauth-protected-resource",
"/.well-known/openid-configuration",
"/.well-known/anything-at-all",
} {
resp, err := ts.client.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
}
if strings.Contains(string(body), "<!doctype html") {
t.Errorf("%s: answered with the app shell", path)
}
}
}
// The one well-known path that is real still works — it is a route, not a
// fallthrough, so the rule above must not have swallowed it.
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Get(ts.URL + "/.well-known/caldav")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
t.Error("CalDAV discovery was caught by the well-known rule")
}
}
// --- what the API accepts, it must actually do -------------------------------------
// project_id on an update used to be validated and then dropped: the destination
// was permission-checked, which reads as "understood", and the task never moved
// while the answer said OK. Both the REST path and the MCP tool did it.
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
taskID := task["id"].(string)
if task["project_id"] == projectID {
t.Fatal("the task started in the destination; the test proves nothing")
}
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"project_id": projectID})
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
}
if updated["project_id"] != projectID {
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
updated["project_id"], projectID)
}
// And it really is there, not merely reported as there.
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if reread["project_id"] != projectID {
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
}
}
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
projectID := project["id"].(string)
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
taskID := created["id"].(string)
moved := ts.callTool(t, "update_task", map[string]any{
"task_id": taskID, "project_id": projectID,
})
if moved["project_id"] != projectID {
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
}
}
// A project you cannot write to is refused rather than quietly ignored.
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
taskID := task["id"].(string)
mine := task["project_id"]
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"project_id": theirs["id"]})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if reread["project_id"] != mine {
t.Error("the task moved into somebody else's project")
}
}
// An unknown #project is still not an error — the thought is kept rather than
// thrown away over a typo — but it is no longer silent. It used to vanish from
// the title while the task went to the Inbox, with nothing saying why.
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Lav plakat #3Dekoration"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
}
if body["unknown_project"] != "3Dekoration" {
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
}
if body["content"] != "Lav plakat" {
t.Errorf("content = %v", body["content"])
}
}
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Lav plakat #Dekoration"})
if _, present := body["unknown_project"]; present {
t.Errorf("reported an unknown project for one that exists: %v", body)
}
}
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
note, _ := created["note"].(string)
if !strings.Contains(note, "3Dekoration") {
t.Errorf("note = %q, want it to name the project that does not exist", note)
}
}
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
// with seven children arrived as eight unrelated tasks.
func TestMCPCreateMakesSubtasks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
parent := ts.callTool(t, "create_task", map[string]any{
"content": "Runen v2", "project_id": project["id"],
})
parentID := parent["id"].(string)
child := ts.callTool(t, "create_task", map[string]any{
"content": "Tegn ikonet", "parent_id": parentID,
})
if child["parent_id"] != parentID {
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
}
// A sub-task joins its parent's project whatever else was asked for.
if child["project_id"] != project["id"] {
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
}
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
}
}
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
child := ts.callTool(t, "create_task", map[string]any{
"content": "Tegn ikonet", "parent_id": parent["id"],
})
// A tool that refuses reports it inside the result with isError, not as a
// JSON-RPC error — the call itself was well formed, the arguments were not.
response := ts.rpc(t, "tools/call", map[string]any{
"name": "create_task",
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
})
result, _ := response["result"].(map[string]any)
if result == nil || result["isError"] != true {
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
}
}
+89 -4
View File
@@ -3,11 +3,14 @@ package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"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"
@@ -55,7 +58,8 @@ func (s *Server) buildMCP() *mcp.Server {
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
@@ -124,6 +128,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) {
@@ -215,6 +255,7 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
Text string `json:"text"`
Content string `json:"content"`
ProjectID string `json:"project_id"`
ParentID string `json:"parent_id"`
DueDate string `json:"due_date"`
Priority int `json:"priority"`
Description string `json:"description"`
@@ -230,6 +271,11 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
task := &store.Task{CreatedBy: userID, Priority: 4}
labels := params.Labels
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
// title while the task went to the Inbox, with nothing anywhere saying so —
// invisible in a chat, where nobody sees the row that was written.
unknownProject := ""
if text := strings.TrimSpace(params.Text); text != "" {
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
task.Content = parsed.Content
@@ -242,6 +288,8 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
if parsed.Project != "" && params.ProjectID == "" {
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
task.ProjectID = id
} else {
unknownProject = parsed.Project
}
}
}
@@ -267,6 +315,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
if params.ProjectID != "" {
task.ProjectID = params.ProjectID
}
// A sub-task belongs to its parent's project, whatever else was asked for:
// the two cannot disagree, and the parent is the more specific instruction.
// Without this the tools could not build a tree at all — an import of a
// parent with seven children arrived as eight unrelated tasks.
if params.ParentID != "" {
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
if err != nil {
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
}
if parent.ParentID != "" {
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
}
task.ParentID = parent.ID
task.ProjectID = parent.ProjectID
}
if task.ProjectID == "" {
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
return nil, err
@@ -281,10 +346,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
}
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
return map[string]any{
out := map[string]any{
"id": task.ID, "content": task.Content, "priority": task.Priority,
"due_date": task.DueDate, "project_id": task.ProjectID,
}, nil
}
if task.ParentID != "" {
out["parent_id"] = task.ParentID
}
if unknownProject != "" {
// Phrased for a model to relay: it says what happened and what to do,
// so the person is told rather than left with a task in the wrong place.
out["note"] = fmt.Sprintf(
"There is no project called %q, so this went to the Inbox. "+
"Create the project and move it, or say which project you meant.",
unknownProject)
}
return out, nil
}
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
@@ -326,8 +404,11 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
if params.Labels != nil {
update.SetLabels, update.Labels = true, *params.Labels
}
// Moved before the field update, and through the same helper the REST handler
// uses. This used to check the caller's rights on the destination and then
// drop the argument, so the task never moved and the answer still said OK.
if params.ProjectID != nil {
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
}
}
@@ -341,9 +422,13 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
}
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
// project_id is in the answer because it is one of the things that can be
// changed here: a caller that asked for a move can see whether it happened
// rather than taking "OK" on trust.
return map[string]any{
"id": task.ID, "content": task.Content, "priority": task.Priority,
"due_date": task.DueDate, "labels": task.Labels,
"project_id": task.ProjectID,
}, nil
}
+67 -1
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -68,6 +69,39 @@ type projectRequest struct {
Archived *bool `json:"archived"`
}
type reorderProjectsRequest struct {
// IDs in the order they should appear, top to bottom.
IDs []string `json:"ids"`
}
// handleReorderProjects sets the sidebar order.
//
// The whole list rather than one move: a person has a handful of projects, and
// sending the order you want is both simpler to reason about and impossible to
// get into the half-ordered state a sequence of individual moves can produce if
// one of them fails.
//
// Projects the caller does not own are skipped — sort_order is a column on the
// project, so a shared one sits where its owner put it.
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
// A ceiling rather than a guess at what is reasonable: this is a bound on
// the transaction, not an opinion about how many projects somebody may have.
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
s.internal(w, "reorder projects", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
var req projectRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -141,7 +175,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)
@@ -151,6 +185,38 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
type trashedProjectJSON struct {
projectJSON
DeletedAt string `json:"deleted_at"`
// PurgeAfter is when it stops being recoverable. Sent rather than left for
// the frontend to work out, because the retention window is the server's and
// a copy of it in the interface is a copy that will one day disagree.
PurgeAfter string `json:"purge_after"`
TaskCount int `json:"task_count"`
}
// handleListTrashedProjects is what makes restoring reachable. The restore
// endpoint has always existed; without this, using it meant knowing the id of
// something that had already vanished from the interface.
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list trashed projects", err)
return
}
out := make([]trashedProjectJSON, 0, len(trashed))
for _, t := range trashed {
out = append(out, trashedProjectJSON{
projectJSON: toProjectJSON(t.Project),
DeletedAt: t.DeletedAt.Format(time.RFC3339),
PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339),
TaskCount: t.TaskCount,
})
}
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
}
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
// Permission cannot be checked through ProjectRole here: a trashed project is
+155
View File
@@ -0,0 +1,155 @@
package httpapi
import (
"net/http"
"testing"
)
// Restoring has been possible since the trash existed. Finding what to restore
// has not: the endpoint takes an id, and the id is exactly what you no longer
// have once the project has gone from the interface.
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
id := created["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
// Gone from the ordinary listing.
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
for _, raw := range live["projects"].([]any) {
if raw.(map[string]any)["id"] == id {
t.Fatal("a deleted project is still in the project list")
}
}
// And findable in the trash, with enough to decide whether to bring it back.
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
list, _ := trash["projects"].([]any)
if len(list) != 1 {
t.Fatalf("want one project in the trash, got %v", list)
}
entry := list[0].(map[string]any)
if entry["name"] != "Ferie" {
t.Errorf("name = %v", entry["name"])
}
if entry["task_count"] != float64(1) {
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
entry["task_count"])
}
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
t.Errorf("no deadline was reported: %v", entry)
}
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("restore: status %d", resp.StatusCode)
}
// Back, with its task.
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
t.Errorf("the task did not come back with the project: %v", got)
}
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
if list, _ := trash["projects"].([]any); len(list) != 0 {
t.Errorf("still in the trash after restoring: %v", list)
}
}
// Somebody else's deleted project is not in your trash, and you cannot restore it.
func TestTheTrashIsPerOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
id := created["id"].(string)
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
if list, _ := trash["projects"].([]any); len(list) != 0 {
t.Errorf("somebody else's deleted project is visible: %v", list)
}
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var ids []string
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
ids = append(ids, body["id"].(string))
}
// Back to front.
reversed := []string{ids[2], ids[1], ids[0]}
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
projects, _ := list["projects"].([]any)
var got []string
for _, raw := range projects {
p := raw.(map[string]any)
// The Inbox sorts ahead of everything by its own rule; it is not part of
// what was reordered.
if p["is_inbox"] == true {
continue
}
got = append(got, p["id"].(string))
}
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
t.Errorf("order = %v, want %v", got, reversed)
}
}
// sort_order is a column on the project, not a per-viewer preference, so
// reordering must not rearrange a project somebody else owns.
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
theirID := theirs["id"].(string)
_, before := other.do(t, "GET", "/api/v1/projects", nil)
var wanted float64
for _, raw := range before["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == theirID {
wanted = p["sort_order"].(float64)
}
}
// Ask, as somebody else, to put their project first.
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
"ids": []string{theirID},
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, after := other.do(t, "GET", "/api/v1/projects", nil)
for _, raw := range after["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == theirID {
if p["sort_order"] != wanted {
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
wanted, p["sort_order"])
}
}
}
}
+38 -4
View File
@@ -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.
@@ -247,6 +259,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Route("/projects", func(r chi.Router) {
r.Get("/", s.handleListProjects)
r.Post("/", s.handleCreateProject)
// Above /{projectID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjects)
r.Route("/{projectID}", func(r chi.Router) {
// Read access is the floor for the whole subtree; the writes
@@ -273,9 +287,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
})
// Restoring is outside the block above: a trashed project is invisible
// to the permission check by design, so the handler resolves ownership
// itself.
// The trash is outside the block above: a deleted project is invisible
// to the permission check by design, so these handlers resolve
// ownership themselves.
r.Get("/trash/projects", s.handleListTrashedProjects)
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
r.Route("/sections/{sectionID}", func(r chi.Router) {
@@ -336,7 +351,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
@@ -348,6 +367,21 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
// Handing back the shell there does not merely waste a response: it tells
// whoever asked that the thing they were looking for exists.
//
// That is not hypothetical. Claude probes
// /.well-known/oauth-authorization-server before connecting to an MCP server,
// read 200 and a page of HTML as "there is an authorization server here", and
// then failed trying to register with it — reporting a broken sign-in service
// on a server that has none and needs none. A 404 means "no OAuth here", the
// client stops asking, and the key in the URL is used as intended.
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
if s.web == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
+65
View File
@@ -1,6 +1,8 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
@@ -249,6 +251,16 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
return
}
// A change of project is a move, not a field: it has to renumber the task
// among its new neighbours. Done before the rest so a request that both moves
// and edits ends up entirely applied or entirely refused.
if req.ProjectID != nil {
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
return
}
}
u := store.TaskUpdate{
Description: req.Description, Priority: req.Priority,
SectionID: req.SectionID, ParentID: req.ParentID,
@@ -473,11 +485,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
}
projectID := req.ProjectID
// Set when the text named a project that does not exist. The task is still
// created — see below — but the caller is told, because the alternative is
// what it used to be: "#Dekoration" disappears from the title, the task lands
// somewhere else, and nothing anywhere says why.
unknownProject := ""
if parsed.Project != "" {
// An unknown "#name" is not an error: the task still gets created, in the
// Inbox, rather than a thought being thrown away over a typo.
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
} else {
unknownProject = parsed.Project
}
}
if projectID == "" {
@@ -510,6 +529,16 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
t.Labels = parsed.Labels
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
if unknownProject != "" {
// Alongside the task rather than instead of it: the task was created, and
// this says what could not be honoured while creating it.
writeJSON(w, http.StatusCreated, struct {
taskJSON
UnknownProject string `json:"unknown_project"`
}{toTaskJSON(*t), unknownProject})
return
}
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
}
@@ -525,6 +554,42 @@ func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
// --- helpers --------------------------------------------------------------------
// moveTaskToProject moves a task to another project, if that is really what was
// asked for.
//
// Shared by the REST handler and the MCP tool, because they had drifted: both
// accepted project_id on an update, both checked the caller's rights on the
// destination — which reads as "understood" — and then neither passed it on,
// because store.TaskUpdate has no such field. The task stayed where it was and
// the response said OK. An argument that is validated and then dropped is worse
// than one that is refused.
//
// A move rather than a field assignment: sort_order is per project, so landing
// in a new one means being given a position among its tasks.
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
if projectID == "" {
return nil
}
current, err := s.db.GetTask(ctx, taskID, userID)
if err != nil {
return errors.New("not found")
}
if current.ProjectID == projectID {
return nil // already there; not worth a write or a renumber
}
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
return errors.New("not found")
}
// No neighbours: it lands at the start of the destination, which is the only
// answer available when the caller did not say where. Section is cleared —
// a section belongs to the project being left behind.
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
return errors.New("not found")
}
return nil
}
func validateTaskContent(content string, priority *int) map[string]string {
fields := map[string]string{}
switch {
+1 -1
View File
@@ -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
}
+76
View File
@@ -234,6 +234,55 @@ func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
})
}
// TrashedProject is a deleted project, with when it went and when it goes for good.
type TrashedProject struct {
Project
DeletedAt time.Time
TaskCount int
}
// ListTrashedProjects returns what the caller owns and has deleted.
//
// Restoring has been possible since the trash existed, but only if you already
// knew the project's id — which is exactly what you no longer have once it has
// gone from the interface. A recovery window nobody can reach is not one.
//
// Owned, not shared: a member of a project somebody else deleted has no business
// bringing it back.
func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) {
rows, err := db.QueryContext(ctx, `
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at, p.deleted_at,
(SELECT count(*) FROM tasks t
WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count
FROM projects p
WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL
ORDER BY p.deleted_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TrashedProject{}
for rows.Next() {
var t TrashedProject
var created, updated, deleted int64
var icon sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID,
&t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted,
&t.TaskCount); err != nil {
return nil, err
}
t.Icon = icon.String
t.CreatedAt = time.Unix(created, 0).UTC()
t.UpdatedAt = time.Unix(updated, 0).UTC()
t.DeletedAt = time.Unix(deleted, 0).UTC()
t.Role = RoleOwner
out = append(out, t)
}
return out, rows.Err()
}
// RestoreProject brings a project back, along with the tasks that were deleted with
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
// and separately, and restoring a project should not undo that too.
@@ -507,3 +556,30 @@ func joinComma(parts []string) string {
}
return out
}
// ReorderProjects writes the given order, and only for projects the user owns.
//
// The whole list at once, rather than the midpoint-between-neighbours dance the
// tasks do. A person has a handful of projects, not a thousand, so writing
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
// failure the task ordering has to respace a section to recover from.
//
// Scoped to ownership because sort_order lives on the project row rather than
// per viewer: a shared project sits where its owner put it, and a member
// reordering their sidebar must not rearrange somebody else's. Ids that are not
// the caller's are skipped rather than refused, so one shared project in the
// list does not fail the whole drag.
func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
now := time.Now().Unix()
for i, id := range ids {
if _, err := tx.ExecContext(ctx,
`UPDATE projects SET sort_order = ?, updated_at = ?
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
float64(i), now, id, userID); err != nil {
return err
}
}
return nil
})
}
+9 -1
View File
@@ -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
+65
View File
@@ -82,6 +82,12 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
for (const href of hrefs) {
await sidebar.locator(`a[href="${href}"]`).first().click();
// Wait for the navigation before looking at the page. Without this the
// assertion can be satisfied by the heading of the page you just left —
// which is not a hypothetical: a change that removed a project page's
// heading entirely passed here on a fast machine and failed on CI.
await page.waitForURL(`**${href}`);
// 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.
@@ -179,3 +185,62 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
expect(worker.ok()).toBeTruthy();
expect(await worker.text()).toContain("addEventListener('push'");
});
/**
* A project can be renamed, deleted and brought back.
*
* The round trip matters more than the three steps: deleting used to be a
* one-way door in the interface — the API could restore, but only from an id
* that had already disappeared from the screen.
*/
test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ferie');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
// The heading is the rename affordance: click it, and it becomes a field.
await page.getByRole('button', { name: 'Ferie' }).click();
const title = page.getByLabel('Projektets navn');
await title.fill('Sommerferie');
await title.press('Enter');
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Slet projektet' }).click();
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
// And it is reachable again, which is the half that did not exist.
await page.goto('/indstillinger/data');
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
await expect(trash.getByText('Sommerferie')).toBeVisible();
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* 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');
});
+23 -1
View File
@@ -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;
@@ -156,6 +176,8 @@ export const api = {
createProject: (data) => post('/projects', data),
updateProject: (id, data) => patch(`/projects/${id}`, data),
deleteProject: (id) => del(`/projects/${id}`),
reorderProjects: (ids) => post('/projects/reorder', { ids }),
listTrashedProjects: () => get('/trash/projects'),
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
listSections: (projectId) => get(`/projects/${projectId}/sections`),
+182 -2
View File
@@ -1,5 +1,5 @@
<script>
import { app } from '$lib/stores.svelte.js';
import { app, sidebar } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -33,8 +33,80 @@
});
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared));
// Sorted here rather than relying on the order the server sent, so a drag
// settles the moment it is dropped instead of after the round trip.
let own = $derived(
app.projects
.filter((p) => !p.is_inbox && !p.shared)
.sort((a, b) => a.sort_order - b.sort_order)
);
let current = $derived($page.url.pathname);
// --- resizing -------------------------------------------------------------------
let resizing = $state(false);
/**
* Pointer events rather than mouse events, so a trackpad, a mouse and a stylus
* all work. Capturing the pointer means the drag keeps following even when it
* leaves the handle, which at 4px wide it does constantly.
*/
function startResize(event) {
event.preventDefault();
resizing = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
function onResize(event) {
if (!resizing) return;
sidebar.setWidth(event.clientX);
}
function endResize(event) {
if (!resizing) return;
resizing = false;
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
// --- reordering ---------------------------------------------------------------
let draggingId = $state(null);
let overId = $state(null);
let overBelow = $state(false);
function onDragStart(event, project) {
draggingId = project.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', project.id);
}
function onDragOver(event, project) {
if (!draggingId || draggingId === project.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const box = event.currentTarget.getBoundingClientRect();
overId = project.id;
overBelow = event.clientY > box.top + box.height / 2;
}
async function onDrop(event, target) {
event.preventDefault();
const id = draggingId;
const below = overBelow;
draggingId = null;
overId = null;
if (!id || id === target.id) return;
const without = own.filter((p) => p.id !== id);
const at = without.findIndex((p) => p.id === target.id);
if (at < 0) return;
const ordered = [...without];
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
await app.reorderProjects(ordered.map((p) => p.id));
}
</script>
<nav class="sidebar" aria-label="Hovedmenu">
@@ -90,8 +162,21 @@
{#each own as project (project.id)}
<a
href="/projekt/{project.id}"
class="sortable"
class:active={current === `/projekt/${project.id}`}
class:dragging={draggingId === project.id}
class:drop-above={overId === project.id && !overBelow}
class:drop-below={overId === project.id && overBelow}
onclick={onnavigate}
draggable="true"
ondragstart={(e) => onDragStart(e, project)}
ondragend={() => {
draggingId = null;
overId = null;
}}
ondragover={(e) => onDragOver(e, project)}
ondragleave={() => (overId = null)}
ondrop={(e) => onDrop(e, project)}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
@@ -182,6 +267,29 @@
<span class="signout">Log ud</span>
</button>
</div>
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
A drag handle with no keyboard equivalent is a setting some people simply
cannot reach. -->
<div
class="resize"
class:resizing
role="separator"
aria-orientation="vertical"
aria-label="Bredde på sidebjælken"
tabindex="0"
onpointerdown={startResize}
onpointermove={onResize}
onpointerup={endResize}
onpointercancel={endResize}
ondblclick={() => sidebar.reset()}
onkeydown={(e) => {
if (e.key === 'ArrowLeft') sidebar.setWidth(sidebar.width - 16);
else if (e.key === 'ArrowRight') sidebar.setWidth(sidebar.width + 16);
else if (e.key === 'Home') sidebar.reset();
else return;
e.preventDefault();
}}
></div>
</nav>
<style>
@@ -196,6 +304,45 @@
border-right: 1px solid var(--line);
overflow-y: auto;
padding-left: max(var(--s3), env(safe-area-inset-left));
position: relative;
}
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
The visible line stays the border; this only widens what the pointer hits. */
.resize {
position: absolute;
top: 0;
bottom: 0;
right: -3px;
width: 7px;
cursor: col-resize;
z-index: 10;
}
.resize::after {
content: '';
position: absolute;
inset: 0 3px;
background: var(--accent);
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.resize:hover::after,
.resize:focus-visible::after,
.resize.resizing::after {
opacity: 1;
}
.resize:focus-visible {
outline: none;
}
@media (max-width: 820px) {
/* The drawer has a fixed width and no room to drag against. */
.resize {
display: none;
}
}
.brand {
@@ -280,6 +427,39 @@
font-weight: 500;
}
/* Only your own projects reorder. A shared one sits where its owner put it —
sort_order is a column on the project, not a preference per viewer. */
a.sortable {
position: relative;
}
a.sortable.dragging {
opacity: 0.4;
}
/* A line in the gap rather than a highlighted row: the gap is the target. */
a.sortable::before {
content: '';
position: absolute;
left: var(--s2);
right: var(--s2);
height: 2px;
background: var(--accent);
opacity: 0;
pointer-events: none;
transition: opacity var(--fast) var(--ease);
}
a.sortable.drop-above::before {
top: -1px;
opacity: 1;
}
a.sortable.drop-below::before {
bottom: -1px;
opacity: 1;
}
.dot {
width: 6px;
height: 6px;
+75
View File
@@ -246,6 +246,29 @@ class AppState {
this.projects = projects;
}
/**
* Puts the projects in the given order.
*
* The whole list rather than one moved item: it is a handful of rows, and
* sending the order you want cannot land in the half-applied state a sequence
* of individual moves can. `sort_order` is set here as well as on the server,
* so the sidebar settles immediately rather than after a round trip.
*/
async reorderProjects(ids) {
const previous = this.projects;
const rank = new Map(ids.map((id, i) => [id, i]));
this.projects = this.projects.map((p) =>
rank.has(p.id) ? { ...p, sort_order: rank.get(p.id) } : p
);
try {
await api.reorderProjects(ids);
} catch (e) {
this.projects = previous;
this.toast(humanMessage(e));
}
}
// --- toasts -------------------------------------------------------------------
toast(message) {
@@ -263,6 +286,58 @@ class AppState {
export const app = new AppState();
/**
* How wide the sidebar is, and whether it is showing at all.
*
* Kept in localStorage rather than on the account: it is a property of the
* screen you are sitting at, not of you. The same person on a laptop and on a
* wide monitor wants different answers, and syncing it would make one of those
* two wrong every time they switch.
*/
const SIDEBAR_MIN = 200;
const SIDEBAR_MAX = 480;
const SIDEBAR_DEFAULT = 268;
function storedNumber(key, fallback) {
if (typeof localStorage === 'undefined') return fallback;
const raw = Number(localStorage.getItem(key));
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
class SidebarLayout {
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
collapsed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
);
setWidth(px) {
// Clamped here rather than in the drag handler, so every caller gets the
// same rule and a stored value from an older build cannot make the
// sidebar unusably narrow.
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
this.#save('verdande:sidebar-width', String(this.width));
}
toggle() {
this.collapsed = !this.collapsed;
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
}
reset() {
this.setWidth(SIDEBAR_DEFAULT);
}
#save(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
export const sidebar = new SidebarLayout();
/** Theme, kept in localStorage and applied to the document element. */
export const theme = {
get current() {
+64 -2
View File
@@ -1,6 +1,6 @@
<script>
import '../app.css';
import { app, theme } from '$lib/stores.svelte.js';
import { app, theme, sidebar } from '$lib/stores.svelte.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from '$lib/components/Sidebar.svelte';
@@ -66,11 +66,20 @@
{:else if !app.user}
<SignIn />
{:else}
<div class="shell" class:sidebar-open={sidebarOpen}>
<div
class="shell"
class:sidebar-open={sidebarOpen}
class:sidebar-collapsed={sidebar.collapsed}
style="--sidebar-width: {sidebar.width}px"
>
<Sidebar onnavigate={() => (sidebarOpen = false)} />
<main>
<header class="topbar">
<!-- Two controls, not one: on a phone the sidebar is a drawer that
opens over the page, on a desktop it is a column that folds away.
Same words, different actions, so each is shown only where it
applies. -->
<button
class="menu"
onclick={() => (sidebarOpen = !sidebarOpen)}
@@ -82,6 +91,19 @@
</svg>
</button>
<button
class="fold"
onclick={() => sidebar.toggle()}
aria-label={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
aria-pressed={sidebar.collapsed}
title={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M9 4v16" />
</svg>
</button>
<button class="search" onclick={() => (paletteOpen = true)}>
<span>Søg</span>
<kbd>⌘K</kbd>
@@ -173,7 +195,32 @@
color: var(--ink-muted);
}
.fold {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border-radius: var(--radius);
color: var(--ink-faint);
transition: color var(--fast) var(--ease);
}
.fold:hover {
color: var(--ink);
}
/* Folded away rather than merely narrow: width 0 with the border gone, so
nothing of it is left to catch the eye. */
.shell.sidebar-collapsed :global(.sidebar) {
width: 0;
padding-left: 0;
padding-right: 0;
border-right: 0;
overflow: hidden;
}
.menu svg,
.fold svg,
.theme svg {
width: 18px;
height: 18px;
@@ -277,6 +324,11 @@
display: grid;
}
/* The drawer is the whole answer here; folding a drawer means nothing. */
.fold {
display: none;
}
.scrim {
display: block;
}
@@ -292,5 +344,15 @@
.shell.sidebar-open :global(.sidebar) {
transform: translateX(0);
}
/* A collapsed desktop sidebar must not follow you to a phone, where the
drawer would then open to nothing. */
.shell.sidebar-collapsed :global(.sidebar) {
width: var(--sidebar-width);
padding-left: max(var(--s3), env(safe-area-inset-left));
padding-right: var(--s3);
border-right: 1px solid var(--line);
overflow-y: auto;
}
}
</style>
@@ -101,6 +101,36 @@
}
let exportProject = $state('');
// --- the trash -------------------------------------------------------------
let trashed = $state([]);
let loadingTrash = $state(true);
$effect(() => {
api
.listTrashedProjects()
.then((r) => (trashed = r.projects))
.catch((e) => app.toast(humanMessage(e)))
.finally(() => (loadingTrash = false));
});
async function restore(project) {
const previous = trashed;
trashed = trashed.filter((p) => p.id !== project.id);
try {
await api.restoreProject(project.id);
await app.refreshProjects();
} catch (e) {
trashed = previous;
app.toast(humanMessage(e));
}
}
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
function daysLeft(iso) {
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
}
</script>
<section class="panel">
@@ -146,6 +176,38 @@
{/if}
</section>
<section class="panel">
<header>
<h2>Papirkurv</h2>
<p class="hint">
Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som
de var — bortset fra opgaver, du havde slettet hver for sig først; dem var
der en grund til.
</p>
</header>
{#if loadingTrash}
<p class="empty">…</p>
{:else if trashed.length === 0}
<p class="empty">Papirkurven er tom.</p>
{:else}
<ul class="list">
{#each trashed as project (project.id)}
<li>
<div class="what">
<span class="title">{project.name}</span>
<span class="meta">
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
{daysLeft(project.purge_after)} dage tilbage
</span>
</div>
<button class="use" onclick={() => restore(project)}>Hent tilbage</button>
</li>
{/each}
</ul>
{/if}
</section>
<section class="panel">
<header>
<h2>Eksport</h2>
@@ -96,10 +96,18 @@
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)).catch(() => {});
api
.getMailAddress()
.then((r) => {
mailAddress = r.address;
mailConfigured = r.configured;
})
.catch(() => {});
});
async function rotateFeed() {
@@ -114,7 +122,9 @@
async function rotateMail() {
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
try {
mailAddress = (await api.rotateMailAddress()).address;
const r = await api.rotateMailAddress();
mailAddress = r.address;
mailConfigured = r.configured;
} catch (e) {
app.toast(humanMessage(e));
}
@@ -218,6 +228,16 @@
</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 />
@@ -20,6 +20,10 @@
/** 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
@@ -59,6 +63,15 @@
}
}
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;
@@ -100,6 +113,22 @@
{#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}
@@ -194,6 +223,18 @@
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;
+142 -1
View File
@@ -1,6 +1,7 @@
<script>
/** One project: its sections, its tasks, and who it is shared with. */
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskList from '$lib/components/TaskList.svelte';
@@ -65,6 +66,41 @@
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
let unsectioned = $derived(open.filter((t) => !t.section_id));
let editing = $state(false);
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
async function rename(input) {
const name = input.value.trim();
editing = false;
if (!name || name === project.name) return;
try {
project = await api.updateProject(project.id, { name });
// The sidebar reads its own copy, so it has to be told.
await app.refreshProjects();
} catch (e) {
app.toast(humanMessage(e));
}
}
async function remove() {
const days = 30;
const confirmed = confirm(
`Slet "${project.name}" med alle dets opgaver?\n\n` +
`Det havner i papirkurven og kan hentes tilbage under ` +
`Indstillinger → Data og skabeloner i ${days} dage.`
);
if (!confirmed) return;
try {
await api.deleteProject(project.id);
await app.refreshProjects();
goto('/');
} catch (e) {
app.toast(humanMessage(e));
}
}
async function invite(event) {
event.preventDefault();
inviteError = '';
@@ -87,7 +123,38 @@
<div class="view">
{#if project}
<header>
<h1>{project.name}</h1>
<!-- A heading first, a field second. Swapping the h1 out for an input
outright cost the page its heading — which the sidebar smoke test
caught, and which is a real loss: every view here has one, and it is
what a screen reader announces on arrival. So the heading stays and
becomes editable on click. -->
{#if editing}
<!-- svelte-ignore a11y_autofocus -->
<input
class="title"
autofocus
value={project.name}
aria-label="Projektets navn"
onblur={(e) => rename(e.currentTarget)}
onkeydown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') {
e.currentTarget.value = project.name;
editing = false;
}
}}
/>
{:else}
<h1 class:renameable={isOwner}>
{#if isOwner}
<button onclick={() => (editing = true)} title="Klik for at omdøbe">
{project.name}
</button>
{:else}
{project.name}
{/if}
</h1>
{/if}
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
<button
@@ -103,6 +170,14 @@
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
</button>
{/if}
{#if isOwner && !project.is_inbox}
<button class="remove" onclick={remove} aria-label="Slet projektet">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
</svg>
</button>
{/if}
</header>
{#if showShare}
@@ -225,6 +300,72 @@
overflow-wrap: anywhere;
}
/* The heading's button carries no button-ness: it is the heading, and the only
hint that it does anything is the underline on hover. */
h1.renameable button {
font: inherit;
letter-spacing: inherit;
color: inherit;
text-align: left;
border-bottom: 1px solid transparent;
}
h1.renameable button:hover {
border-bottom-color: var(--line-strong);
}
/* Matches the heading it stands in for, so the swap does not move the page. */
.title {
flex: 1;
min-width: 0;
font-size: var(--text-2xl);
font-weight: 560;
letter-spacing: -0.015em;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
padding: var(--s1) var(--s2);
margin-left: calc(var(--s2) * -1);
outline: none;
}
.title:hover {
border-color: var(--line);
}
.title:focus {
background: var(--surface);
border-color: var(--accent);
}
.remove {
flex: none;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: var(--radius);
color: var(--ink-faint);
transition:
color var(--fast) var(--ease),
background var(--fast) var(--ease);
}
.remove:hover {
color: var(--danger);
background: var(--danger-sunken);
}
.remove svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.views {
display: flex;
gap: 1px;
@@ -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==