Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 fcedab0b19 docs(site): add the new features and light screenshots of them
The landing page did not mention two things the app grew — sharing a note with a
person, and task durations — and its screenshots were all in the dark theme.

- Copy: a cell for sharing a note or a page (to a person, or by filing it in a
  project, with the Shared with me group), and one for how long a task takes (drawn
  to length on the calendar, dragged by its foot, kept when moved).

- Two new screenshots in the light theme, at the top of the gallery: the calendar
  drawing timed tasks to their real length, and a note somebody shared gathered
  under Shared with me. English throughout, like the rest of the site — the capture
  account is English, so the interface is.

The generator grew the demo data these need: a handful of timed tasks with lengths
across the week, and a note the colleague shares back to the owner. Every other
screenshot was recaptured from the current build in the same pass, so the whole set
reflects what ships today (the tidied note footer, and the rest).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:30:00 +02:00
Kristian Wind 72b6ea3803 Merge pull request #19 from kristianwind/task-durations-calendar
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m58s
CI / Docker image builds (push) Successful in 1m40s
CI / Go (push) Failing after 13m55s
Task durations: draw, resize, and edit a task's length
2026-08-24 10:22:37 +02:00
kristianwindandClaude Opus 5 4f25905d81 feat(calendar): a task's length is drawn, resizable, and editable
A timed task was drawn at a fixed half hour whatever its `duration_min` said, so a
length the data model already carried was invisible and uneditable. Three parts:

- The calendar draws a task at its real length — a quarter-hour floor so a
  zero-minute task is still grabbable, the day's end as the ceiling — instead of a
  flat thirty minutes. A ninety-minute task now looks like ninety minutes.

- A grip along the foot of a timed task drags its length. Pointer events, not the
  HTML5 drag the body uses, and stopPropagation between them — otherwise making a
  task longer would pick it up and move it instead. It snaps to the quarter hour,
  follows the handle live, and writes once on release through a new store `resize`.
  Moving a task (the existing day/time drag) leaves the length alone.

- The task drawer gains a length field beside the time — minutes, shown only with
  a time to hang it on, since a duration without a start is a length of nothing and
  the calendar has nowhere to draw it. So a length can be set off the calendar too.

e2e: dragging the foot of a task makes it longer both on screen and in what the
server stores, and the time does not move. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:14:39 +02:00
Kristian Wind e3eeb3037c Merge pull request #18 from kristianwind/tidy-note-share-footer
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m51s
CI / Docker image builds (push) Successful in 1m37s
CI / Go (push) Failing after 14m7s
Tidy the note sharing UI: gather it behind a Del button
2026-08-24 10:05:08 +02:00
kristianwindandClaude Opus 5 97e6be3afa fix(notes): gather sharing behind a Del button so the footer stays tidy
The sharing controls were laid out across the note's footer — the project select,
the whole "Del med personer" panel and the Save/Delete buttons all on one row. It
crammed: "Læg i projekt" wrapped to two lines and the people panel ran into the
rest.

Now one Del button in the footer opens a popover that holds both ways a note is
shared — a project and named people — stacked in a small card. The footer is a
status on the left and Del / Gem / Slet on the right, and nothing wraps. The
button carries a count when the note is shared with someone, so "shared" is
legible without opening it.

The popover closes on a click outside, on Escape, and on switching to another
note — the last tracked by note id, so a save returning a fresh copy of the same
note does not slam it shut mid-use. The inner "add someone" button is now Tilføj,
so it does not read as a second Del.

e2e updated to open the popover before reaching the controls; full Chromium suite
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:58:02 +02:00
Kristian Wind d67b61d3c6 Merge pull request #17 from kristianwind/fix-mono-block-escape
CI / Go (push) Failing after 13m12s
CI / OpenAPI is valid (push) Successful in 9s
CI / Docker image builds (push) Successful in 1m52s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 6m13s
Fix: let a monospace line be left — on return and through the menu
2026-08-24 09:34:38 +02:00
kristianwindandClaude Opus 5 f0d18bb800 fix(notes): let a monospace line be left — on return and through the menu
A <pre> is preformatted, so the browser answered Enter by inserting a <br> and
keeping the caret inside it. A monospace last line therefore trapped everything
typed after it in monospace, with no way back to body by typing — and the style
menu could not get out either: `formatBlock('p')` on a line inside a <pre> nests a
<p> *inside* the block (`<pre>…<p>body</p></pre>`), so the text stayed monospace
and the note was left malformed. Reported as "monospace keeps going and I can
neither delete it nor switch to body".

Two fixes, both in the editor:

- Plain Enter in a <pre> now steps out: the tail after the caret becomes a
  paragraph below the block, and that is where the caret lands. Shift+Enter is
  untouched, so a soft line break still writes multi-line code inside the block —
  Enter to leave, Shift+Enter to stay.

- The style menu converts anything touching a <pre> by hand, replacing the element
  rather than reformatting inside it, so a monospace line goes cleanly back to body
  (or to a heading) instead of nesting.

The split can leave an empty text node in the new paragraph, and a block with no
element and no text is not a caret host in Chromium — the next keystroke falls back
into the block above. So an empty paragraph is given a <br> to stand in, which was
the difference between the caret landing in the new line and the text reappearing
in the code.

e2e: a monospace line left on Enter is body after it, Shift+Enter stays monospace,
the menu returns it to body, and the whole thing survives the round-trip through
Markdown and a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:27:44 +02:00
Kristian Wind 7f8d925299 Merge pull request #15 from kristianwind/share-notes-between-users
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 6m39s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m33s
Release / Build and publish image (push) Failing after 5s
Share notes with people, not only through a project
2026-08-24 08:33:06 +02:00
kristianwind cdab817141 Merge main: pick up the calendar e2e flake fix 2026-08-24 08:25:51 +02:00
Kristian Wind 2e76ced5fa Merge pull request #16 from kristianwind/fix-flaky-calendar-drag
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m45s
CI / Docker image builds (push) Successful in 1m47s
CI / Go (push) Failing after 14m5s
Fix flaky calendar e2e: count the test's own events, not the whole day
2026-08-24 08:25:41 +02:00
kristianwindandClaude Opus 5 711fb4838b test(e2e): the calendar test counted other tests' tasks as its own
The calendar test asserted `[data-date=day] .tevent` had a count of exactly one —
the mocked meeting — after dragging its task to the next day. That count assumed
the day held nothing but this test's own events. It does not: the suite shares one
database, and an earlier test drags a task across a month boundary, which on some
dates lands a timed task on this very day. A timed task renders as a `.tevent`
too, so the count was two, and the test failed — but only in the full run, and
only when the date arithmetic put the two tests on the same cell. Alone it passed,
which is exactly the shape of a shared-state collision rather than a real bug.

Named, not counted: assert the meeting is still there and the task is no longer in
the day's column, which is what "the events stayed, the task moved" actually
means — and is true however full another test left the day. Same for the all-day
band. No product code changed; the drag and everything it checks are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:19:17 +02:00
kristianwindandClaude Opus 5 643b30555e test(e2e): follow the renamed share label, and cover person-to-person sharing
The project-filing control was relabelled "Del i" → "Læg i projekt" and its empty
option "Kun mig" → "Intet projekt" (a note can now be shared with people while
under no project, so "kun mig" was no longer true). The two e2e tests that drove
that control by its old label are updated to the new one.

And a new test for the feature itself: an owner shares a note with a second
account from the Del med personer panel, the recipient's own list grows a Delt
med mig group with the note in it, a viewer's row carries no star or archive
control, and unsharing takes it back off their list. The recipient is navigated
on the origin the invite signed her in on — VERDANDE_BASE_URL is localhost while
the suite drives 127.0.0.1, and a session cookie set on one host is not sent to
the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 07:59:57 +02:00
kristianwindandClaude Opus 5 91671a4f97 feat(notes): share a note with a person, not only through a project
Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.

A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.

The store:
  - note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
    recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
    notes shared directly with the reader.
  - the effective role folds a direct share on top of the owner and project paths.

The rules, drawn where they protect the owner:
  - read = viewer, edit text = editor, on any path.
  - MOVE a note (change its project) is owner-only: an editor filing it into a
    project the owner is not in would take the owner's own note away from them.
  - DELETE is owner or project-editor — a direct share, however high its role,
    does not reach to throwing the note away.
  - SHARE and UNSHARE are the owner's alone, like a project's invitations.
  - the picker is everyone on the instance (a closed, invited set) minus who is
    already on the note; a made-up id and the owner themselves are refused.

The list:
  - a "Delt med mig" group at the top, collapsible, that appears only when it has
    something in it — the same rule Favoritter follows — so it costs nothing when
    you share nothing. A shared note lives there, not also in a time bucket.
  - every shared note carries a small chip in its owner's avatar colour, the
    project mark's shape said for a person.
  - no star or archive button on a note somebody else owns: those change the note
    itself, and it is not yours to put away.
  - the editor grows a "Del med personer" panel for the owner: who can see it, at
    what role, and an add row drawn from the instance's other people.

The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.

Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.

Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 07:36:55 +02:00
Kristian Wind 15be5d2165 Merge pull request #14 from kristianwind/security-hardening-headers
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m30s
CI / Docker image builds (push) Successful in 1m27s
CI / Go (push) Failing after 14m29s
Security hardening: HSTS, forwarded-IP trust, single-use TOTP
2026-08-23 23:41:44 +02:00
kristianwindandClaude Opus 5 77994eff09 feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code
stays valid for its whole thirty-second window, so one seen in flight — a
phishing page, a proxy, over the shoulder — could be replayed until it expired.

Tasks now record the counter of the last accepted step (totp_last_step), and a
login accepts a code only when its step is strictly greater. The guard is in the
UPDATE, not a read-then-write, so two logins racing with the same stolen code
cannot both find the old value and both proceed — exactly one moves the row.

auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the
same one-step window; the code entered a little early or late spends the step it
was issued at, not the step it was checked at, so a late-but-valid code is still
single-use. Verified end to end: the same code presented to two logins in one
window is accepted once and refused the second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:34:34 +02:00
kristianwindandClaude Opus 5 b4038356f0 feat(security): trust a forwarded IP only from a proxy, and set COOP
The rate limiter and the audit log key on the caller's address, and that address
was read with chi's middleware.RealIP — which believes X-Forwarded-For from
anyone. So a caller could set it to a new value on every request and get a fresh
password-guess bucket each time, and could write any address they liked into the
log the panel's watchers read. The one control meant for when a password is
already gone was keyed on a number the attacker got to pick.

Now the header is believed only when the machine that *connected* is a trusted
proxy — by default anything on a private network, which is the shape of a reverse
proxy or tunnel sharing the host or the docker bridge. A connection straight off
the internet is trusted for nothing and is keyed on the address it actually came
from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no
proxy at all); VERDANDE_REAL_IP_HEADER names the header.

Verified in the current deployment before the change: behind the Cloudflare
Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared
overwrites it. This makes that safety a property of the code, not of the
deployment — expose the instance directly, or put a header-passing proxy in
front, and it would have been a live hole.

Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating
the browsing context. verdande scripts no other window, so there is nothing to
lose and a class of cross-window attacks to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:34:34 +02:00
kristianwindandClaude Opus 5 8a87cfeb44 feat(security): HSTS og en lukket Permissions-Policy på hvert svar
To headere, appen ikke satte, og som begge følger af, hvad den allerede er.

Sessionen bor i en __Host-cookie, der kun findes over HTTPS — hele modellen
forudsætter TLS. Uden HSTS holder den forudsætning ikke mod en netværksangriber:
den allerførste navigation kan strippes til http://, og cookien rækkes over i
klartekst, før Secure-flaget nogensinde kommer i spil. HSTS lukker vinduet, og
den er sat på nøjagtig samme betingelse som cookiens Secure-flag (secureCookies),
så en dev-instans på ren HTTP aldrig bliver bedt om et certifikat, den ikke har.
To år, underdomæner med; ingen preload — at blive hard-kodet ind i browsere er en
beslutning for domænets ejer, ikke for en selv-hostet app.

verdande beder aldrig om kamera, mikrofon eller placering, så den sikreste
Permissions-Policy er at slå dem alle fra: et indsat element kan ikke bede om det,
siden selv har afvist. Navngivet felt for felt frem for med et jokertegn, fordi
jokertegns-formen ikke forstås ens af alle browsere, og en regel, en browser
ignorerer, er ingen regel.

Prøve på begge: HSTS følger HTTPS og udebliver over HTTP, og Permissions-Policy
lukker for kameraet uanset skema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:15:33 +02:00
Kristian Wind 3e52eea2da feat(kvasir): én mail bliver til én opgave, uanset hvem der læser den
CI / OpenAPI is valid (push) Successful in 15s
CI / End-to-end (push) Successful in 9m46s
CI / Docker image builds (push) Successful in 1m46s
CI / Go (push) Failing after 15m23s
Release / Build and publish image (push) Failing after 5s
Fundamentet under Kvasir, og det er værd at have uden ham.

Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere
af samme postkasse — en anden assistent, en gentaget kørsel, et menneske —
laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal
faktura fra Browns" to gange i samme kørsel.

Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`,
`imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke
hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det
betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en
nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og
det her skal virke uden.

Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der
læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal
være ude af stand til" er en betingelse frem for en aftale, nogen husker.
Afgrænset til den, mailen kom til — to personer på samme instans, der begge
modtager tråden, får hver sin opgave, og det er rigtigt.

CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge
mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som
læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da
af standser samme sted.

`TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer
triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave,
der blev lavet af en mail og smidt væk, er også et svar.
2026-08-22 09:19:41 +02:00
Kristian Wind acd6784333 test(e2e): kalendertesten afhang af, hvilken ugedag den blev kørt på
CI / OpenAPI is valid (push) Successful in 11s
CI / Go (push) Failing after 13m58s
CI / Docker image builds (push) Successful in 2m43s
CI / End-to-end (push) Successful in 9m12s
Grøn i månedsvis, rød i morges — på en commit, der ikke var i nærheden af en
kalender. Det kostede en runde at tro, det var min egen ændring.

Testen lægger opgaven på i morgen og trækker den til dagen efter. Er i morgen en
søndag, er kilden sidste kolonne i sin række og målet første celle i den næste —
nede i den del af gitteret, et 720 px vindue skærer af. Et syntetisk træk slipper
i målets midtpunkt uden den auto-scroll, en rigtig markør får af browseren, så et
mål under folden bliver aldrig ramt. I går lå begge celler i samme række.

To ting rettet, og den anden er den vigtigste:

- Vinduet er højt nok til alle seks rækker under månedsdelen.
- Målet tages fra gitteret frem for at blive regnet ud af datoen. Dagen efter i
  morgen ligger ikke altid i den måned, der vises — i enden af en kort måned
  falder den udenfor — mens cellen ved siden af altid gør. Med et fald tilbage
  til cellen før, for den ene dag om måneden hvor der ikke er en efter.

Appen fejler ikke noget: en rigtig bruger ruller.
2026-08-22 08:48:31 +02:00
67 changed files with 2474 additions and 67 deletions
+19
View File
@@ -12,6 +12,25 @@ nowhere to put one that the operator would ever see.
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
## Behind a proxy
verdande keys its login rate limit and its audit log on the caller's address, so
that address has to be one the caller cannot forge. A forwarded-for header is
believed only when the machine that connected is a trusted proxy; a connection
straight off the internet is keyed on the address it really came from.
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
!!! warning "A proxy that does not overwrite the header"
The default is safe when your proxy replaces a client-supplied
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
do. If yours passes the client's own header through, narrow
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
choose the address they are rate-limited and logged as.
## Sessions and links
| Variable | Default | What it does |
+86
View File
@@ -3072,6 +3072,69 @@ paths:
"204": { description: Deleted }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/{noteID}/shares:
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
get:
tags: [notes]
summary: Who a note is shared with, and who it can be shared with.
description: >
Owner only. Returns the people the note is already shared with, each with
their role, and the candidates the owner may add — everyone on the instance
who is not already on the note.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
shares:
type: array
items: { $ref: "#/components/schemas/NoteShare" }
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
"404": { $ref: "#/components/responses/NotFound" }
post:
tags: [notes]
summary: Share a note with a person, or change the role they hold.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [user_id]
properties:
user_id: { type: string }
role: { type: string, enum: [viewer, editor] }
responses:
"204": { description: Shared }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/notes/{noteID}/shares/{userID}:
delete:
tags: [notes]
summary: Take a person's access to a note away again.
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
- name: userID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Unshared }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/linking/{kind}/{targetID}:
get:
tags: [notes]
@@ -3907,6 +3970,14 @@ components:
pinned: { type: boolean }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
shared_with_me:
type: boolean
description: >
True when the reader reached this note through a direct share rather
than by owning it or its project. What puts it in the Delt med mig group.
owner:
allOf: [{ $ref: "#/components/schemas/Person" }]
description: Who the note belongs to, present on a note shared with the reader.
links:
type: array
description: What the body points at, read out of the text on the way in.
@@ -3923,6 +3994,21 @@ components:
body: { type: string, description: "Markdown. Its first line becomes the title." }
pinned: { type: boolean }
Person:
type: object
description: A user, as much of one as a name and a colour.
properties:
id: { type: string }
name: { type: string }
avatar_color: { type: string }
NoteShare:
type: object
description: One person's direct grant on a note.
properties:
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [viewer, editor] }
Mailbox:
type: object
description: One connected mailbox. The password and any token are never returned.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+33
View File
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
}
}
// TOTPStep returns the step a code matched, and it must be the same step across
// the tolerance window — that number is what makes a code single-use, so a code
// entered a little early or late has to spend the same step, not a new one.
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
secret, _, err := NewTOTPSecret("verdande", "k@example.com")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
code := codeAt(t, secret, now)
want, err := TOTPStep(secret, code, now)
if err != nil {
t.Fatalf("the current code did not match: %v", err)
}
// Verified one step late — the code is still valid, and must report the step it
// was issued at, not the step it was checked at.
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
if err != nil {
t.Fatalf("code rejected one step late: %v", err)
}
if got != want {
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
}
if _, err := TOTPStep(secret, "000000", now); err == nil {
t.Error("a wrong code returned a step")
}
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
}
}
// codeAt produces the code an authenticator app would display at that moment,
// using the same settings VerifyTOTP checks against.
func codeAt(t *testing.T, secret string, now time.Time) string {
+35
View File
@@ -2,6 +2,7 @@ package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"errors"
"fmt"
@@ -82,6 +83,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
return nil
}
// TOTPStep verifies a code and returns the counter of the step it matched, so the
// caller can refuse to accept the same step twice.
//
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
// window is not enough to stop a replay: a code seen in flight stays valid for the
// rest of its period, and the fix is to record which step was spent and never
// accept that step or an earlier one again. That needs the number, so this walks
// the same window ValidateCustom would — one step either side — and reports the
// counter of the match. The comparison is constant time for the same reason the
// password check is: a code is a secret being checked against a computed value.
func TOTPStep(secret, code string, now time.Time) (int64, error) {
if secret == "" {
return 0, ErrNoTOTPSecret
}
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
Period: totpPeriod,
Skew: 0,
Digits: totpDigits,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return 0, ErrInvalidCode
}
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return at.Unix() / totpPeriod, nil
}
}
return 0, ErrInvalidCode
}
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
// and the hashes to store. Without these, losing a device means losing the account,
// and the only fix left is editing the database by hand.
+82
View File
@@ -7,6 +7,7 @@ package config
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strconv"
@@ -80,6 +81,26 @@ type Config struct {
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
// TrustedProxies are the peers whose forwarded-for header may be believed.
//
// The rate limiter and the audit log key on the caller's address, and that
// address is only as honest as wherever it was read. Read it from a header that
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
// every request and to write someone else's IP into the log. So the header is
// believed only when the machine that *connected* is one of these — a reverse
// proxy or tunnel the operator put there — and ignored when the connection came
// straight off the internet.
//
// The default is "the peer is on a private network", which is exactly the shape
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
// host or the docker bridge, so its address is loopback or RFC1918. A directly
// exposed instance sees a public peer, trusts nothing, and keys on the real
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
// or "none" to trust no proxy at all.
TrustedProxies []netip.Prefix
// RealIPHeader is where the client address is read from, for a trusted peer.
RealIPHeader string
Dev bool
}
@@ -149,9 +170,70 @@ func Load() (*Config, error) {
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
}
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
return nil, err
}
return c, nil
}
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
// families' link-local. A proxy an operator stands up in front of this server is
// on one of these; a stranger connecting directly is on none.
var defaultTrustedProxies = mustPrefixes(
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
"169.254.0.0/16", "fe80::/10",
)
// parseTrustedProxies reads the override. Empty keeps the private-network default;
// the literal "none" trusts no proxy at all, for an instance that is exposed
// directly and must never believe a forwarded header.
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultTrustedProxies, nil
}
if strings.EqualFold(raw, "none") {
return nil, nil
}
var out []netip.Prefix
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
// naming one proxy does not have to know CIDR notation to do it.
if !strings.Contains(part, "/") {
addr, err := netip.ParseAddr(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
continue
}
p, err := netip.ParsePrefix(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
}
out = append(out, p.Masked())
}
return out, nil
}
func mustPrefixes(cidrs ...string) []netip.Prefix {
out := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
out = append(out, netip.MustParsePrefix(c))
}
return out
}
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
+18 -1
View File
@@ -218,7 +218,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
return
}
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
if err == nil {
// The code is right; now it must also be unused. A TOTP code stays valid for
// its whole window, so one observed in flight — a phishing page, a proxy,
// over the shoulder — could otherwise be replayed until it expires. Spending
// the step here makes it single-use: a second presentation of the same code,
// or an earlier one, finds the step already gone.
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
if cerr != nil {
s.internal(w, r, "consume totp step", cerr)
return
}
if !fresh {
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"that verification code has already been used")
return
}
}
if err != nil {
// A recovery code is accepted in the same field. Making somebody find a
// different form while locked out of their account is a poor time to
+44
View File
@@ -372,6 +372,50 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
}
}
// A TOTP code works once. A code seen in flight and replayed inside its window
// must be refused the second time, or the second factor is only as good as the
// thirty seconds an attacker has to reuse what they captured.
func TestTOTPCodeIsSingleUse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
secret, _ := body["secret"].(string)
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
// One code, captured once and presented twice.
code := codeFor(t, secret)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusOK {
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
}
// A second login, same code, same window.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
}
}
// A recovery code is accepted in the same field as a TOTP code, and works once.
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
ts := newTestServer(t)
+35
View File
@@ -3,6 +3,8 @@ package httpapi
import (
"strings"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// Politikken skal stå i svaret, og den skal stadig være stram.
@@ -50,6 +52,39 @@ func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
}
}
// HSTS følger med, når instansen kører over HTTPS — og kun der.
//
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
func TestHSTSTracksHTTPS(t *testing.T) {
overHTTP := newTestServer(t) // BaseURL er http://localhost
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
t.Errorf("HSTS sat over ren HTTP: %q", got)
}
overHTTPS := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://todo.example.dk"
})
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
t.Error("ingen HSTS over HTTPS")
} else if !strings.Contains(got, "includeSubDomains") {
t.Errorf("HSTS uden includeSubDomains: %q", got)
}
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
// mikrofon og placering, som appen aldrig beder om.
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
}
// COOP isolerer browserkonteksten og står på hvert svar.
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
}
}
// Et bilag må ikke kunne blive læst som et dokument.
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
+11
View File
@@ -360,8 +360,19 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
Description: msg.Snippet + "\n\n" + msg.Link,
Priority: 4,
CreatedBy: user.ID,
// The thread, not the message: a reply arriving later is the same
// conversation, and a second task for it would be the duplicate this
// key exists to prevent.
SourceKey: "gmail:thread:" + msg.ThreadID,
}
if err := s.db.CreateTask(ctx, task, nil); err != nil {
if errors.Is(err, store.ErrDuplicate) {
// Somebody got there first — an earlier sweep, or another
// assistant reading the same mailbox. Mark it seen and move on;
// it is handled, which is all this loop wanted.
seen[id] = true
continue
}
s.log.Warn("gmail create task", "err", err)
continue
}
+12
View File
@@ -202,8 +202,20 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
Description: msg.Snippet,
Priority: 4,
CreatedBy: user.ID,
// Host and uid together: a uid is only unique within one mailbox on
// one server, and two accounts will hand out the same small numbers.
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
}
if err := s.db.CreateTask(ctx, task, nil); err != nil {
if errors.Is(err, store.ErrDuplicate) {
// Already a task. Still count it as read, or last_uid never moves
// past it and every sweep from here on stops at the same message.
done = append(done, msg.UID)
if msg.UID > highest {
highest = msg.UID
}
continue
}
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
continue
}
+51 -18
View File
@@ -19,21 +19,38 @@ import (
// Notes.
//
// Access follows the project, because that is where a note lives: a note in a
// shared project is readable by everybody who can read the project and writable
// by an editor. A note with no project is its author's alone — the same answer a
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
// Access has three ways in, and a note may have any of them at once. A note with
// no project is its author's alone — the same answer a task in the inbox gets. A
// note in a project is readable by everyone who can read the project and writable
// by an editor. And a note can be shared with a person directly, which grants that
// person a role of its own. The effective role is the most any of the three allow.
// mayReadNote reports whether this person may see the note, and mayWriteNote
// whether they may change it. Split, because a viewer on a shared project can do
// the first and not the second.
// mayTouchNote reports whether this person may act on the note at the given role —
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
// on a shared project or a shared note, may do the first and not the second.
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
user := userFrom(r.Context())
// The owner and the project path, exactly as before.
if n.ProjectID == "" {
return n.CreatedBy == user.ID
if n.CreatedBy == user.ID {
return true
}
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
return true
}
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
return err == nil
// A direct share grants its role on top of whatever the above allow.
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
return shareRole.AtLeast(role)
}
return false
}
// ownsNote reports whether this person is the note's owner — its created_by — which
// is who, and only who, may hand it to other people. An editor on a shared note can
// change its text; giving away access is the owner's alone, the same shape a
// project takes where members edit but only the owner invites.
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
}
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "search notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
return
}
@@ -62,7 +79,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "notes in project", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
return
}
@@ -75,7 +92,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list archived notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
return
}
@@ -87,7 +104,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
}
// briefly replaces each note's body with the first of it.
@@ -206,9 +223,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
return
}
if body.ProjectID != nil {
// Moving it means being allowed on both sides: you cannot file somebody
// else's note into your project, nor yours into theirs.
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
// Filing a note is the owner's call, not an editor's. A shared editor may
// change the text; letting them also move the note would let them file
// somebody else's note into a project the owner is not in — and the owner,
// who is not a member there, would lose their own note. So the move is
// owner-only, and then only into a project the owner may write to.
if !s.ownsNote(r, n) {
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
return
}
if *body.ProjectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
@@ -238,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "delete note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
// Deleting is not editing. Someone the note was shared with — even as an editor
// — may change its text, but throwing away the owner's note is the owner's to
// do. A project editor keeps the reach they have always had over a project's
// notes; a direct share does not extend that far.
mayDelete := s.ownsNote(r, n)
if !mayDelete && n != nil && n.ProjectID != "" {
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
mayDelete = perr == nil
}
if n == nil || !mayDelete {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
+181
View File
@@ -0,0 +1,181 @@
package httpapi
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// markShared fills in the two fields the list needs to show a note somebody else
// shared with the reader: that it is such a note, and whose it is.
//
// Done in one pass over the page rather than per note: one query asks which of
// these ids are shared with the reader, and one asks for the owners' names and
// colours. A note the reader owns is never "shared with me", however it was
// reached — the group is other people's notes, not one's own.
func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note {
me := userFrom(r.Context()).ID
if len(notes) == 0 {
return notes
}
ids := make([]string, 0, len(notes))
for _, n := range notes {
if n.CreatedBy != me {
ids = append(ids, n.ID)
}
}
shared, err := s.db.NotesSharedWith(r.Context(), me, ids)
if err != nil {
// The list is worth more than the chip: a note without its "shared" mark
// still reads and still opens. Log nothing here — the store already did.
return notes
}
owners := map[string]store.Person{}
if len(shared) > 0 {
ownerIDs := make([]string, 0, len(shared))
for _, n := range notes {
if shared[n.ID] && n.CreatedBy != "" {
ownerIDs = append(ownerIDs, n.CreatedBy)
}
}
owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs)
}
for i := range notes {
if !shared[notes[i].ID] {
continue
}
notes[i].SharedWithMe = true
if p, ok := owners[notes[i].CreatedBy]; ok {
owner := p
notes[i].Owner = &owner
}
}
return notes
}
// handleListNoteShares is who a note is shared with. Owner only: the list of people
// who can see your note is itself something only you should see.
func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
// 404, not 403: telling a stranger "not yours" still confirms the note exists.
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
shares, err := s.db.ListNoteShares(r.Context(), n.ID)
if err != nil {
s.internal(w, r, "list note shares", err)
return
}
out := make([]noteShareJSON, 0, len(shares))
already := map[string]bool{}
for _, sh := range shares {
out = append(out, noteShareJSON{
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
Role: string(sh.Role),
})
already[sh.User.ID] = true
}
// The picker is filled from the same response, so the panel opens complete in
// one round-trip. People already on the note are left out — they are in the list
// above it, not the "add someone" menu.
candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "share candidates", err)
return
}
cand := make([]personJSON, 0, len(candidates))
for _, p := range candidates {
if already[p.ID] {
continue
}
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
}
type noteShareJSON struct {
User personJSON `json:"user"`
Role string `json:"role"`
}
type shareNoteRequest struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
// handleShareNote grants a person a role on a note, or changes the role they hold.
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
var req shareNoteRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
role := store.Role(req.Role)
if role == "" {
role = store.RoleViewer
}
if role != store.RoleViewer && role != store.RoleEditor {
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
return
}
if req.UserID == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
// The recipient must be a real account and not the sharer themselves; ShareNote
// refuses the owner, and a made-up id should not reach it.
if req.UserID == userFrom(r.Context()).ID {
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
return
}
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
s.storeError(w, r, "share note", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnshareNote takes a person's access away again.
func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil {
s.internal(w, r, "unshare note", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+199
View File
@@ -0,0 +1,199 @@
package httpapi
import (
"net/http"
"testing"
)
// createNote makes a note as the current client and returns its id.
func (ts *testServer) createNote(t *testing.T, body string) string {
t.Helper()
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create note: status %d", resp.StatusCode)
}
id, _ := out["id"].(string)
if id == "" {
t.Fatal("create note returned no id")
}
return id
}
func userID(t *testing.T, ts *testServer, email string) string {
t.Helper()
u, err := ts.db.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("look up %s: %v", email, err)
}
return u.ID
}
// A note shared with a person shows up in their list, marked as theirs-from-
// somebody, and a viewer can read it but not change it.
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
// Before sharing, Sofie cannot see it at all.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
}
// Share it as a viewer.
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("share: status %d", resp.StatusCode)
}
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
_, list := other.do(t, "GET", "/api/v1/notes", nil)
notes, _ := list["notes"].([]any)
var found map[string]any
for _, raw := range notes {
n := raw.(map[string]any)
if n["id"] == noteID {
found = n
}
}
if found == nil {
t.Fatal("the shared note is not in the recipient's list")
}
if found["shared_with_me"] != true {
t.Errorf("the note is not marked shared_with_me: %v", found)
}
ownerObj, _ := found["owner"].(map[string]any)
if ownerObj == nil || ownerObj["name"] != "Kristian" {
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
}
// A viewer reads it.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
}
// But cannot change it.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
}
}
// An editor may change the text, but only the owner may delete the note or hand it
// to somebody else.
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Delt\noprindelig")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
t.Fatalf("share as editor failed")
}
// The editor writes.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
}
// The editor cannot re-share it: giving access is the owner's alone.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
}
// The editor cannot delete it.
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
t.Error("an editor deleted the owner's note")
}
// The note still exists for the owner.
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
}
}
// Unsharing takes access away again, and only the owner can do it.
func TestUnshareRemovesAccess(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Hemmelig")
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
// A non-owner cannot unshare.
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
}
// The owner can.
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("owner unshare: %d", resp.StatusCode)
}
// And access is gone.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
}
}
// The share list is owner-only and offers the instance's other people as
// candidates, minus those already on the note.
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "sofie@example.dk", "Sofie")
owner.newUser(t, "anders@example.dk", "Anders")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Plan")
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
shares, _ := out["shares"].([]any)
if len(shares) != 1 {
t.Fatalf("shares = %d, want 1", len(shares))
}
cands, _ := out["candidates"].([]any)
// Anders is a candidate; Sofie is already on the note and so is not.
names := map[string]bool{}
for _, c := range cands {
names[c.(map[string]any)["name"].(string)] = true
}
if !names["Anders"] {
t.Errorf("Anders is not offered as a candidate: %v", names)
}
if names["Sofie"] {
t.Error("somebody already on the note is offered as a candidate")
}
if names["Kristian"] {
t.Error("the owner is offered as a candidate to share with themselves")
}
}
// Sharing refuses a made-up person and the owner themselves.
func TestShareRejectsUnknownAndSelf(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
me := userID(t, owner, "kristian@example.dk")
noteID := owner.createNote(t, "# Note")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
t.Error("a note was shared at the owner role")
}
}
+88
View File
@@ -0,0 +1,88 @@
package httpapi
import (
"net"
"net/http"
"net/netip"
"strings"
"github.com/kristianwind/verdande/internal/config"
)
// realIP rewrites r.RemoteAddr to the client address a trusted proxy reported,
// and does nothing at all when the peer is not trusted.
//
// This replaces chi's middleware.RealIP, which is deprecated precisely because it
// believes a forwarded header from anyone: an attacker sets X-Forwarded-For to a
// new value on every request and gets a fresh rate-limit bucket each time, and
// writes whatever address they like into the audit log. The one control that
// exists for when a password is already gone is keyed on this address, so it has
// to be an address the caller cannot choose.
//
// The gate is the peer. A header is read only when the machine that opened the
// connection is one of cfg.TrustedProxies — a reverse proxy or tunnel the operator
// put there. A connection straight off the internet is trusted for nothing, so its
// header is ignored and it is keyed on the address it actually came from. Behind a
// proxy that overwrites the client's own header (nginx, Caddy and cloudflared all
// do) the value is then the real client and cannot be forged from outside.
func realIP(cfg *config.Config) func(http.Handler) http.Handler {
trusted := cfg.TrustedProxies
header := cfg.RealIPHeader
if header == "" {
header = "X-Forwarded-For"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := trustedClientIP(r, trusted, header); ip != "" {
// Port preserved off the original peer only for shape; downstream
// clientIP() splits it away, and the forwarded value carries none.
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
}
// trustedClientIP returns the forwarded client address if the peer may be believed,
// or "" to leave RemoteAddr untouched.
func trustedClientIP(r *http.Request, trusted []netip.Prefix, header string) string {
peer := r.RemoteAddr
if host, _, err := net.SplitHostPort(peer); err == nil {
peer = host
}
peerAddr, err := netip.ParseAddr(peer)
if err != nil || !isTrusted(peerAddr, trusted) {
return ""
}
raw := r.Header.Get(header)
if raw == "" {
return ""
}
// The leftmost entry is the original client. That entry is only as trustworthy
// as the proxy that wrote it — but the trusted proxy is exactly the one that
// replaces a client-supplied header, so on the deployments this is written for
// it is the real client, and on a direct connection we never reach this line.
first := raw
if i := strings.IndexByte(raw, ','); i >= 0 {
first = raw[:i]
}
first = strings.TrimSpace(first)
if _, err := netip.ParseAddr(first); err != nil {
return ""
}
return first
}
func isTrusted(addr netip.Addr, trusted []netip.Prefix) bool {
if !addr.IsValid() {
return false
}
addr = addr.Unmap()
for _, p := range trusted {
if p.Contains(addr) {
return true
}
}
return false
}
+94
View File
@@ -0,0 +1,94 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// The address the rate limiter keys on must be one the caller cannot choose. A
// forwarded header is believed only from a trusted proxy, and ignored from a
// stranger connecting directly — the difference between a control and a suggestion.
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
seen := ""
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = clientIP(r)
}))
cases := []struct {
name string
remoteAddr string
xff string
want string
}{
{
// A private peer is a proxy the operator put there: believe it.
name: "trusted proxy is believed",
remoteAddr: "10.1.2.3:5000",
xff: "203.0.113.9",
want: "203.0.113.9",
},
{
// A public peer connected straight to us; its header is a guess.
name: "public peer header ignored",
remoteAddr: "198.51.100.7:5000",
xff: "203.0.113.9",
want: "198.51.100.7",
},
{
// No header from a trusted proxy leaves the peer as the answer.
name: "trusted peer without header",
remoteAddr: "127.0.0.1:5000",
xff: "",
want: "127.0.0.1",
},
{
// A spoofed value from a stranger cannot become the key.
name: "spoof from outside does not win",
remoteAddr: "198.51.100.7:5000",
xff: "127.0.0.1",
want: "198.51.100.7",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
r.RemoteAddr = tc.remoteAddr
if tc.xff != "" {
r.Header.Set("X-Forwarded-For", tc.xff)
}
h.ServeHTTP(httptest.NewRecorder(), r)
if seen != tc.want {
t.Errorf("keyed on %q, want %q", seen, tc.want)
}
})
}
}
// "none" turns the trust off entirely, for an instance exposed directly.
func TestRealIPTrustNone(t *testing.T) {
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
seen := ""
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = clientIP(r)
}))
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
r.Header.Set("X-Forwarded-For", "203.0.113.9")
h.ServeHTTP(httptest.NewRecorder(), r)
if seen != "10.1.2.3" {
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
}
}
+34 -1
View File
@@ -110,7 +110,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
// Not middleware.RealIP: that believes a forwarded header from anyone, which
// lets a caller pick the address the rate limiter and audit log key on. This
// one reads the header only from a trusted proxy — see realip.go.
r.Use(realIP(cfg))
r.Use(requestLogger(log))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
@@ -345,6 +348,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/", s.handleCreateNote)
r.Post("/import", s.handleImportNotes)
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
// Sharing a note with a person directly — its own access list, beside
// the project path. Owner only; see notes_share_handlers.go.
r.Get("/{noteID}/shares", s.handleListNoteShares)
r.Post("/{noteID}/shares", s.handleShareNote)
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
@@ -665,6 +673,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", s.csp)
// The session lives in a __Host- cookie that only exists over HTTPS, so the
// whole security model already assumes TLS. HSTS is what makes that
// assumption hold against a network attacker: without it, the very first
// navigation can be stripped to http:// and the cookie handed over in the
// clear. Gated on the same condition as the cookie's Secure flag, so a
// plain-HTTP dev instance is never told to demand a certificate it cannot
// present. Two years, subdomains included; no preload directive, because
// asking to be hard-coded into browsers is a decision the operator makes
// for their domain, not one a self-hosted app makes for them.
if s.secureCookies() {
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
// verdande uses none of the powerful browser features, so the safest policy
// is to turn them all off: an injected element cannot ask for the camera,
// the microphone or the location if the page itself has already declined
// them. Named explicitly rather than with a wildcard because the wildcard
// form of this header is not universally understood, and a directive a
// browser ignores is not a control.
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
"payment=(), usb=(), interest-cohort=()")
// Isolate the browsing context: a window this page opened, or one that
// opened it, cannot keep a handle across the origin boundary. verdande
// never scripts another window, so there is nothing to lose and a whole
// class of cross-window attacks to close.
h.Set("Cross-Origin-Opener-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
+79
View File
@@ -0,0 +1,79 @@
-- Kvasir: what an assistant did, and what it acted on.
--
-- Two things, and they are separate on purpose: one is about the outside world,
-- and would be worth having even if Kvasir never existed.
-- 1. Where a task came from, out there.
--
-- Nothing today can answer "has this mail already become a task?". A second
-- reader of the same mailbox — another assistant, a rerun of the same sweep,
-- a person — makes a second task, and the only sign is the duplicate itself.
-- That has happened in practice, twice in one run.
--
-- Namespaced rather than a bare id: `gmail:thread:18f2c…`, `imap:<host>:<uid>`,
-- `ics:<uid>`. A Gmail thread id and an IMAP uid look nothing alike until the
-- day they collide, and a namespace costs one colon. It also means the next
-- source needs no column — which is the point of a key rather than a foreign
-- key. There is no messages table to point at, and this has to work without one.
ALTER TABLE tasks ADD COLUMN source_key TEXT NOT NULL DEFAULT '';
-- The index is the interlock, not an optimisation. Two agents reading the same
-- inbox must be unable to make two tasks, and "must be unable" is a constraint
-- rather than a convention somebody remembers to follow.
--
-- Scoped to the author: two people on one instance connect their own mailboxes,
-- and the same thread reaching both of them is two tasks, correctly. Partial
-- because `created_by` is ON DELETE SET NULL — a task outlives its author, and
-- rows that have lost theirs must not start colliding with each other.
CREATE UNIQUE INDEX tasks_once_per_source
ON tasks (created_by, source_key)
WHERE source_key <> '' AND created_by IS NOT NULL;
-- 2. Kvasir's ledger.
--
-- The dial that lets an assistant act on its own is only bearable because of
-- this table. Two questions have to be answerable at any moment: what did it do
-- today, and how do I undo it. Neither is answerable from the tasks themselves.
--
-- A suggestion and a completed action are the SAME ROW in different states. That
-- is the whole design: "suggest" and "act" are not two code paths, they are one
-- path that writes a different `state`. Accepting a suggestion moves it to done;
-- undoing an action moves it to undone. Nothing has to be written twice, and the
-- feed reads the same either way.
--
-- `before_json` is the undo. Not a diff and not a description — the fields as
-- they were, so putting them back needs no reasoning about what changed. It is
-- also the honest record: a task edited by hand afterwards will not match, and
-- an undo that would overwrite newer work should say so rather than win.
CREATE TABLE kvasir_actions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- 'file', 'rewrite', 'split', 'nudge', 'prioritise'. Free text rather than a
-- CHECK: a new capability must not need a migration to be recorded, and an
-- unknown kind in the feed is a row nobody rendered, not a broken database.
kind TEXT NOT NULL,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
-- One sentence, written when the action was decided. Stored rather than
-- rebuilt: it says what Kvasir believed at the time, and the project it
-- names may since have been renamed or deleted.
summary TEXT NOT NULL,
-- Why it thought so. Shown on request, because a suggestion you cannot
-- interrogate is one you either trust blindly or ignore entirely.
reason TEXT NOT NULL DEFAULT '',
before_json TEXT NOT NULL DEFAULT '{}',
after_json TEXT NOT NULL DEFAULT '{}',
-- suggested → done | dismissed, and done → undone.
state TEXT NOT NULL CHECK (state IN ('suggested', 'done', 'dismissed', 'undone')),
created_at INTEGER NOT NULL,
decided_at INTEGER NOT NULL DEFAULT 0
);
-- The feed asks "what is waiting for me, newest first", and the corrections
-- Kvasir learns from ask "what did I get wrong lately" — both read by user and
-- order by time.
CREATE INDEX kvasir_actions_by_user ON kvasir_actions (user_id, created_at DESC);
@@ -0,0 +1,11 @@
-- A TOTP code is meant to be usable once. RFC 6238 says so in as many words: the
-- verifier must reject a code that has already been accepted, or a code observed in
-- flight — over the shoulder, off a phishing page, out of a proxy — can be replayed
-- for the rest of its thirty-second window.
--
-- totp_last_step records the counter of the most recently accepted code (Unix time
-- divided by the period). A login accepts a code only when its step is strictly
-- greater, so the same code cannot be spent twice and time cannot run backwards.
-- Null means no code has been accepted yet, which is every account until its owner
-- next signs in with one.
ALTER TABLE users ADD COLUMN totp_last_step INTEGER;
@@ -0,0 +1,30 @@
-- Sharing a note with a person, rather than only by filing it in a shared project.
--
-- Until now a note was shared exactly one way: put it in a project, and everyone
-- who could read the project could read the note. That is the right tool when the
-- note belongs with a body of work, and the wrong one when it is just "let Sofie
-- see this" — it makes you stand up a project to hand over a single page.
--
-- A note_shares row is that direct grant: this note, this person, this role. It
-- sits alongside the project path, not instead of it — a note can be loose, filed,
-- shared with people, or any combination, and access is the most that any of those
-- allow. Only the note's owner (its created_by) writes these rows.
--
-- role is 'viewer' or 'editor', the same words a project membership uses, so there
-- is one idea of what a role means in this program and not two.
CREATE TABLE note_shares (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer',
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
created_at INTEGER NOT NULL,
-- One share per person per note: sharing again changes the role, it does not
-- add a second row that the first would then quietly disagree with.
PRIMARY KEY (note_id, user_id)
);
-- "Which notes are shared with me" is asked on every visit to the notes page, to
-- build the Delt med mig group and to widen what the list may show. It is a lookup
-- by user_id, so that is what carries the index.
CREATE INDEX note_shares_by_user ON note_shares (user_id);
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// NoteShare is one person's direct grant on a note: who they are, and what they
// may do. The person is carried whole rather than by id, because every caller of
// this wants to show them — a name and an avatar colour, never a bare id.
type NoteShare struct {
User Person `json:"user"`
Role Role `json:"role"`
}
// ShareNote grants a person a role on a note, or changes the role if they already
// had one. The owner is passed so the row records who did the sharing; the caller
// has already checked that they may.
//
// Sharing a note with its own owner is refused: the owner already has everything a
// share could give, and a row saying otherwise is a contradiction waiting to
// confuse the access check.
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
if !role.Valid() || role == RoleOwner {
return errors.New("store: a note can be shared as viewer or editor")
}
var createdBy sql.NullString
if err := db.QueryRowContext(ctx,
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if createdBy.String == userID {
return errors.New("store: a note is already the owner's")
}
_, err := db.ExecContext(ctx, `
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
noteID, userID, role, byUserID, time.Now().Unix())
return err
}
// UnshareNote removes a person's grant. Removing one that is not there is not an
// error: the end state the caller asked for — this person cannot see this note —
// is the same either way.
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
_, err := db.ExecContext(ctx,
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
return err
}
// ListNoteShares is who a note is shared with, each with their role, ordered by
// name so the list under a note does not reshuffle every time it is opened.
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id, u.name, u.avatar_color, s.role
FROM note_shares s
JOIN users u ON u.id = s.user_id
WHERE s.note_id = ?
ORDER BY lower(u.name)`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NoteShare
for rows.Next() {
var s NoteShare
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// NoteShareRole is the role a person holds on a note through a direct share, if
// any. The bool is whether a share exists at all — distinct from a share whose
// role could not be read — so a caller does not have to treat "no share" as an
// error.
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
var role Role
err := db.QueryRowContext(ctx,
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
if !role.Valid() {
// A role the database holds but this build does not understand is no access,
// never a guess — the same rule project roles follow.
return "", false, nil
}
return role, true, nil
}
// NotesSharedWith returns the ids, among those given, that are shared directly with
// the user. It answers the Delt med mig question in one query rather than one per
// note, and takes the ids it is scoping so it never has to load a person's whole
// world to mark a single page.
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
out := map[string]bool{}
if len(noteIDs) == 0 {
return out, nil
}
args := make([]any, 0, len(noteIDs)+1)
args = append(args, userID)
for _, id := range noteIDs {
args = append(args, id)
}
rows, err := db.QueryContext(ctx,
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = true
}
return out, rows.Err()
}
+13 -1
View File
@@ -37,6 +37,17 @@ type Note struct {
// set by a caller: it is derived, and a caller that set it would be describing
// a note whose text says something else.
Links []NoteLink `json:"links,omitempty"`
// SharedWithMe is true when the person reading the list reached this note
// through a direct share rather than by owning it or being in its project. It
// is what puts the note in the Delt med mig group and shows whose note it is.
// Derived per request, so it is never stored and never set by a caller.
SharedWithMe bool `json:"shared_with_me,omitempty"`
// Owner is who the note belongs to — its created_by — as a name and a colour,
// for the chip on a note shared with the reader. Set only when it helps the
// list say something; nil the rest of the time.
Owner *Person `json:"owner,omitempty"`
}
// NoteLink is one reference from a note to something else.
@@ -263,8 +274,9 @@ func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Not
UNION
SELECT project_id FROM project_members WHERE user_id = ?
)
OR notes.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID, userID)
}
// NotesLinking answers the backwards question: what points at this thing.
+109
View File
@@ -0,0 +1,109 @@
package store
import (
"context"
"errors"
"testing"
)
// One mail, one task — however many readers there are.
//
// Nothing could answer "has this already become a task?" before, so a second
// reader of the same mailbox made a second task. That is not hypothetical: a
// briefing run produced "Betal faktura fra Browns" twice in one pass, and a
// separate assistant reading the same inbox does the same thing every sweep.
func TestOneMailBecomesOneTask(t *testing.T) {
db := openTest(t)
ctx := context.Background()
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
t.Fatal(err)
}
inbox := newProject(t, db, user.ID)
const thread = "gmail:thread:18f2cabc"
first := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
if err := db.CreateTask(ctx, first, nil); err != nil {
t.Fatalf("first task: %v", err)
}
// The same thread again — a rerun, or somebody else's assistant.
second := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
err := db.CreateTask(ctx, second, nil)
if !errors.Is(err, ErrDuplicate) {
t.Fatalf("second task for the same thread: err = %v, want ErrDuplicate", err)
}
// And the question that makes the other agent safe.
id, err := db.TaskIDBySource(ctx, user.ID, thread)
if err != nil {
t.Fatal(err)
}
if id != first.ID {
t.Errorf("TaskIDBySource = %q, want %q", id, first.ID)
}
if id, err := db.TaskIDBySource(ctx, user.ID, "gmail:thread:aldrig-set"); err != nil || id != "" {
t.Errorf("an unseen thread answered %q, %v", id, err)
}
}
// A task somebody typed has no source, and there can be any number of those.
// The index is partial for exactly this reason — without the WHERE clause the
// second hand-written task in the database would be refused.
func TestTasksWithoutASourceDoNotCollide(t *testing.T) {
db := openTest(t)
ctx := context.Background()
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
t.Fatal(err)
}
inbox := newProject(t, db, user.ID)
for i, name := range []string{"køb kaffe", "ring til Andreas", "hent pakken"} {
if err := db.CreateTask(ctx, &Task{ProjectID: inbox, Content: name, CreatedBy: user.ID}, nil); err != nil {
t.Fatalf("task %d (%s): %v", i, name, err)
}
}
if id, err := db.TaskIDBySource(ctx, user.ID, ""); err != nil || id != "" {
t.Errorf("an empty source key answered %q, %v", id, err)
}
}
// Two people on one instance, each with their own mailbox. The same thread id
// reaching both of them is two tasks, correctly — the key is scoped to whoever
// the mail arrived for.
func TestTheSameThreadForTwoPeopleIsTwoTasks(t *testing.T) {
db := openTest(t)
ctx := context.Background()
var ids []string
for _, email := range []string{"kristian@example.dk", "andreas@example.dk"} {
u := &User{Email: email, Name: email, PasswordHash: "x"}
if err := db.CreateUser(ctx, u, "Indbakke"); err != nil {
t.Fatal(err)
}
inbox := newProject(t, db, u.ID)
task := &Task{ProjectID: inbox, Content: "Fælles tråd", CreatedBy: u.ID,
SourceKey: "gmail:thread:delt"}
if err := db.CreateTask(ctx, task, nil); err != nil {
t.Fatalf("%s: %v", email, err)
}
ids = append(ids, task.ID)
}
if ids[0] == ids[1] {
t.Error("the two people got the same task")
}
}
// A project to hang the tasks on. The tests are about the source key, not about
// where a task lives.
func newProject(t *testing.T, db *DB, ownerID string) string {
t.Helper()
p := &Project{OwnerID: ownerID, Name: "Indbakke"}
if err := db.CreateProject(context.Background(), p); err != nil {
t.Fatalf("create project: %v", err)
}
return p.ID
}
+37 -3
View File
@@ -32,6 +32,13 @@ type Task struct {
// CreatedBy is empty when the author's account has been deleted: `created_by`
// is ON DELETE SET NULL so the task survives the person who wrote it.
CreatedBy string
// SourceKey names the thing out there this task was made from, namespaced:
// `gmail:thread:18f2c…`. Empty for a task somebody typed.
//
// It exists so that the second reader of a mailbox — another assistant, a
// rerun, a person — can ask "has this already become a task?" and get an
// answer. CreateTask returns ErrDuplicate rather than making a second one.
SourceKey string
SortOrder float64
// SubtaskCount and SubtaskDone are what a row can say without being opened:
// that there is something underneath it, and how much of it is left. Counted
@@ -452,21 +459,48 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
_, err := tx.ExecContext(ctx, `
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
priority, due_date, due_datetime, due_timezone, duration_min,
recurrence_rule, assignee_id, created_by, sort_order,
recurrence_rule, assignee_id, created_by, source_key, sort_order,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
t.Content, t.Description, t.Priority, nullString(t.DueDate),
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
t.SortOrder, now.Unix(), now.Unix())
t.SourceKey, t.SortOrder, now.Unix(), now.Unix())
if err != nil {
// The one broken constraint that is not a bug: this source has already
// become a task. The caller skips it rather than making a duplicate,
// which is the entire reason the index exists.
if isUniqueViolation(err) {
return ErrDuplicate
}
return err
}
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
})
}
// TaskIDBySource answers "has this mail already become a task?" — the question
// nothing could answer before, and the one that stops two assistants reading the
// same inbox from making two of everything. Empty id and no error means no.
//
// Deleted tasks count. A task that was made from a mail and then thrown away is
// an answer — remaking it on the next sweep would be the same duplicate wearing
// a different hat.
func (db *DB) TaskIDBySource(ctx context.Context, userID, sourceKey string) (string, error) {
if sourceKey == "" {
return "", nil
}
var id string
err := db.QueryRowContext(ctx,
`SELECT id FROM tasks WHERE created_by = ? AND source_key = ?`,
userID, sourceKey).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return id, err
}
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
var max sql.NullFloat64
var err error
+64 -3
View File
@@ -205,9 +205,50 @@ func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections [
// Person is the public half of a user: what somebody else is allowed to see when
// their name has to appear next to a task. No address, no hash, no timezone.
type Person struct {
ID string
Name string
AvatarColor string
ID string `json:"id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
}
// PersonByID resolves one user to a name and colour, or ErrNotFound. Used to check
// that somebody a note is about to be shared with actually exists, without loading
// the whole account.
func (db *DB) PersonByID(ctx context.Context, id string) (Person, error) {
var p Person
err := db.QueryRowContext(ctx,
`SELECT id, name, avatar_color FROM users WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.AvatarColor)
if errors.Is(err, sql.ErrNoRows) {
return Person{}, ErrNotFound
}
return p, err
}
// UsersForSharing is everyone on the instance except the caller — the people a note
// can be handed to.
//
// Deliberately wider than ListPeople, which is the project collaborators. The whole
// point of sharing a note directly is to do it without first standing up a shared
// project, so the picker cannot be limited to people you already share a project
// with. An instance's accounts are a closed, invited set to begin with — there is
// no open signup — so listing them to each other is not disclosure, it is the
// address book the feature needs.
func (db *DB) UsersForSharing(ctx context.Context, meID string) ([]Person, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, name, avatar_color FROM users WHERE id <> ? ORDER BY lower(name)`, meID)
if err != nil {
return nil, err
}
defer rows.Close()
people := []Person{}
for rows.Next() {
var p Person
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
return nil, err
}
people = append(people, p)
}
return people, rows.Err()
}
// PeopleByIDs looks up several users at once.
@@ -466,6 +507,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
return err
}
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
// fresh. It answers false when the step has already been spent — the same code, or
// an earlier one, being presented a second time.
//
// The guard is in the statement, not in a read-then-write: two logins racing with
// the same stolen code must not both find the old value and both proceed. The row
// moves forward only when the new step is strictly greater, so exactly one of them
// updates a row and the other sees nothing change.
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
res, err := db.ExecContext(ctx,
`UPDATE users SET totp_last_step = ? WHERE id = ?
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
step, userID, step)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// InboxID returns the user's Inbox project.
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
var id string
+37
View File
@@ -926,6 +926,26 @@
</figure>
<div class="shots">
<figure class="shot">
<img
src="screenshots/calendar-light.webp"
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/notes-shared-light.webp"
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/board.webp"
@@ -1063,6 +1083,23 @@
written about it. Archive what is done with it; the bin is for mistakes.
</p>
</div>
<div class="cell">
<h3>Share a note, or a page</h3>
<p>
Hand a single note to a person — to read or to edit — without standing up
a project for it. Notes other people share with you gather under
<em>Shared with me</em>, each marked with whose it is. File it in a project
instead and everyone who can read the project reads the note.
</p>
</div>
<div class="cell">
<h3>How long it takes</h3>
<p>
A task can carry a length, and the calendar draws it to scale. Drag the
foot of a task to make it longer or shorter, or drag its body to another
day or time — the length comes along.
</p>
</div>
<div class="cell">
<h3>Waiting on others</h3>
<p>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+232 -12
View File
@@ -558,15 +558,35 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
// browser is pinned to Europe/Copenhagen and this process is not, and for two
// hours of every day they disagree about what today is.
await page.goto('/upcoming');
// Taller than the default window, and it is not cosmetic: the month grid is
// six rows, and in 720 px the last one is cut off by the fold. A synthetic
// drag drops at the target's centre without the auto-scroll a real pointer
// gets from the browser — so a target below the fold is never hit.
//
// That made this test depend on the date. The task sits on tomorrow, and when
// tomorrow is a Sunday, the day after is the first cell of the next row, down
// in the cut-off part. It passed for months and failed the morning tomorrow
// happened to be a Sunday, on a commit that had touched nothing near a
// calendar.
await page.setViewportSize({ width: 1280, height: 1000 });
await page.getByRole('button', { name: 'Måned', exact: true }).click();
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
await expect(chip).toBeVisible();
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
.toISOString()
.slice(0, 10);
// The neighbouring cell is taken from the grid, not worked out from the date.
// The day after tomorrow is not always in the month on screen — at the end of
// a short month it falls outside it entirely — and the cell next door always
// is. Falls back to the cell before, for the one day a month when there is no
// cell after.
const dates = await page
.locator('.day[data-date]')
.evaluateAll((cells) => cells.map((cell) => cell.dataset.date));
const at = dates.indexOf(from);
const to = dates[at + 1] ?? dates[at - 1];
await chip.dragTo(page.locator(`[data-date="${to}"]`));
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
@@ -1182,8 +1202,18 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
await expect(band.getByText('vande blomster')).toBeHidden();
// The events stayed where they were; a rescheduled task moves nothing else.
await expect(cell.locator('.tevent')).toHaveCount(1);
await expect(band.locator('.event')).toHaveCount(1);
//
// Named, not counted. A raw `.tevent` count here assumes this day holds nothing
// but this test's own events — and it does not: the suite shares one database,
// so an earlier test that drags a task across a month boundary can leave a timed
// task on this very day, and a timed task is a `.tevent` too. Counting them all
// made this pass alone and fail in the full run, on whatever date the arithmetic
// happened to land the two tests on the same cell. Asserting the meeting stayed
// and the task left is the thing the test actually means, and it is true however
// full somebody else left the day.
await expect(cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' })).toHaveCount(1);
await expect(cell.locator('.tevent').filter({ hasText: 'vande blomster' })).toHaveCount(0);
await expect(band.locator('.event').filter({ hasText: 'Feriedag' })).toHaveCount(1);
expect(trouble).toEqual([]);
});
@@ -1195,6 +1225,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
* thing this view was before anything was laid over it — and a Google account that
* is not there must not take it down with it.
*/
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// A timed task today at ten, half an hour long. Today, so the week opens on it
// without paging, and ten is inside the hours the grid draws by default.
const today = await page.evaluate(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const id = await page.evaluate(async (day) => {
const r = await fetch('/api/v1/tasks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
content: 'Møde med Anders',
due_date: day,
due_time: '10:00',
duration_min: 30
})
});
return (await r.json()).id;
}, today);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const chip = page
.locator(`[data-date="${today}"] .tevent.task`)
.filter({ hasText: 'Møde med Anders' });
await expect(chip).toBeVisible();
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
// Drag the foot of the task down a good stretch. It should grow, both on screen
// and in what the server stores — a resize is a change of length, not of time.
const box = await chip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
await page.mouse.up();
await expect
.poll(async () =>
page.evaluate(async (tid) => {
const r = await fetch('/api/v1/tasks/' + tid, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).duration_min;
}, id)
)
.toBeGreaterThan(30);
// And the chip is taller than it was, so the length is something you can see.
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
// The time did not move: it is still a ten o'clock task.
await expect(chip.locator('.at')).toHaveText('10:00');
expect(trouble).toEqual([]);
});
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/kalender');
@@ -2410,8 +2503,9 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Shared by being filed in a project. Not an access list of its own.
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
// Filing it in a project — the controls live in the Del popover in the footer.
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
// And it is really there, not merely labelled: the project's own page shows it.
@@ -2421,7 +2515,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
await expect(page.getByText('din igen')).toBeVisible();
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
@@ -2430,6 +2525,68 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
expect(trouble).toEqual([]);
});
test('en note kan deles med en person direkte, og dukker op hos dem', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
// Somebody to share with: a second account on the instance, made the ordinary
// way. The candidate list is drawn when the note opens, so they must exist
// before it does.
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('sofie@example.dk');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const sofieCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const sofie = await sofieCtx.newPage();
await sofie.goto(link);
await sofie.getByLabel('Navn', { exact: true }).fill('Sofie');
await sofie.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await sofie.getByRole('button', { name: 'Opret konto' }).click();
await expect(sofie.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// The owner writes a note and hands it to Sofie, as a reader.
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Ferieplan for hele holdet');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Sharing lives in a popover behind the Del button in the footer.
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
await share.getByRole('button', { name: 'Tilføj' }).click();
// She is on the note now, with the role she was given.
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
await expect(row).toBeVisible();
await expect(row.locator('select')).toHaveValue('viewer');
// And it reaches her: her own notes list grows a Delt med mig group with it in.
//
// Navigated on the origin the invite signed her in on. VERDANDE_BASE_URL is
// localhost while the suite drives 127.0.0.1, and a session cookie set on one
// host is not sent to the other — a bare "/noter" would land her logged out.
const noterURL = new URL('/noter', link).href;
await sofie.goto(noterURL);
await expect(sofie.getByRole('button', { name: /Delt med mig/ })).toBeVisible();
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeVisible();
// A viewer's note carries no star or archive control — it is not hers to put away.
const sofieRow = sofie.locator('.notes li').filter({ hasText: 'Ferieplan for hele holdet' });
await expect(sofieRow.locator('.star')).toHaveCount(0);
// Taken back, it leaves her list.
await row.getByRole('button', { name: 'Fjern adgang' }).click();
await expect(row).toBeHidden();
await sofie.goto(noterURL);
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeHidden();
await sofieCtx.close();
expect(trouble).toEqual([]);
});
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
page
}) => {
@@ -2890,10 +3047,11 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
await page.keyboard.type('Tagsten og lægter');
await page.waitForTimeout(1200);
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
// nogen har valgt det bagefter.
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
await page.locator('.sharewrap > button').click();
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
expect(shared).toContain('Tagprojekt');
// Og den står på projektets egen side.
@@ -2959,6 +3117,68 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
expect(trouble).toEqual([]);
});
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Titel');
await page.keyboard.press('Enter');
// En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere
// monospace. Før rettelsen holdt en <pre> på markøren, og alt derefter blev ved
// med at være kode.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('kode linje');
await page.keyboard.press('Enter');
await page.keyboard.type('almindelig tekst');
// Koden står i sin blok; teksten efter står uden for den.
await expect(ed.locator('pre')).toHaveText('kode linje');
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('linje et');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('linje to');
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
// lagde et <p> inde i <pre> og ikke kom nogen vegne.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
// Den blok er nu brødtekst, ikke kode: den står som <p>, ikke i en <pre>.
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
await ed.blur();
await page.waitForTimeout(1200);
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
expect(trouble).toEqual([]);
});
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
+43
View File
@@ -73,6 +73,11 @@ const SHOTS = [
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
// The new work, and in the light theme, which is what most people want to look
// at a landing page in. The calendar drawing tasks to their length, and a note
// somebody shared into the "Shared with me" group.
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
// Scrolled, because the themes are the second panel on the page and a picture
@@ -114,6 +119,13 @@ async function showNoteSource(page) {
await page.waitForTimeout(600);
}
async function openSharedNote(page) {
// The note Mette shared in, so the "Shared with me" group and its owner chip are
// what the picture is of rather than something the group happens to sit above.
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
await page.waitForTimeout(600);
}
async function showThemes(page) {
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
// already a few pixels inside the viewport when the page loads, so "if needed"
@@ -340,6 +352,23 @@ async function handOver(ids) {
await api(`/tasks/${ids.detail}/comments`, {
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
});
// A note of Mette's own, handed to the owner — so their Notes page has a
// "Shared with me" group with a real note in it, marked as hers. This is the
// other half of sharing: not the project path, but a page passed to a person.
const note = await api('/notes', {
body: `# Packaging quotes
Three back, one still to come:
- **Nordfelt** — 79 øre a bag at 2,000, plates included
- **Kolding Karton** — 84 øre, faster, kinder about small runs
- Bruun still owes me a number
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
});
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
return { colleague: me.id };
}
@@ -373,6 +402,8 @@ async function seed({ colleague, noteTitle }) {
).padStart(2, '0')}`;
};
const me = await api('/auth/me', null, 'GET');
// Two headings over the projects, which is what the sidebar grew for.
const work = await api('/project-groups', { name: 'Work' });
const home = await api('/project-groups', { name: 'Home' });
@@ -420,6 +451,17 @@ async function seed({ colleague, noteTitle }) {
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
// A handful given a time and a length, so the calendar's week draws them to
// scale rather than as a row of identical blocks — a stand-up, a long
// planning block, a call, a review. Spread across the day and the days, so
// the picture is a week that has a shape.
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
// The next couple of weeks, so Upcoming and the month grid have something in
// them rather than a row of empty cells.
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
@@ -560,6 +602,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
await api('/users', { email: 'anders@example.dk' });
return {
owner: me.id,
work: launch.id,
detail: detail.id,
invite: invite.link,
+6
View File
@@ -405,6 +405,12 @@ export const api = {
deleteNote: (id) => del(`/notes/${id}`),
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
// Sharing a note with people directly — its own access list, beside the project
// path. Owner only; the list call also returns who it can still be shared with.
noteShares: (id) => get(`/notes/${id}/shares`),
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
// there is no instance-wide registration behind these the way Gmail has one.
mailboxes: () => get('/mailboxes'),
+88 -3
View File
@@ -286,6 +286,48 @@
/** The cell lit up under the pointer. */
let over = $state(null);
// A resize in progress: which task, and where its foot is being dragged to, in
// minutes since midnight. Kept apart from the stored length so the task can
// follow the handle live and only be written when the drag ends.
let resizing = $state(null);
/**
* Dragging the foot of a timed task changes how long it is.
*
* Pointer events rather than the HTML5 drag the body uses: the two must not be
* the same gesture, or every attempt to make a task longer would instead pick it
* up and move it. stopPropagation keeps the handle's press from starting the
* move, and the column it lives in is what the minutes are measured against —
* the same box onDropAt reads a drop time from.
*/
function startResize(event, task, date) {
event.stopPropagation();
event.preventDefault();
const col = event.currentTarget.closest('.daycol');
if (!col || !task.due_datetime) return;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
const onMove = (e) => {
const box = col.getBoundingClientRect();
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
const minutes = hours.from * 60 + share * hours.minutes;
// Quarter-hour steps, at least a quarter long, never past midnight.
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
resizing = { ...resizing, to: snapped };
};
const onUp = async () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const current = resizing;
resizing = null;
if (current) await app.resize(current.id, current.to - current.from);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function onDragOver(event, date) {
if (!carries(event, TASK)) return;
accept(event);
@@ -394,7 +436,16 @@
if (!task.due_datetime) continue;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
out.push({ kind: 'task', ref: task, from, to: from + 30 });
// The task's own length, not a fixed half hour. A quarter-hour floor so a
// zero-minute task is still something to see and grab, and the end of the
// day is the ceiling. While a resize is in flight this task follows the
// handle instead of its stored length.
let to =
resizing && resizing.id === task.id
? resizing.to
: from + (task.duration_min || 30);
to = Math.min(Math.max(to, from + 15), 24 * 60);
out.push({ kind: 'task', ref: task, from, to });
}
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
@@ -547,12 +598,13 @@
</svelte:element>
{/snippet}
{#snippet taskChip(task, kind, extra = '', at = '')}
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
<!-- Draggable as well as clickable: moving something to another day is the
whole reason to be looking at a calendar. The browser needs a few pixels
of movement before it calls a press a drag, so the click survives. -->
<button
class={kind}
class:resizing={resizing?.id === task.id}
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
@@ -562,6 +614,16 @@
>
{#if at}<span class="at">{at}</span>{/if}
{task.content}
<!-- Only a task standing at a time can be made longer, and only where the
grid has room to show it — the timed column, which passes the date. -->
{#if date && task.due_datetime}
<span
class="resize"
title={t('cal.resize')}
aria-hidden="true"
onpointerdown={(e) => startResize(e, task, date)}
></span>
{/if}
</button>
{/snippet}
@@ -632,7 +694,7 @@
{#if item.kind === 'event'}
{@render eventChip(item.ref, date, 'tevent', place(item))}
{:else}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
{/if}
{/each}
</div>
@@ -937,6 +999,29 @@
cursor: grab;
}
/* The grip along the foot of a task, for dragging its length. Out of the way
until the task is hovered or being resized, so it never competes with the
words above it. */
.tevent.task .resize {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 7px;
cursor: ns-resize;
opacity: 0;
touch-action: none;
}
.tevent.task:hover .resize,
.tevent.task.resizing .resize {
opacity: 1;
background: linear-gradient(to bottom, transparent, var(--line-strong));
}
.tevent.task.resizing {
cursor: ns-resize;
z-index: 5;
}
.tevent.task[data-priority='1'] {
border-left-color: var(--p1);
}
+98
View File
@@ -120,11 +120,99 @@
function setStyle(style) {
stylesOpen = false;
editor?.focus();
if (style.key === 'bullet') return apply('insertUnorderedList');
if (style.key === 'numbered') return apply('insertOrderedList');
// formatBlock is unreliable around <pre>. Asked to turn a monospace line back
// into body, Chromium and WebKit nest a <p> *inside* the <pre> —
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
// out of the block from the menu. Do any conversion that touches a <pre> by
// hand, replacing the element rather than reformatting inside it.
const block = currentBlock();
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
replaceBlock(block, style.tag);
return;
}
apply('formatBlock', style.tag);
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
*/
function replaceBlock(block, tag) {
// The copy button lives inside the <pre> so it can sit in its corner; it is
// not text and must not travel into a paragraph. addCopyButtons puts it back
// on the next render for any <pre> that still needs one.
block.querySelectorAll?.('.copy').forEach((b) => b.remove());
const el = document.createElement(tag);
while (block.firstChild) el.appendChild(block.firstChild);
if (!el.firstChild) el.appendChild(document.createElement('br'));
block.replaceWith(el);
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
readState();
changed();
}
/**
* Steps out of a code block on Enter.
*
* A <pre> is preformatted, so the browser answers Enter by inserting a <br> and
* staying inside it — which means a monospace last line makes every line after
* it monospace too, with no way back to body by typing. Plain Enter now leaves
* the block: the tail after the caret becomes a paragraph below the <pre>, and
* that is where the caret lands. Shift+Enter is left untouched, so a soft line
* break still writes multi-line code inside the block.
*/
function exitPre(pre) {
pre.querySelectorAll('.copy').forEach((b) => b.remove());
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// Everything from the caret to the end of the block leaves with the new line.
const tail = document.createRange();
tail.setStart(range.endContainer, range.endOffset);
tail.setEnd(pre, pre.childNodes.length);
const frag = tail.extractContents();
// A break at the head of what we carried is the empty line return was pressed
// on; it should not open the paragraph with a blank row.
while (frag.firstChild && frag.firstChild.nodeName === 'BR') frag.removeChild(frag.firstChild);
const p = document.createElement('p');
if (frag.childNodes.length) p.appendChild(frag);
// The split can leave an empty text node behind, and a block with no element
// and no text is not a caret host in Chromium — the next keystroke falls back
// into the block above. An empty paragraph needs a <br> to be stood in.
if (!p.textContent && !p.querySelector('br, img')) {
p.replaceChildren(document.createElement('br'));
}
// And the same break seen from the other side, left dangling in the <pre>.
while (pre.lastChild && pre.lastChild.nodeName === 'BR') pre.removeChild(pre.lastChild);
// Leaving an empty block behind is leaving nothing: the <pre> itself goes.
if (!pre.textContent.trim()) pre.replaceWith(p);
else pre.after(p);
const caret = document.createRange();
caret.setStart(p, 0);
caret.collapse(true);
selection.removeAllRanges();
selection.addRange(caret);
}
/** What the caret is standing in, for the toolbar's pressed states. */
function readState() {
if (!editor) return;
@@ -470,6 +558,16 @@
}
if (event.key === 'Enter' && !event.shiftKey) {
// A code block is the one place the browser keeps the caret inside on
// Enter rather than starting a new block. Step out of it ourselves, or a
// monospace last line traps everything typed after it in monospace.
const block = currentBlock();
if (block && block.tagName === 'PRE') {
event.preventDefault();
exitPre(block);
changed();
return;
}
// After the split, not before it.
setTimeout(() => {
keepTitleFirst();
+24
View File
@@ -23,6 +23,9 @@
let priority = $state(4);
let dueDate = $state('');
let dueTime = $state('');
// Length in minutes, as a string for the field. Only meaningful with a time —
// see the field below — so it is seeded and shown alongside the clock.
let duration = $state('');
let labels = $state('');
let projectId = $state('');
let recurrence = $state('');
@@ -57,6 +60,7 @@
priority = task.priority;
dueDate = task.due_date ?? '';
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
duration = task.duration_min ? String(task.duration_min) : '';
labels = (task.labels ?? []).join(', ');
projectId = task.project_id;
recurrence = task.recurrence_rule ?? '';
@@ -436,6 +440,21 @@
disabled={!dueDate}
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
/>
<!-- Length in minutes, and only with a time to hang it on: a duration
without a start is a length of nothing, and the calendar has
nowhere to draw it. Empty clears it. -->
<input
type="number"
class="dur"
min="0"
step="15"
aria-label={t('detail.duration')}
placeholder={t('detail.durationShort')}
bind:value={duration}
disabled={!dueTime}
onchange={() =>
save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })}
/>
</div>
</div>
</div>
@@ -731,6 +750,11 @@
width: 7.5rem;
}
.due input.dur {
flex: none;
width: 5rem;
}
.due input:disabled {
opacity: 0.5;
}
+19 -2
View File
@@ -16,10 +16,24 @@
*/
export const da = {
// --- the shell ---------------------------------------------------------------
'notes.shareWith': 'Del i',
'notes.private': 'Kun mig',
'notes.shareWith': 'Læg i projekt',
'notes.private': 'Intet projekt',
'notes.shared': 'Noten er delt med projektets folk.',
'notes.unshared': 'Noten er din igen.',
// Deling med enkeltpersoner, ved siden af projektet.
'notes.groupSharedWithMe': 'Delt med mig',
'notes.sharedBy': 'Delt af {name}',
'notes.sharePeople': 'Del med personer',
'notes.sharePick': 'Vælg en person …',
'notes.shareButton': 'Del',
'notes.shareAdd': 'Tilføj',
'notes.shareRoleViewer': 'Kan læse',
'notes.shareRoleEditor': 'Kan rette',
'notes.shareRemove': 'Fjern adgang',
'notes.shareNobody': 'Ikke delt med nogen endnu.',
'notes.sharedToast': 'Delt med {name}.',
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
'notes.save': 'Gem',
'notes.format': 'Formatér',
'notes.bold': 'Fed',
@@ -583,6 +597,8 @@ export const da = {
'detail.none': 'Ingen',
'detail.due': 'Forfalder',
'detail.dueTime': 'Klokkeslæt',
'detail.duration': 'Længde i minutter',
'detail.durationShort': 'min',
'detail.labels': 'Etiketter',
'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt',
@@ -735,6 +751,7 @@ export const da = {
'cal.settings': 'Indstillinger',
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
'cal.allDay': 'Hele dagen',
'cal.resize': 'Træk for at ændre længden',
'cal.openInGoogle': 'Åbn i Google Kalender',
'cal.beyondWindow':
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
+19 -2
View File
@@ -12,10 +12,24 @@
*/
export const en = {
// --- the shell ---------------------------------------------------------------
'notes.shareWith': 'Share in',
'notes.private': 'Only me',
'notes.shareWith': 'File in project',
'notes.private': 'No project',
'notes.shared': "The note is shared with the project's people.",
'notes.unshared': 'The note is yours again.',
// Sharing with individual people, alongside the project.
'notes.groupSharedWithMe': 'Shared with me',
'notes.sharedBy': 'Shared by {name}',
'notes.sharePeople': 'Share with people',
'notes.sharePick': 'Choose someone …',
'notes.shareButton': 'Share',
'notes.shareAdd': 'Add',
'notes.shareRoleViewer': 'Can view',
'notes.shareRoleEditor': 'Can edit',
'notes.shareRemove': 'Remove access',
'notes.shareNobody': 'Not shared with anyone yet.',
'notes.sharedToast': 'Shared with {name}.',
'notes.unsharedPerson': '{name} can no longer see the note.',
'notes.shareOwnerOnly': 'Only the owner can share the note.',
'notes.save': 'Save',
'notes.format': 'Format',
'notes.bold': 'Bold',
@@ -579,6 +593,8 @@ export const en = {
'detail.none': 'None',
'detail.due': 'Due',
'detail.dueTime': 'Time',
'detail.duration': 'Length in minutes',
'detail.durationShort': 'min',
'detail.labels': 'Labels',
'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project',
@@ -728,6 +744,7 @@ export const en = {
'cal.settings': 'Settings',
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
'cal.allDay': 'All day',
'cal.resize': 'Drag to change the length',
'cal.openInGoogle': 'Open in Google Calendar',
'cal.beyondWindow':
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
+13
View File
@@ -403,6 +403,19 @@ class AppState {
await this.update(id, { due_date: date, due_time: clock });
}
/**
* Sets how long a task is, in minutes. Nothing else about it moves — the date
* and time are left alone, so making a task longer on the calendar does not
* also reschedule it.
*/
async resize(id, minutes) {
const previous = this.get(id);
if (!previous) return;
const rounded = Math.max(15, Math.round(minutes / 15) * 15);
if (previous.duration_min === rounded) return;
await this.update(id, { duration_min: rounded });
}
async remove(id) {
const previous = [...this.tasks];
this.tasks = this.tasks.filter((t) => t.id !== id);
+397 -21
View File
@@ -146,14 +146,28 @@
if (query.trim()) return [{ key: 'fundet', label: '', notes: ordered, plain: true }];
const out = [];
const favourites = ordered.filter((n) => n.pinned);
// Andres noter først, samlet. Som Favoritter-gruppen vises den kun, når der
// er noget i den — har man ingen delte noter, findes overskriften ikke og
// tager ingen plads. En delt note bor kun her, ikke også nede i en tidsbunke.
const sharedWithMe = ordered.filter((n) => n.shared_with_me);
if (sharedWithMe.length) {
out.push({
key: 'delt-med-mig',
label: t('notes.groupSharedWithMe'),
notes: sharedWithMe,
people: true
});
}
const favourites = ordered.filter((n) => n.pinned && !n.shared_with_me);
if (favourites.length) {
out.push({ key: 'favoritter', label: t('notes.groupFavourites'), notes: favourites, star: true });
}
let current = null;
for (const note of ordered) {
if (note.pinned) continue;
if (note.pinned || note.shared_with_me) continue;
const bucket = bucketOf(note);
if (!current || current.key !== bucket.key) {
current = { key: bucket.key, label: bucket.label, notes: [] };
@@ -170,6 +184,97 @@
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
let selectedId = $state(null);
// Deling af den åbne note med enkeltpersoner. Kun ejeren ser panelet, så det
// hentes kun for en note, man selv har lavet.
let shares = $state([]);
let shareCandidates = $state([]);
let sharePick = $state('');
let shareRole = $state('viewer');
let shareBusy = $state(false);
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// The whole of sharing — a project and named people — lives in a popover behind
// one button, so the note's footer stays a status and three buttons rather than
// a row that runs off its own width.
let shareOpen = $state(false);
let shareWrap = $state(null);
// Closing on a switch of note, and only then: the panel belongs to the note it
// was opened on. Tracked by id rather than by the object, so a save that returns
// a fresh copy of the same note does not slam the panel shut mid-use.
let sharePanelFor = $state(null);
$effect(() => {
const id = selected?.id ?? null;
if (id !== sharePanelFor) {
shareOpen = false;
sharePanelFor = id;
}
});
function onShareOutside(event) {
if (shareOpen && shareWrap && !shareWrap.contains(event.target)) shareOpen = false;
}
$effect(() => {
const id = selected?.id;
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
// under den nye.
shares = [];
shareCandidates = [];
sharePick = '';
if (!id || selected.created_by !== app.user?.id) return;
let alive = true;
api
.noteShares(id)
.then((r) => {
if (!alive || selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
})
.catch(() => {});
return () => {
alive = false;
};
});
const byName = (a, b) => (a.name ?? '').localeCompare(b.name ?? '');
async function addShare() {
if (!selected || !sharePick || shareBusy) return;
const person = shareCandidates.find((p) => p.id === sharePick);
shareBusy = true;
try {
await api.shareNote(selected.id, sharePick, shareRole);
shares = [...shares, { user: person, role: shareRole }].sort((a, b) => byName(a.user, b.user));
shareCandidates = shareCandidates.filter((p) => p.id !== sharePick);
app.toast(t('notes.sharedToast', { name: person?.name ?? '' }));
sharePick = '';
} catch (e) {
app.toast(humanMessage(e));
} finally {
shareBusy = false;
}
}
async function changeShareRole(userId, role) {
try {
await api.shareNote(selected.id, userId, role);
shares = shares.map((s) => (s.user.id === userId ? { ...s, role } : s));
} catch (e) {
app.toast(humanMessage(e));
}
}
async function removeShare(userId) {
const person = shares.find((s) => s.user.id === userId)?.user;
try {
await api.unshareNote(selected.id, userId);
shares = shares.filter((s) => s.user.id !== userId);
if (person) shareCandidates = [...shareCandidates, person].sort(byName);
app.toast(t('notes.unsharedPerson', { name: person?.name ?? '' }));
} catch (e) {
app.toast(humanMessage(e));
}
}
/**
* Flere ad gangen.
*
@@ -553,6 +658,13 @@
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<svelte:window
onclick={onShareOutside}
onkeydown={(e) => {
if (e.key === 'Escape' && shareOpen) shareOpen = false;
}}
/>
<div class="notes">
<aside>
<div class="head">
@@ -650,6 +762,14 @@
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
{#if group.star}
<span class="mark favmark" aria-hidden="true">★</span>
{:else if group.people}
<span class="mark peoplemark" aria-hidden="true">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="9" cy="8" r="3.2" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M16 6.2a3 3 0 0 1 0 5.6M17.5 19c0-2.2-1-3.9-2.6-4.7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</span>
{:else}
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
{/if}
@@ -692,6 +812,19 @@
{projectName(note.project_id)}
</span>
{/if}
{#if note.shared_with_me && note.owner}
<!-- Hvis note delt med mig: hvem den kom fra, i personens egen
avatar-farve. Samme form som projekt-mærkaten, så listen har
ét sprog for "det her hører til nogen". -->
<span
class="filed person"
style="--project: {note.owner.avatar_color}"
title={t('notes.sharedBy', { name: note.owner.name })}
>
<span class="dot" aria-hidden="true"></span>
{note.owner.name}
</span>
{/if}
</span>
<span class="preview">{preview(note)}</span>
</button>
@@ -699,6 +832,10 @@
Ved siden af hinanden tog de toogfyrre pixels af en liste,
der er tre hundrede bred — og de bruges sjældent, mens
titlen og uddraget læses hele tiden. -->
<!-- Ingen knapper på en note, en anden ejer: at arkivere eller
stjernemarkere ændrer selve noten, og den er ikke min at lægge
væk eller pinne. Rækken læses og åbnes; resten er ejerens. -->
{#if !note.shared_with_me}
<div class="rowactions">
<!-- Én note lagt væk, uden at markere den først. Markeringen er
til flere; det her er til den ene, man står med — og i
@@ -724,6 +861,7 @@
{note.pinned ? '★' : '☆'}
</button>
</div>
{/if}
</div>
</li>
{/each}
@@ -754,25 +892,109 @@
{/each}
</span>
{/if}
<!-- Sharing a note is filing it in a project.
Not an access list of its own: projects already have members, roles
and invitations, all of them tested and all of them things people
have already learnt here. A second way to give somebody access is a
second place to get it wrong, and the two would disagree on the day
one of them was changed. -->
<label class="share">
<span class="hint">{t('notes.shareWith')}</span>
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
<option value="">{t('notes.private')}</option>
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
<div class="actions">
<!-- All of sharing behind one button. A note has two ways in — a
project and named people — and both belong to the owner; the
panel gathers them rather than lining them up across the footer. -->
{#if ownsSelected}
<div class="sharewrap" bind:this={shareWrap}>
<button
class="button"
class:on={shareOpen}
aria-expanded={shareOpen}
aria-haspopup="dialog"
onclick={() => (shareOpen = !shareOpen)}
>
{t('notes.shareButton')}{#if shares.length}<span class="count">{shares.length}</span>{/if}
</button>
{#if shareOpen}
<div class="sharepanel" role="dialog" aria-label={t('notes.shareButton')}>
<!-- Filing it in a project: everybody who can read the project
reads the note. A real <label> around the select, so it is
named for a screen reader and not only for the eye. -->
<section>
<label class="field">
<span class="phead">{t('notes.shareWith')}</span>
<select
aria-label={t('notes.shareWith')}
value={selected.project_id ?? ''}
onchange={(e) => share(e.currentTarget.value)}
>
<option value="">{t('notes.private')}</option>
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
</section>
<!-- Handing it to named people, without a project between. -->
<section>
<span class="phead">{t('notes.sharePeople')}</span>
{#if shares.length}
<ul class="sharelist">
{#each shares as sh (sh.user.id)}
<li>
<span class="who">
<span
class="avatar"
style="--who: {sh.user.avatar_color}"
aria-hidden="true"
></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
<option value="">{t('notes.sharePick')}</option>
{#each shareCandidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={shareRole}>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="button"
disabled={!sharePick || shareBusy}
onclick={addShare}
>
{t('notes.shareAdd')}
</button>
</div>
{/if}
</section>
</div>
{/if}
</div>
{/if}
<button class="button" onclick={save}>{t('notes.save')}</button>
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
{#if ownsSelected}
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
{/if}
</div>
</footer>
{:else}
@@ -1397,13 +1619,167 @@
font-family: var(--mono, ui-monospace, monospace);
}
.share {
/* Sharing lives in a popover above the Del button, so the footer stays a status
and a few buttons rather than a row that runs off its own width. */
.sharewrap {
position: relative;
display: inline-flex;
}
.button.on {
background: var(--surface-raised);
}
.sharewrap .count {
margin-left: 0.4em;
padding: 0 0.45em;
border-radius: var(--radius-full);
background: var(--line-strong);
font-size: 0.85em;
}
.sharepanel {
position: absolute;
bottom: calc(100% + var(--s2));
right: 0;
z-index: 20;
width: 20rem;
max-width: 80vw;
display: flex;
align-items: center;
flex-direction: column;
gap: var(--s3);
padding: var(--s3);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
text-align: left;
cursor: default;
}
.sharepanel section,
.sharepanel .field {
display: flex;
flex-direction: column;
gap: var(--s1);
}
.share select {
.sharepanel .phead {
font-size: var(--text-xs);
font-weight: 600;
color: var(--ink-muted);
}
.sharepanel select {
font-size: var(--text-xs);
padding: 2px var(--s1);
}
/* The person icon on the Delt med mig heading, sized to the row of text it
sits in rather than to a fixed pixel. */
h3.group .peoplemark {
flex: none;
width: 1em;
height: 1em;
color: var(--ink-faint);
}
h3.group .peoplemark svg {
width: 100%;
height: 100%;
display: block;
}
/* The person chip: the project mark's shape in a person's own colour. No ▸
before it — the coloured dot already says "belongs to", and two markers on
one short chip is noise. It sits next to the title rather than pushed to the
right, so in the Delt med mig group it reads as "Ferieplan — Sofie". */
.filed.person {
margin-left: 0.5em;
max-width: 40%;
}
.filed.person::before {
content: none;
}
.sharepanel .none {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.sharelist {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sharelist li {
display: flex;
align-items: center;
gap: var(--s1);
font-size: var(--text-xs);
}
.sharelist .who {
display: flex;
align-items: center;
gap: 0.375em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sharelist .avatar {
width: 8px;
height: 8px;
flex: none;
border-radius: var(--radius-full);
background: var(--who, var(--line-strong));
}
.sharelist select {
font-size: var(--text-xs);
padding: 1px var(--s1);
margin-left: auto;
}
/* The × that takes access away. A word would crowd the row; the mark is enough
beside a name and a role. */
.unshare {
flex: none;
width: 1.3em;
height: 1.3em;
line-height: 1;
border: none;
background: transparent;
color: var(--ink-faint);
cursor: pointer;
border-radius: var(--radius);
}
.unshare:hover {
color: var(--danger, var(--ink));
background: var(--surface-raised);
}
.addshare {
display: flex;
align-items: center;
gap: var(--s1);
flex-wrap: wrap;
margin-top: var(--s1);
}
/* The person picker takes its own row, so the role and the button below it never
get squeezed to nothing in the panel's width. */
.addshare select:first-child {
flex: 1 1 100%;
}
.addshare .button {
margin-left: auto;
}
.addshare select {
font-size: var(--text-xs);
padding: 2px var(--s1);
}