Compare commits

...
25 Commits
Author SHA1 Message Date
github-actions[bot] 7d9bd737ab chore: bump version to 0.33.0 2026-08-18 10:32:17 +00:00
Frederic CabassutandClaude Opus 5 4812eaf1ed docs: correct the admin user-management claims
Issue #77 was caused by these docs. FEATURES.md promised "Reset
passwords for any user" when no such control existed, so a user
reasonably filed a bug against a promise the software never made. The
docs were describing the API surface, not the UI. Now that the control
ships, they describe what it actually does — and the same audit turned up
three neighbouring claims that were already wrong.

Corrected:
- "Reset passwords for any user" — it is any user other than yourself,
  whose account signs in with a password, and Crumbs generates the
  password and shows it once rather than the admin choosing one.
- "Revoke all sessions ... for any user" — Revoke has always been
  self-excluded in the UI. Wrong before this feature existed.
- The AUTH.md sentence covering both capabilities in one breath, so
  fixing only the reset half would have left the revoke half wrong.
- Two references to a "Settings > Users > Invite User" affordance that
  does not exist; the section is headed Create User. Same defect as #77
  — documentation promising UI that was never built — one of them buried
  in the Authentik setup guide.

Newly documented, all previously silent:
- The sole-admin dead end, and its bound. There is no forgot-password
  flow, the reset control excludes your own row, and Profile requires
  your current password. Another admin can reset you. Failing that, an
  OAuth sign-in recovers the account when a provider is configured and
  the email matches — at the cost of handing the account to that
  provider permanently. Only with no second admin and no provider is
  there genuinely no way back in through the app. An earlier draft
  stated that absolutely, which would have pushed an operator toward
  restoring from backup when one OAuth sign-in would have done.
- The OAuth pre-link trap: a password set during invite works only until
  the first OAuth sign-in, which hands the account to the provider.
  Ticking OAuth-only avoids the surprise. Tracked for a real fix in #78.
- That the reset notification is best-effort. Both layers swallow send
  errors, so a successful reset is not evidence the user was told.
- That a displayed password is only good until that account's password
  changes again, by any route, with nothing marking it stale.
- That an unconfirmed reset shows the password anyway, and that one
  should be kept and retried.
- That the control is also unavailable while a password is on screen —
  the one unavailable state that shows no reason, so an admin meeting a
  dead button has somewhere to look.

No openapi.yaml or API.md change: neither documents /api/admin/* at all,
so the new 400 needs no spec edit and pnpm docs:api regenerates nothing
relevant. That gap predates this work.

Verified: pnpm check 0 errors / 14 pre-existing warnings. Every factual
claim traced to code by the author and again by an independent reviewer,
which is how the false recovery claim was caught.

Reviewed-by: adversarial-review (13 of 14 claims verified true on the
first pass; failed for one false absolute, since bounded)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 afce024cac refactor(wu-003b): one uniform icon per row action, and guard the reveal
Follow-up to d5c7ab6 after visual review. The 44px Reset button sat
beside ~24px siblings, and four text labels wrapped — Delete dropped to
its own line at 900px, three lines at 360px.

All four row actions are now identical 44x44 icon buttons on one line:
shield-check/shield-off, key-round, log-out, trash-2. Each carries a
tooltip for a mouse and an aria-label, since an icon alone has no
accessible name. In-flight swaps to a spinning loader-circle and an
"Resetting…" accessible name, so the e2e assertion reads the accessible
name rather than visible text that no longer exists.

`flex-wrap` on the strip is what keeps the squares square: flex
compresses items only on a line that overflows, so breaking the line
means no button is ever asked to shrink. Without it the four flatten to
40x44 at 320px — under the minimum, on a row whose Delete is
irreversible. They hold one line down to 336px, exactly 4x44 plus the
gaps, and take a second line below that. Correctly sized targets over
two lines beat cramped ones on one.

A first attempt also put `shrink-0` on each button and credited it in a
comment. Measurement disproved that: strip the token and the geometry is
byte-identical at every width from 280 to 900, because `flex-wrap` has
already spared the buttons from ever shrinking. The dead tokens are gone
and the comment now names the real mechanism, while noting that the
container's identical-looking `shrink-0` DOES carry weight — from 640px
the row turns sm:flex-row, where the truncating name block would
otherwise squeeze the strip.

Two things stay deliberately as they were. Copy and Dismiss remain text
buttons: Dismiss irreversibly destroys a credential the server may
already hold, and an icon-only cross beside Copy is the one place
ambiguity could cost a password. The blocked-reset reason remains visible
in-row text rather than moving into the tooltip, which needs hover the
reporter's Android device cannot give.

Also closes a gap in the reveal's regression cover. The rule that an
unconfirmed outcome REVEALS the generated password protects the only copy
of a credential the server may already hold, but only the network-abort
trigger was tested. Two more scenarios now pin the lines that decide it:
a 5xx carrying a parseable message (guarding `res.status < 500`) and a
4xx with an unreadable body (guarding `.json().catch()`), the latter
being what a reverse proxy returns as an HTML error page.

DoD items verified:
- [x] 1-3. four identical >=44px icon buttons, no wrap at 360px+,
      per-icon subpath imports at 16-20px
- [x] 4-5. Copy/Dismiss still text; reason still visible in-row text
- [x] 6. spinner plus "Resetting…" accessible name, aria-disabled
- [x] 7-9. no disabled attribute, focusable, no request when
      unavailable; Dismiss focus return; all 10 data-testids kept
- [x] 10. CSS variables only, no hover-only visibility
- [x] 11-12. 5xx-with-reason and unparseable-body both reveal
- [x] 13. distinct victim emails, no new failed logins (suite: 3 of 5)
- [x] 14. no regression in the 22 properties of d5c7ab6

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files;
admin-users.spec.ts 31 passed; full e2e suite 129 passed. Removing
flex-wrap fails the new scenario with "Expected: >= 44, Received: 40",
reproduced here, so the guard covers the token that actually matters.

Reviewed-by: adversarial-review (PASS on the icon work; a second
reviewer failed the first attempt for crediting shrink-0 with an effect
mutation testing showed it does not have)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 ffde151b75 feat(wu-003): add admin reset-password control to Settings > Users
Closes the UI gap in issue #77. The endpoint, resetPassword() and the
notification email have all existed since the feature was built; only
the control was missing, which is why the docs promised something the
screen did not offer.

The control renders on EVERY row, the admin's own included. It is
enabled only for accounts that sign in with a password and that are not
the signed-in admin; otherwise it is presented as unavailable WITH A
VISIBLE REASON. A control that simply vanishes is the confusion this
replaces, so silence was not an option.

No new control uses the `disabled` attribute. Unavailable is expressed
with aria-disabled, the element stays focusable, and the handler refuses
activation itself — a disabled button leaves the tab order, so a
keyboard or TalkBack user loses the element they just activated, and a
title tooltip explains nothing on a touch screen. The reporter is on
Android.

Two rules protect the credential, and both are enforced by the shape of
the state rather than by convention:

- An unconfirmed outcome REVEALS the password. If the request left the
  device and no answer came back, the server may well have stored it,
  making the revealed string the only copy in existence. Discarding it
  could leave an account holding a credential nobody knows.
- `password` and `message` are independent fields, so an error has
  nowhere to write that would replace a reveal. Revealed rows are also
  marked unavailable, so a second reset cannot be issued until Dismiss.

Offline is a definite state, not a lost response: the service worker
caches only GET /api/*, so a PATCH never leaves the browser. It is
checked before anything is generated, and reported as the certainty it
is.

The live region is rendered empty on first paint and only its text is
replaced, because a region inserted together with its content is not
reliably announced. It announces that a password appeared and where —
never the secret itself, which a polite region would speak aloud once,
unrepeatably, to whoever is in earshot.

DoD items verified (22):
- [x] 1-2. control on every row; enabled iff eligible and not self,
      else aria-disabled with a visible, aria-describedby reason
- [x] 3-4. PATCHes newPassword; "Resetting…" and no double-submit
- [x] 5-6. revealed as selectable text, announced; stays unavailable
- [x] 7. Copy confirms success; failure offers a manual-copy hint
- [x] 8-10. 4xx reveals nothing; offline definite; unknown REVEALS
- [x] 11-12. errors never replace a reveal; both announced
- [x] 13-14. 404 drops the row and its plaintext; rows isolated
- [x] 15-17. Dismiss clears and restores focus; 44px targets;
      activating an aria-disabled control fires no request
- [x] 18-19. victim login from a separate context, old password 401
      last; plaintext in no storage, no IndexedDB, no console
- [x] 20-22. CSS variables only, no hover-only; untruncated at 360px;
      declarative Gherkin with distinct victim emails

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files;
admin-users.spec.ts 28 passed; full e2e suite 126 passed.

The reviewer additionally mutation-tested the security core, each
mutation rebuilt and caught with clean attribution: revealing null on an
unconfirmed outcome, forcing the offline pre-flight false (which falls
into the unknown path and would reveal a password that never left the
browser), dropping the state merge in patchReset, swapping aria-disabled
for disabled (which breaks focus return), and deleting the activation
guard — the last proving the force-click assertions test the component
refusing rather than Playwright declining to click.

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 a6f86ec1f0 fix(wu-002): refuse password resets that could never work
Second slice of issue #77. PATCH /api/admin/users/[id] accepted
{ newPassword } for any account and returned 200, but resetPassword()
writes only passwordHash while verifyPassword() matches only rows whose
authProvider is 'password'. So for an OAuth-only account (the
"OAuth-only" checkbox stores 'none') or anyone who has signed in via
OAuth (authProvider becomes the provider name), the endpoint wrote a
hash that could never be used to sign in, returned success, and fired a
"your password was reset" email. It lied.

The endpoint already guards both other self-affecting operations
server-side — "Cannot change your own role" and "Cannot delete your own
account" — but a newPassword targeting the requesting admin succeeded.

Two early returns in the newPassword branch, ordered length -> self ->
eligibility so the existing length 400 keeps precedence. Both are placed
before the resetPassword() call and before the email block: that
ordering is what guarantees no unusable hash is written and no
misleading email is sent, and it is a property of control flow rather
than of an assertion, because neither is observable from a test.
passwordHash never leaves the server (toUser omits it) and
isEmailConfigured() is false under e2e, so an assertion either way would
pass for a broken implementation too.

Eligibility comes from canResetPassword() rather than an inline
comparison, so the endpoint and the settings UI cannot drift; the 400
message comes from passwordResetBlockedReason() so the API reason and
the UI explanation are the same string.

DoD items verified:
- [x] 1. eligible user other than self still 200s, new password works
- [x] 2. authProvider 'none' returns 400 naming the reason
- [x] 3. positive control: an eligible user CAN log in after a reset
- [x] 4. targeting own id returns 400 naming the reason
- [x] 5. sub-8-char password still 400s for length, beating eligibility
- [x] 6. both guards precede the write and the email (diff-verified)
- [x] 7. role, revokeSessions and DELETE byte-unchanged (15+/0-)
- [x] 8. eligibility imported from wu-001, not re-implemented
- [x] 9. declarative Given/When/Then, distinct victim emails throughout

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files;
pnpm build && pnpm test:e2e admin-users.spec.ts 14 passed (7 auth-setup
+ 3 pre-existing + 4 new). The reviewer additionally reverted the
endpoint to its pre-change form and confirmed both guard scenarios fail
with "Expected: 400, Received: 200", so they are genuine regression
tests rather than assertions of behaviour that already held.

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 51dd844f96 feat(wu-001): add password generator and reset-eligibility utilities
Groundwork for issue #77 (admin cannot reset other users' passwords).
Two pure, colocated-tested modules shared by the API route and the
settings UI that follow.

password.ts — generatePassword() over a charset pinned at exactly 64
characters. 64 is a power of two, so nanoid's customAlphabet takes its
exactly-uniform masking path (256 % 64 === 0) rather than the rejection
branch; 120 bits of entropy at the default length of 20. Visually
ambiguous characters (0 O o 1 l I) are excluded so a password survives
being read aloud or retyped. Class coverage is guaranteed by discarding
and regenerating a whole password that is missing a group, which stays
uniform over exactly the set of valid passwords — rather than forcing
characters into positions and shuffling, which would need its own
unbiased index source.

auth-methods.ts — canResetPassword() and passwordResetBlockedReason(),
the single source of truth for eligibility so the endpoint and the
component cannot drift. Password login is gated on authProvider
(verifyPassword matches only rows where it equals 'password'), so
writing a hash for any other provider stores a credential that can
never be used to sign in.

The reason lookup is a Map, not an object literal: an object inherits
Object.prototype, so a lookup for 'toString' or '__proto__' resolves to
an inherited member and the nullish fallback never fires — returning a
function where the signature promises string | null. A Map has no such
keys, so totality holds by construction instead of by a guard a later
refactor could drop.

DoD items verified:
- [x] 1. generatePassword() returns 20 chars; explicit length honoured
- [x] 2. always >=1 lowercase, uppercase, digit and symbol
- [x] 3. never contains 0 O o 1 l I
- [x] 4. randomness from nanoid customAlphabet; no Math.random
- [x] 5. throws for length < 8 (the server minimum)
- [x] 6. retry bounded at 100 with an unconditional throw (inspection)
- [x] 7. canResetPassword true only for 'password'
- [x] 8. totality: every blocked input yields a non-empty string
- [x] 9. exact reason copy per provider; null when allowed
- [x] 10. colocated tests pass

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files (from
288 / 24); coverage 45.52 lines, 45.52 statements, 54.01 functions,
84.87 branches — every metric above both its .coverage-thresholds.json
floor and the previous baseline, new files at 100%.

Reviewed-by: adversarial-review (PASS, second reviewer; first found a
prototype-chain totality violation, since fixed and re-reviewed fresh)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
github-actions[bot] 0d21f7efda chore: pin docker-compose to v0.32.0 [skip ci] 2026-08-05 09:03:49 +00:00
github-actions[bot] 60c5f2298c chore: bump version to 0.32.0 2026-08-05 08:57:08 +00:00
Frederic CabassutandClaude Sonnet 5 1ced23033e fix(e2e): match note-card locator on exact title, not substring content
noteCard() filtered on hasText, matching against a card's *entire*
rendered text — with note-linking, a card's content preview can now
render a linked note's title as plain text (a stripped note-link), so
a substring match against the whole card ambiguously resolves to more
than one card whenever one note's title happens to appear inside
another's preview. Scoped the match to the card's <h3> title element
with an anchored exact-match regex instead.

This was failing CI on two tests: the note-linking suite's own
backlink scenario, and — since test note titles aren't otherwise
isolated across files in the same run — an unrelated notes-crud test
whose note title happened to collide with a leftover "Linker Note"
card's rendered preview.

Verified: full e2e suite (114/114) passes locally against this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:51:08 +02:00
Frederic CabassutandClaude Sonnet 5 3eb8d2eeab fix: close note-switching race conditions and restore offline-first note open
Three issues found in review, all around switching to a different note
while the editor is already open (via a note-link or backlink chip):

- TiptapEditor's onTransaction/onUpdate had no guard against writing
  state after the component is torn down. Removing a focused editor's
  DOM node fires a native blur, which ProseMirror turns into one last
  transaction synchronously during that same teardown pass, crashing
  with state_unsafe_mutation. Deferred the onTransaction write to a
  microtask (checked against a destroyed flag) so it lands after
  Svelte's current pass finishes; guarded onUpdate synchronously.

- openEditor/openNoteById in NotesView.svelte awaited a network fetch
  before ever showing the editor, for every note open (not just
  link-driven navigation) — a regression for an offline-first app, and
  the source of a real race: two rapid note-link clicks left two
  fetches in flight with no ordering guarantee, so whichever resolved
  last won, not whichever was clicked last. Reverted to opening
  instantly from local/IndexedDB data (with a request-id guard against
  the residual race), and instead fetch backlinks lazily in
  NoteEditor.svelte only if not already present — mirroring the
  existing attachments-fetch pattern already in that file. Also
  applied the same post-teardown guard to that pre-existing attachments
  effect, since remounting on note-switch newly exposes it to the same
  class of race.

- FormattingToolbar's closeDropdowns() cleared search results but never
  cancelled the pending debounce timer, so a stale query could
  repopulate results after the dropdown had already closed. Added the
  missing clearTimeout, plus a try/catch around the search fetch for
  consistency with the pattern used elsewhere in the app.

Verified: pnpm check (0 errors), full unit suite (316/316), and the
existing e2e suite including all note-linking scenarios (19/19) all
pass against these changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:51:08 +02:00
Arkaprava Gaine 534d84224d tests for formating and note linking 2026-08-05 10:51:08 +02:00
Arkaprava Gaine bbfde5bac0 custom utility component of tiptap to solve the thing : the 2026-08-05 10:51:08 +02:00
Arkaprava Gaine f33a4037b1 utils and markdown changes needed for crumb:// linking to work 2026-08-05 10:51:08 +02:00
Arkaprava Gaine e6d1f18311 adding UI for note-linking 2026-08-05 10:51:08 +02:00
Arkaprava Gaine 1eebd009ac server functions and service wiring on save and attach backlinks 2026-08-05 10:51:08 +02:00
Arkaprava Gaine 1ca2ba5b79 extrcation notes utility for parsing note links 2026-08-05 10:51:08 +02:00
Arkaprava Gaine 331c56981d add notelinks tabel for note to note references 2026-08-05 10:51:08 +02:00
Frederic Cabassut c640868957 Revert "feat: add extractNoteLinks utility for parsing note-link references"
This reverts commit 39e247a46c.
2026-08-05 10:32:26 +02:00
Frederic CabassutandClaude Sonnet 5 dc20ac0f60 fix(ci): pin e2e Docker image to playwright v1.62.1 to match npm package
The @playwright/test npm package was bumped to ^1.62.1 during an earlier
dependency-vulnerability pass, but the CI container image stayed pinned
to v1.58.0-noble — Playwright requires the container's bundled browser
binaries to exactly match the npm package's version, so e2e jobs have
been failing with "Executable doesn't exist" ever since that bump landed
on main, unrelated to any feature branch's own changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:30:01 +02:00
Frederic Cabassut c341fe2b86 feat: add extractNoteLinks utility for parsing note-link references 2026-08-03 15:46:19 +02:00
Frederic CabassutandClaude Sonnet 5 35fceac5a1 docs: add note-linking implementation plan
11 TDD tasks covering schema, extraction/sync/backlink service functions,
the NoteLink tiptap node (parse/serialize + NodeView + title index),
toolbar note-search UI, click-to-navigate wiring, the backlinks UI
section, preview/public rendering, and e2e coverage.

Self-review caught and fixed three real bugs before this was usable:
a test referencing a USER_ID constant that doesn't exist in the actual
notes-service.test.ts (it's OWNER_ID), a posAtDOM-based position lookup
in the NodeView's remove button where tiptap's own getPos() is the
correct API, and a title-index gap where trashed notes remain in a
note's own IndexedDB cache (only collaborators have them filtered out
at sync time) — so the index needed its own explicit trashed filter or
a trashed note's links would never grey out for its owner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 15:25:55 +02:00
Frederic CabassutandClaude Sonnet 5 895edc05a3 docs: address review gap in note-linking spec — link removal affordance
Re-reviewed the committed spec and found instant-navigate-on-click left
no way to ever remove or repoint an existing note-link, since external
links solve this via a click-triggered popover that navigation now
replaces. Resolved with a hover-revealed x on the chip (touch-visible
by default), keeping the chosen click-to-navigate behavior intact.

Also verified three feasibility risks while reviewing: tiptap-markdown's
custom node serialization API is real and documented; version restore
already goes through updateNote() so link-sync comes for free; and the
line-based content-merge algorithm is markdown-agnostic, so concurrent
edits can't corrupt a note-link reference any more than any other text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 14:59:03 +02:00
Frederic CabassutandClaude Sonnet 5 50041d2298 docs: add note-linking design spec
Brainstormed with the user for issue #74. Notes can reference each
other via the existing link toolbar, rendered as a live tiptap node
(not a mark) so the label always tracks the target's current title,
plus a backlinks section on the referenced note.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 14:45:06 +02:00
Frederic CabassutandClaude Sonnet 5 d4ed358ba3 fix(security): patch dependency CVEs and close SSRF/XSS gaps in MCP image upload
Bumps deps to clear all 69 pnpm audit findings (nodemailer 8->9 for the
raw-option SSRF/arbitrary-file-read CVE; svelte, hono/express/qs chain,
linkify-it, and the rest resolved within their existing semver ranges
via a lockfile refresh). Caps the previously-unbounded cookie override
at <1 so it can't silently jump to the API-incompatible 2.x line again.

Hardens the MCP upload_image tool, which fetched an arbitrary caller-
supplied URL and stored whatever content-type came back with no checks:
- validates every resolved DNS record (not just the first) against
  private/loopback/link-local/multicast ranges, then pins the actual
  connection to the validated address so a second, unvalidated lookup
  at connect time can't be used for DNS-rebinding SSRF
- rejects non-image content-types and enforces the same 10MB cap as
  the manual upload endpoint
- adds X-Content-Type-Options: nosniff to both attachment-serving
  routes as defense in depth

Also validates the admin role field server-side (previously trusted
the client-supplied string), and fixes two incidental regressions
surfaced by the dependency bump: StarterKit now bundles its own
Link/Underline extensions (disabled to avoid duplicate registration),
and FormattingToolbar could call methods on an already-destroyed
editor instance during the markdown-mode toggle race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 13:43:25 +02:00
github-actions[bot] 97523ea087 chore: pin docker-compose to v0.31.0 [skip ci] 2026-07-31 10:12:07 +00:00
46 changed files with 6737 additions and 2070 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
name: E2E Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.0-noble
image: mcr.microsoft.com/playwright:v1.62.1-noble
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.30.1
image: ghcr.io/bretzel-app/crumbs:0.32.0
container_name: crumbs
ports:
- "3000:3000"
+22 -3
View File
@@ -10,7 +10,24 @@ On first launch, Crumbs presents a setup screen to create the initial admin acco
- Passwords are hashed with **Argon2** (argon2id variant)
- Sessions use httpOnly cookies with 30-day expiry
- Admins can reset passwords and revoke sessions for any user via Settings
- Admins can revoke the sessions of, and reset the password of, users other than themselves via **Settings > Users** — see [Admin password resets](#admin-password-resets)
### Admin password resets
An admin can reset the password of any user other than themselves, provided that account signs in with a password. Crumbs generates the new password and shows it once in that user's row — copy it and pass it to the user out of band, because it is not stored anywhere and cannot be shown again once dismissed. If SMTP is configured Crumbs also tries to notify the user that their password was reset, but the attempt is best-effort: a delivery failure is logged on the server and never surfaced in the UI, so a successful reset is not evidence the user was told. The notification never contains the password.
The control appears on every row, but is unavailable in two cases, each stating its reason on the row:
- **Your own row** — change your own password in **Settings > Profile**, which verifies your current password first.
- **An account that signs in via OAuth/SSO** — it has no password the login form would accept, so setting one would hand the user a credential that cannot work.
It is also unavailable while a generated password is still on screen, and that case gives no reason — dismiss the password first if you need to generate another.
A reset does not sign the user out. **Revoke all sessions** is a separate action on the same row and, like the reset, is not offered for your own account.
A displayed password is only good until that account's password changes again: any later reset — by another admin, or by the user themselves from **Settings > Profile** — silently invalidates one still on screen, and nothing marks it as stale. Separately, if Crumbs cannot confirm a reset it says so and shows the password anyway; keep that one, since it may or may not have been applied, and reset again if the user cannot sign in.
> **No password recovery.** Crumbs has no forgot-password flow. An admin who forgets their own password cannot reset it themselves: the admin control excludes their own row, and the profile flow requires the current password. Another admin can reset you, so keep a second admin account as cheap insurance. Failing that, if an OAuth/SSO provider is configured and your account's email matches your identity there, signing in with that provider gets you back in without a password — at the cost of handing the account over to the provider permanently, after which your password no longer works. With no other admin and no provider configured, there is no way back in through the app.
## OAuth / SSO
@@ -29,12 +46,14 @@ OAuth providers are **auto-enabled** when their environment variables are set
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
1. Go to **Settings > Users > Invite User**
1. Go to **Settings > Users** and fill in the **Create User** form
2. Create the user with the **same email** they use on the OAuth provider
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
Tick **OAuth-only (no password)** when creating these accounts — the provider is how they will sign in anyway. If you set a password instead, treat it as temporary: the first OAuth sign-in hands the account over to the provider, after which the password no longer works at the login form and the admin password reset is unavailable for that account.
### Environment variables
#### Google OAuth
@@ -130,7 +149,7 @@ services:
**4. Pre-create users:**
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
In Crumbs, go to **Settings > Users** and create accounts with emails matching your Authentik users, ticking **OAuth-only (no password)**. They can then log in via the "Authentik" button on the login page.
#### Keycloak
+5 -2
View File
@@ -142,8 +142,11 @@
### User Management (admin)
- Admin dashboard to create, list, and delete users
- Assign roles (admin / user)
- Reset passwords for any user
- Revoke all sessions (force logout) for any user
- Reset another user's password — Crumbs generates a 20-character password and shows it once in that user's row, with a copy button; it cannot be shown again
- Only for accounts that sign in with a password — OAuth/SSO accounts show why the reset is unavailable
- Your own password is changed from Settings > Profile, not here
- A reset does not sign the user out — revoke their sessions separately to force a logout
- Revoke all sessions (force logout) for any user other than yourself
### PWA / Offline-First
- Installable as standalone app
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
# Note Linking — Design Spec
**Issue**: [#74 — feat: link to other crumbs (notes) inside the app](https://github.com/bretzel-app/crumbs/issues/74)
**Date**: 2026-08-03
## Overview
Let a note reference another note by ID, so clicking it opens the target note in the editor. Reuses the existing "insert link" toolbar entry point (Ctrl+K) rather than introducing a new `[[wikilink]]`-style typing syntax. The displayed link text always tracks the target note's current title — it's a live reference, not frozen text — and the target note gets a "Referenced by" backlinks section.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Link creation UX | Extend the existing link toolbar dropdown with a note-search mode | Matches the issue reporter's own proposed solution; reuses the Link dropdown, `/api/search`, and toolbar infra as-is instead of building a new inline autocomplete |
| Click behavior | Opens the target note in the editor overlay, same as clicking a note card | Matches how every other note-opening interaction already works in the app |
| Link label | Always the target note's live title | A stale link label after a rename would be confusing; this requires modeling the link as an atomic node rather than a mark (see Architecture) |
| Broken/inaccessible links | Grey out, inert, content untouched | Nothing is silently rewritten; if the note is restored from trash the link works again automatically |
| Backlinks | Included in v1 | Requested explicitly; reuses the same extract-and-sync pattern tags already use, so the incremental cost is a reverse query, not new infrastructure |
| Public share page | Note-links never resolve, even if the target is also shared | Simplest, most conservative option — zero chance of leaking the existence of a private note to an anonymous visitor |
| Link picker scope | Owned notes + notes shared with you | Same visibility scope the main notes list already uses; `searchNotes()` already implements this |
| Removing an existing link | Hover-reveal a small × on the chip | Click already navigates instantly, so it can't also open an edit/remove popover the way external links do; a hover-revealed remove button solves this without touching the click behavior. Repointing a link is just remove + re-insert — no separate "edit target" UI |
## Architecture
**Data model** — a new self-referential join table, mirroring `noteTags`/`tags`:
```typescript
export const noteLinks = sqliteTable(
'note_links',
{
sourceNoteId: text('source_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
targetNoteId: text('target_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull()
},
(table) => [
index('note_links_source_idx').on(table.sourceNoteId),
index('note_links_target_idx').on(table.targetNoteId)
]
);
```
**Markdown representation** — a note-link round-trips through raw markdown as a normal-looking link with a custom URI scheme:
```
[Grocery List](crumb-note://<note-id>)
```
This keeps the existing raw-markdown-mode toggle showing something readable, and keeps exported/copied content sensible outside the app. The title baked into the markdown text is only ever a fallback for external tools — inside the app, parsing recognizes the `crumb-note://` scheme and rebuilds the live node, discarding whatever static text was there.
**Editor node** — a new tiptap **node** (not a mark) named `NoteLink`, with a single attribute `{ noteId: string }`. Unlike a mark, a node fully owns its own rendering, which is what makes "always show the live title" possible — a mark decorates existing typed text and has no clean way to swap that text later.
## Components
### Client
| Component | Change |
|---|---|
| `src/lib/components/tiptap/NoteLink.ts` (new) | Inline atomic tiptap node extension. `NodeView` looks up the target's title via `getNote(id)` from `src/lib/sync/idb.ts` — **not** the reactive `notes` writable store, which only holds whatever the current filter (all/archived/trashed) last loaded and would wrongly grey out a valid note just because it's outside the current view. IndexedDB is kept complete for all accessible notes by the existing `/api/sync` incremental sync, independent of which filter is on screen, so it's the correct source of truth here — and it works offline too. Renders a chip (note icon + title), greys out and disables click if the note isn't found. Click opens the note editor overlay for that ID. A small × button is layered on the chip using the app's existing hover-action pattern (`max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100` — always visible on touch, hover-revealed on desktop) that deletes the node from the document without navigating; there's no separate "change target" action, since deleting and re-inserting is simple enough that a dedicated repoint UI isn't worth building. |
| `src/lib/components/TiptapEditor.svelte` | Register the `NoteLink` node extension alongside the existing ones. |
| `src/lib/components/FormattingToolbar.svelte` | The existing link dropdown gets a note-search results list below the URL input, live-filtered by whatever's typed there (debounced, via `/api/search`, excluding the currently-open note) — no heuristic needed to guess whether the typed text is a URL or a note search; both affordances are just available side by side. Applying the URL input still applies the normal external `Link` mark; picking a search result instead inserts a `NoteLink` node at the cursor (replacing any selection). |
| `src/lib/components/NoteEditor.svelte` | New "Referenced by" section, shown when `note.backlinks` is non-empty — a small chip list near where tags render, each chip opens that referencing note. |
### Server
| Function | Location | Description |
|---|---|---|
| `extractNoteLinks(content): string[]` | `src/lib/utils/note-links.ts` (new) | Regex-extracts `crumb-note://<id>` references from markdown content, same code-block/URL exclusions `extractTags` already applies. |
| `syncNoteLinks(db, noteId, targetIds, userId)` | `src/lib/server/note-links.ts` (new) | Mirrors `syncNoteTags`: clears existing rows for the note, re-inserts the current set, within the same save transaction. |
| `fetchBacklinksForNote(db, noteId, userId)` | `src/lib/server/note-links.ts` (new) | Reverse query on `noteLinks.targetNoteId`, joined through the same access rules as everything else (owner or collaborator on the *source* note), returning `{id, title}` pairs. |
`getNote()` in `notes-service.ts` calls `fetchBacklinksForNote` and attaches the result as `backlinks`. This is **not** added to `hydrateNotes()` (which also backs list views) — computing backlinks for every note in a list load would be wasted work for something only the open editor displays.
## Data Flow
1. **Create** — pick a note in the toolbar's link dropdown → a `NoteLink` node is inserted at the cursor.
2. **Save** — content round-trips to markdown as usual; the note-save transaction additionally runs `extractNoteLinks` + `syncNoteLinks`, alongside the existing tag sync.
3. **Reopen** — markdown parses back into the doc; a custom parse rule recognizes `crumb-note://` and produces a `NoteLink` node; its `NodeView` looks up the live title via IndexedDB (`getNote(id)`), not the filter-scoped reactive store (see Components).
4. **Click** — opens the target note in the editor overlay.
5. **Rename** — no action needed anywhere: the title is looked up live at render time, so the next render already shows the new title.
6. **Trash / permanent delete / no access** — all three are treated identically: the note simply isn't found in IndexedDB (or, server-side, fails the backlink query's access check), so the link renders inert. The referencing note's content is never rewritten.
7. **Public share page** — doesn't use the tiptap editor; content is rendered server-side via `markdown-it`. That render path strips `crumb-note://` links down to plain, non-clickable text, regardless of whether the target is also shared.
## Error Handling
- **Malformed `crumb-note://` reference** during markdown parse falls back to plain text rather than crashing the parse.
- **Search race conditions** in the toolbar's note-picker are debounced and guarded against stale results overwriting newer ones, same pattern the existing search feature (`tests/e2e/search.spec.ts`) already covers.
- **Self-links** — the currently-open note is filtered out of its own picker results.
- **Circular references** (A ↔ B) are a non-issue — clicking only ever opens one note at a time, nothing recurses.
- **Cross-user link privacy** — the picker only offers notes you can already access, so under normal use a link can only point at something you had legitimate access to when you created it. Hand-editing raw markdown with a guessed note ID is the only way around that, and note IDs are unguessable UUIDs — the same "if you have the ID, you were meant to have it" trust model the app already applies to session/share tokens. Accepted as a low-severity edge case rather than adding extra access-control plumbing for it.
## Testing
**Unit (Vitest)**
- `extractNoteLinks` — extracts IDs, ignores code blocks/URLs, dedupes, handles empty content
- `syncNoteLinks` — clears + re-inserts correctly
- `fetchBacklinksForNote` — returns only notes the requesting user can access
- Markdown round-trip — a `NoteLink` node serializes to `[title](crumb-note://id)` and parses back to the same node with the right `noteId`
**E2E (Playwright)**
- Linking to a note from the toolbar renders a live chip; clicking it opens that note
- Renaming the linked note updates the displayed link text next time it's viewed
- A link to a trashed/deleted/inaccessible note renders inert and doesn't navigate
- The linked-to note shows a "Referenced by" backlink to the note that links to it
- A public share page never renders a note-link as clickable, even if the target is also shared
- The note-picker excludes the currently-open note from its own search results
- Hovering a note-link chip reveals a × button that removes it from the document without navigating; the × is always visible (not hover-gated) on touch devices
## Out of Scope (v1)
- Inline `[[wikilink]]`/`@mention` autocomplete while typing (toolbar-only for now; can be added later without touching the data model)
- Note graph / visual map view
- Ranking or sorting backlinks beyond simple list order
- Cross-user access-control tightening for hand-crafted `crumb-note://` references (see Error Handling)
## Acceptance Criteria
- [ ] Link toolbar dropdown supports searching and picking another note, in addition to pasting a URL
- [ ] Picking a note inserts a live `NoteLink` node showing the target's current title
- [ ] Clicking a note-link opens the target note in the editor overlay
- [ ] Renaming a note updates its link's displayed text everywhere, without editing referencing notes' content
- [ ] Trashed, deleted, or inaccessible link targets render greyed-out and inert
- [ ] Note editor shows a "Referenced by" section listing notes that link to it
- [ ] Public share page never renders a note-link as clickable
- [ ] Note-links round-trip correctly through raw markdown mode as `[title](crumb-note://id)`
- [ ] The link picker excludes the current note and includes both owned and shared-with-you notes
- [ ] Hovering a note-link chip reveals a × that removes it without navigating; always visible on touch
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE `note_links` (
`source_note_id` text NOT NULL,
`target_note_id` text NOT NULL,
FOREIGN KEY (`source_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`target_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_links_source_idx` ON `note_links` (`source_note_id`);--> statement-breakpoint
CREATE INDEX `note_links_target_idx` ON `note_links` (`target_note_id`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1774037379518,
"tag": "0003_uneven_tomorrow_man",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1785824271578,
"tag": "0004_violet_nicolaos",
"breakpoints": true
}
]
}
+41 -41
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.31.0",
"version": "0.33.0",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
@@ -34,64 +34,64 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-table-cell": "^3.20.1",
"@tiptap/extension-table-header": "^3.20.1",
"@tiptap/extension-table-row": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@sveltejs/adapter-node": "^5.5.7",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-link": "^3.29.2",
"@tiptap/extension-placeholder": "^3.29.2",
"@tiptap/extension-table": "^3.29.2",
"@tiptap/extension-table-cell": "^3.29.2",
"@tiptap/extension-table-header": "^3.29.2",
"@tiptap/extension-table-row": "^3.29.2",
"@tiptap/extension-task-item": "^3.29.2",
"@tiptap/extension-task-list": "^3.29.2",
"@tiptap/extension-text-align": "^3.29.2",
"@tiptap/extension-underline": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.45.2",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
"nanoid": "^5.1.7",
"node-diff3": "^3.2.0",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
"markdown-it": "^14.3.0",
"nanoid": "^5.1.16",
"node-diff3": "^3.2.1",
"nodemailer": "^9.0.3",
"svelte-dnd-action": "^0.9.77",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0",
"zod": "^4.3.6",
"uuid": "^11.1.1",
"zod": "^4.4.3",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.63.1",
"@playwright/test": "^1.62.1",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.11",
"@types/nodemailer": "^8.0.1",
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.6",
"drizzle-kit": "^0.31.9",
"jsdom": "^29.0.1",
"@vitest/coverage-v8": "^3.2.7",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"nyc": "^18.0.0",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"svelte": "^5.56.8",
"svelte-check": "^4.7.4",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.5",
"typescript": "^5.9.3",
"vite": "^7.3.5",
"vite": "^7.3.6",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.6",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
"vite-plugin-pwa": "^1.3.0",
"vitest": "^3.2.7",
"workbox-window": "^7.4.1",
"yaml": "^2.9.0"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -102,7 +102,7 @@
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"cookie": ">=0.7.0 <1",
"hono": ">=4.12.7",
"devalue": ">=5.8.1",
"path-to-regexp@8": ">=8.4.0",
+1372 -1936
View File
File diff suppressed because it is too large Load Diff
+61 -5
View File
@@ -33,15 +33,19 @@
interface Props {
tick?: number;
editor: Editor | undefined;
currentNoteId?: string | null;
}
let { editor, tick }: Props = $props();
let { editor, tick, currentNoteId = null }: Props = $props();
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
function canRedo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().redo().run()) ?? false; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
let openDropdown: string | null = $state(null);
let dropdownAnchorEl: HTMLElement | null = $state(null);
@@ -49,6 +53,40 @@
let linkInput: HTMLInputElement | undefined = $state();
let ignorePositionInvalidationUntil = 0;
let noteSearchResults: { id: string; title: string }[] = $state([]);
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
async function searchNotesForLink(query: string) {
const requestId = ++noteSearchRequestId;
if (!query.trim()) {
noteSearchResults = [];
return;
}
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (requestId !== noteSearchRequestId) return;
if (!res.ok) return;
const results: { id: string; title: string }[] = await res.json();
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
} catch {
// Search failed silently
}
}
let noteSearchRequestId = 0;
function handleLinkInput() {
clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
}
function insertNoteLink(noteId: string) {
if (!editor) return;
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
noteSearchResults = [];
closeDropdowns();
}
const dropdownGap = 4;
const viewportMargin = 10;
@@ -81,6 +119,8 @@
function closeDropdowns() {
openDropdown = null;
dropdownAnchorEl = null;
clearTimeout(noteSearchTimer);
noteSearchResults = [];
}
function closeDropdownsAfterPositionChange() {
@@ -434,6 +474,7 @@
<input
bind:this={linkInput}
bind:value={linkUrl}
oninput={handleLinkInput}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
@@ -472,6 +513,21 @@
<Trash2 size={16} />
</button>
</form>
{#if noteSearchResults.length > 0}
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
{#each noteSearchResults as result (result.id)}
<button
type="button"
onmousedown={preventToolbarMouseFocus}
onclick={() => insertNoteLink(result.id)}
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="format-link-note-result"
>
{result.title || 'Untitled'}
</button>
{/each}
</div>
{/if}
</div>
{:else if openDropdown === 'table'}
<div
+70 -5
View File
@@ -3,7 +3,7 @@
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { pushState } from '$app/navigation';
import { page } from '$app/state';
import ColorPicker from './ColorPicker.svelte';
@@ -19,7 +19,7 @@
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import type { Note, NoteColor, Attachment, Collaborator, NoteBacklink } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
@@ -43,9 +43,10 @@
isNew?: boolean;
initialChecklistMode?: boolean;
onClose: () => void;
onOpenNote: (noteId: string) => void;
}
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
@@ -164,8 +165,22 @@
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
});
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
// component (rather than reusing the instance) whenever the user switches to a
// different note while the editor is open. That teardown skips the debounced
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
// could otherwise be silently dropped.
onDestroy(() => {
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
performSave();
}
});
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// svelte-ignore state_referenced_locally
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
let viewportHeight = $state('100dvh');
let viewportTop = $state('0px');
@@ -325,15 +340,45 @@
}
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB).
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
// component on every note-switch (rather than reusing the instance), a fetch that
// resolves after teardown would otherwise write to $state on an already-destroyed
// instance racing the newly-mounted one.
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
let destroyed = false;
fetch(`/api/notes/${noteId}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (destroyed) return;
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
return () => {
destroyed = true;
};
}
});
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
// note.backlinks is only ever attached server-side by getNote(), which nothing
// else in the client calls when opening a note (the bulk notes store and
// IndexedDB never carry it), so this mirrors the attachments effect above to
// fill it in on open. Same post-teardown guard as above.
$effect(() => {
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
let destroyed = false;
fetch(`/api/notes/${noteId}`)
.then((res) => res.ok ? res.json() : null)
.then((data: Note | null) => {
if (destroyed) return;
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
})
.catch(() => {});
return () => {
destroyed = true;
};
}
});
@@ -597,6 +642,7 @@
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
{onOpenNote}
placeholder="Add a crumb..."
/>
{/if}
@@ -616,10 +662,29 @@
</div>
{/if}
<!-- Backlinks -->
{#if backlinksList.length > 0}
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
<div class="flex flex-wrap gap-1.5">
{#each backlinksList as backlink (backlink.id)}
<button
type="button"
onclick={() => onOpenNote(backlink.id)}
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
data-testid="backlink-chip"
>
{backlink.title || 'Untitled'}
</button>
{/each}
</div>
</div>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
</div>
{/if}
+25 -2
View File
@@ -9,6 +9,7 @@
import Plus from 'lucide-svelte/icons/plus';
import SquareCheck from 'lucide-svelte/icons/square-check';
import { tooltip } from '$lib/utils/tooltip.js';
import { getNote as getIdbNote } from '$lib/sync/idb.js';
interface Props {
filter: NoteFilter;
@@ -43,6 +44,20 @@
replaceState(location.pathname, {});
}
// Reads from the local IndexedDB cache rather than fetching over the network —
// this keeps note-link navigation instant and offline-safe (matching how the
// rest of the app opens notes), and backlinks are filled in progressively by
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
// the same pattern already used there for attachments.
let openNoteRequestId = 0;
async function openNoteById(noteId: string) {
const requestId = ++openNoteRequestId;
const note = await getIdbNote(noteId);
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
if (note) openEditor(note);
}
function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders);
@@ -137,9 +152,17 @@
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
while the editor is already open) instead of reusing the same instance
with a new `note` prop — a persisting instance's own back-navigation
effect misreads `openEditor`'s replaceState call as "browser back was
pressed" and closes the editor, and its once-only local state never
resyncs to the new note otherwise. -->
{#key editingNote.id}
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
{/key}
{/if}
+61 -33
View File
@@ -13,20 +13,25 @@
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
import { NoteLink } from './tiptap/NoteLink.js';
import { getAllNotes } from '$lib/sync/idb.js';
interface Props {
content: string;
onUpdate: (markdown: string) => void;
onEditor?: (editor: Editor) => void;
onTransaction?: () => void;
onOpenNote?: (noteId: string) => void;
placeholder?: string;
}
let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props();
let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props();
let element: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
const titleIndexRef = { index: new Map<string, string>() };
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
if (target instanceof Element) {
const checkbox = target.matches('input[type="checkbox"]')
@@ -76,43 +81,66 @@
}
onMount(() => {
editor = new Editor({
element: element!,
extensions: [
StarterKit,
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
let destroyed = false;
let editorInstance: Editor | undefined;
(async () => {
const allNotes = await getAllNotes();
if (destroyed) return;
titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
editorInstance = new Editor({
element: element!,
extensions: [
StarterKit.configure({ link: false, underline: false }),
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Markdown
],
content,
onUpdate: ({ editor: e }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
},
onTransaction: () => {
// Trigger Svelte reactivity for active state checks
editor = editor;
onTransaction?.();
TaskItem.configure({ nested: true }),
NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
Markdown
],
content,
onUpdate: ({ editor: e }) => {
if (destroyed) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
},
onTransaction: () => {
// Removing this component's DOM node (e.g. a {#key}-driven remount
// in NotesView.svelte when switching to a different note) fires a
// native blur on the still-focused contenteditable, which
// ProseMirror's blur plugin turns into one last transaction —
// synchronously, from inside Svelte's own render/effect pass that's
// doing the removal. Mutating $state right there crashes with
// state_unsafe_mutation; the `destroyed` check alone isn't enough
// since this can fire before onMount's own cleanup runs. Defer to a
// microtask so the write lands after Svelte's current pass finishes.
queueMicrotask(() => {
if (destroyed) return;
editor = editorInstance;
onTransaction?.();
});
}
});
editor = editorInstance;
if (element && !destroyed) {
(element as any).__tiptapEditor = editorInstance;
}
});
// Expose editor on DOM element for e2e testing
if (element) {
(element as any).__tiptapEditor = editor;
}
onEditor?.(editor);
onEditor?.(editorInstance);
})();
return () => {
editor?.destroy();
destroyed = true;
editorInstance?.destroy();
};
});
</script>
+101
View File
@@ -0,0 +1,101 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
export interface NoteLinkOptions {
titleIndexRef: { index: Map<string, string> };
onOpenNote: (noteId: string) => void;
}
export const NoteLink = Node.create<NoteLinkOptions>({
name: 'noteLink',
group: 'inline',
inline: true,
atom: true,
addOptions() {
return {
titleIndexRef: { index: new Map<string, string>() },
onOpenNote: () => {}
};
},
addAttributes() {
return {
noteId: {
default: null,
parseHTML: (element) => element.getAttribute('data-note-id'),
renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId })
}
};
},
parseHTML() {
return [
{
tag: 'a[href^="crumb-note://"]',
priority: 100,
getAttrs: (element) => {
const href = (element as HTMLElement).getAttribute('href') ?? '';
const noteId = parseNoteLinkHref(href);
return noteId ? { noteId } : false;
}
}
];
},
renderHTML({ HTMLAttributes }) {
return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })];
},
addStorage() {
return {
markdown: {
serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => {
state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index));
}
}
};
},
addNodeView() {
return ({ node, editor, getPos }) => {
const noteId = node.attrs.noteId as string;
const title = this.options.titleIndexRef.index.get(noteId);
const dom = document.createElement('span');
dom.setAttribute('data-note-id', noteId);
dom.className =
'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm';
const label = document.createElement('span');
if (title !== undefined) {
label.textContent = title;
label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]';
label.addEventListener('click', (event) => {
event.preventDefault();
this.options.onOpenNote(noteId);
});
} else {
label.textContent = 'Note not found';
label.className = 'text-[var(--text-muted)] opacity-60';
}
dom.appendChild(label);
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = '×';
removeButton.setAttribute('aria-label', 'Remove note link');
removeButton.className =
'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]';
removeButton.addEventListener('click', (event) => {
event.preventDefault();
const pos = getPos();
if (pos === undefined) return;
editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
});
dom.appendChild(removeButton);
return { dom };
};
}
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
describe('parseNoteLinkHref', () => {
it('should extract the note ID from a crumb-note:// href', () => {
expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123');
});
it('should return null for a non-crumb-note href', () => {
expect(parseNoteLinkHref('https://example.com')).toBeNull();
});
it('should return null for an empty string', () => {
expect(parseNoteLinkHref('')).toBeNull();
});
});
describe('serializeNoteLinkMarkdown', () => {
it('should produce a markdown link with the title from the index', () => {
const titleIndex = new Map([['abc-123', 'Grocery List']]);
expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)');
});
it('should fall back to Untitled when the ID is not in the index', () => {
const titleIndex = new Map<string, string>();
expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)');
});
it('should escape markdown-special characters in the title', () => {
const titleIndex = new Map([['id-1', 'Buy [milk] today']]);
expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)');
});
});
@@ -0,0 +1,22 @@
const NOTE_LINK_SCHEME = 'crumb-note://';
/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */
export function parseNoteLinkHref(href: string): string | null {
if (!href.startsWith(NOTE_LINK_SCHEME)) return null;
return href.slice(NOTE_LINK_SCHEME.length) || null;
}
/**
* Escapes markdown-special characters the same way a plain link's visible
* text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt
* the surrounding markdown syntax.
*/
function escapeMarkdownText(text: string): string {
return text.replace(/[[\]\\*_`]/g, '\\$&');
}
/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */
export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map<string, string>): string {
const title = titleIndex.get(noteId) ?? 'Untitled';
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
}
+7
View File
@@ -226,6 +226,13 @@ sqlite.exec(`
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS note_links_source_idx ON note_links(source_note_id);
CREATE INDEX IF NOT EXISTS note_links_target_idx ON note_links(target_note_id);
`);
// Replace old tags unique index with compound index including user_id
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm';
import { createTestDb } from './test-helpers.js';
import { notes, noteLinks, users } from './schema.js';
describe('noteLinks schema', () => {
it('should store a source/target link row', () => {
const { db } = createTestDb();
db.insert(users)
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(notes)
.values([
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
])
.run();
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
const rows = db.select().from(noteLinks).all();
expect(rows).toEqual([{ sourceNoteId: 'source-note', targetNoteId: 'target-note' }]);
});
it('should cascade-delete link rows when the source note is deleted', () => {
const { db, sqlite } = createTestDb();
sqlite.pragma('foreign_keys = ON');
db.insert(users)
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(notes)
.values([
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
])
.run();
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
db.delete(notes).where(eq(notes.id, 'source-note')).run();
expect(db.select().from(noteLinks).all()).toEqual([]);
});
});
+16
View File
@@ -80,6 +80,22 @@ export const noteTags = sqliteTable(
]
);
export const noteLinks = sqliteTable(
'note_links',
{
sourceNoteId: text('source_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
targetNoteId: text('target_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull()
},
(table) => [
index('note_links_source_idx').on(table.sourceNoteId),
index('note_links_target_idx').on(table.targetNoteId)
]
);
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
userId: integer('user_id')
+7
View File
@@ -68,6 +68,13 @@ export function createTestDb(options?: { seedUser?: boolean }) {
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX note_links_source_idx ON note_links(source_note_id);
CREATE INDEX note_links_target_idx ON note_links(target_note_id);
CREATE TABLE note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
+155 -2
View File
@@ -1,5 +1,8 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { lookup } from 'node:dns/promises';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { NoteFilter } from '$lib/types/index.js';
import {
listNotes,
@@ -29,6 +32,134 @@ const NOTE_COLORS = [
'chalk'
] as const;
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
const MAX_IMAGE_REDIRECTS = 5;
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
// upload_image can't be used to reach internal network services.
function isPrivateOrReservedIp(address: string): boolean {
if (address.includes(':')) {
const normalized = address.toLowerCase();
if (normalized === '::' || normalized === '::1') return true;
if (normalized.startsWith('fe80:')) return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
return false;
}
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
const [a, b] = parts;
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
return false;
}
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
// (not just the first), so a validated hostname can't have a private record hiding
// behind a public one in the same DNS answer.
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
const records = await lookup(hostname, { all: true });
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
for (const record of records) {
if (isPrivateOrReservedIp(record.address)) {
throw new Error('URL resolves to a private or reserved address');
}
}
return records[0];
}
interface PinnedResponse {
ok: boolean;
status: number;
statusText: string;
headers: { get(name: string): string | null };
arrayBuffer(): Promise<ArrayBuffer>;
}
// Issues the request against the exact address validated by resolveValidatedAddress,
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
// could serve a public address to the validator and a private one to the real connect.
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
return new Promise((resolve, reject) => {
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
const req = requestFn(
{
hostname: url.hostname,
servername: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
timeout: 15000,
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
callback(null, address, family);
}
},
(res) => {
const chunks: Buffer[] = [];
let total = 0;
res.on('data', (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_IMAGE_BYTES) {
req.destroy(new Error('Image too large (max 10MB)'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const buffer = Buffer.concat(chunks);
const status = res.statusCode ?? 0;
resolve({
ok: status >= 200 && status < 300,
status,
statusText: res.statusMessage ?? '',
headers: {
get: (name: string) => {
const value = res.headers[name.toLowerCase()];
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
}
},
arrayBuffer: async () =>
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
});
});
res.on('error', reject);
}
);
req.on('error', reject);
req.end();
});
}
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
let currentUrl = new URL(imageUrl);
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
throw new Error('Only http/https URLs are allowed');
}
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
const response = await requestPinnedAddress(currentUrl, address, family);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) return response;
currentUrl = new URL(location, currentUrl);
continue;
}
return response;
}
throw new Error('Too many redirects');
}
export function createMcpServer(userId: number): McpServer {
const server = new McpServer({
name: 'crumbs',
@@ -302,7 +433,7 @@ export function createMcpServer(userId: number): McpServer {
}
try {
const response = await fetch(imageUrl);
const response = await fetchPublicImage(imageUrl);
if (!response.ok) {
return {
content: [
@@ -315,8 +446,30 @@ export function createMcpServer(userId: number): McpServer {
};
}
const contentType = response.headers.get('content-type') || 'image/png';
const contentType = response.headers.get('content-type') || '';
if (!contentType.startsWith('image/')) {
return {
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
isError: true
};
}
const contentLength = Number(response.headers.get('content-length'));
if (contentLength > MAX_IMAGE_BYTES) {
return {
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
isError: true
};
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_BYTES) {
return {
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
isError: true
};
}
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
const filename = `upload_${Date.now()}.${ext}`;
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteLinks, noteCollaborators, users } from './db/schema.js';
import type { Db } from './db/index.js';
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
let db: Db;
const OWNER_ID = 1;
const OTHER_USER_ID = 2;
function seedUser(id: number, email: string) {
db.insert(users)
.values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
}
function seedNote(id: string, userId: number, title = '') {
db.insert(notes)
.values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUser(OWNER_ID, 'owner@test.com');
seedUser(OTHER_USER_ID, 'other@test.com');
});
describe('syncNoteLinks', () => {
it('should create link rows for each target', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
seedNote('target-2', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1', 'target-2']);
const rows = db.select().from(noteLinks).all();
expect(rows).toHaveLength(2);
});
it('should remove old links and add new ones on re-sync', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
seedNote('target-2', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1']);
syncNoteLinks(db, 'source', ['target-2']);
const rows = db.select().from(noteLinks).all();
expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]);
});
it('should handle an empty target list (removes all links)', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1']);
syncNoteLinks(db, 'source', []);
expect(db.select().from(noteLinks).all()).toEqual([]);
});
});
describe('fetchBacklinksForNote', () => {
it('should return notes that link to the given note', () => {
seedNote('source', OWNER_ID, 'Source Note');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID);
expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]);
});
it('should return an empty array when nothing links to the note', () => {
seedNote('target', OWNER_ID, 'Target Note');
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
});
it('should exclude a backlink from a source note the requesting user cannot access', () => {
seedNote('source', OTHER_USER_ID, 'Other Users Note');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
});
it('should include a backlink from a source note shared with the requesting user', () => {
seedNote('source', OTHER_USER_ID, 'Shared Source');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
db.insert(noteCollaborators)
.values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() })
.run();
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Db } from './db/index.js';
import { noteLinks, notes } from './db/schema.js';
import { eq, inArray } from 'drizzle-orm';
import { canAccessNote } from './api-utils.js';
/**
* Sync link rows for a note: removes old associations, inserts the current set.
* Mirrors syncNoteTags — called right after a note's content is saved.
*/
export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void {
db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run();
for (const targetId of targetIds) {
db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run();
}
}
/**
* Find notes that link to the given note, scoped to what the requesting user
* can access — this both respects normal sharing boundaries and, as a side
* effect, prevents an unrelated note from appearing in someone's backlinks
* just because it happens to reference a note ID it was never shared to see.
*/
export function fetchBacklinksForNote(
db: Db,
noteId: string,
userId: number
): { id: string; title: string }[] {
const linkRows = db
.select({ sourceNoteId: noteLinks.sourceNoteId })
.from(noteLinks)
.where(eq(noteLinks.targetNoteId, noteId))
.all();
if (linkRows.length === 0) return [];
const sourceIds = linkRows.map((r) => r.sourceNoteId);
const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess);
if (accessibleIds.length === 0) return [];
const sourceNotes = db
.select({ id: notes.id, title: notes.title })
.from(notes)
.where(inArray(notes.id, accessibleIds))
.all();
return sourceNotes;
}
+42
View File
@@ -374,3 +374,45 @@ describe('reorderNotes', () => {
expect(collabView!.sortOrder).toBe(5);
});
});
describe('note links', () => {
it('should sync note-links when a note is created with a reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, {
title: 'Source',
content: `See [Target](crumb-note://${target.id})`
});
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
});
it('should sync note-links when a note is updated with a reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, { title: 'Source', content: '' });
updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` });
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
});
it('should remove a backlink when the referencing note is edited to drop the reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, {
title: 'Source',
content: `See [Target](crumb-note://${target.id})`
});
updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' });
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([]);
});
it('should return an empty backlinks array for a note nothing links to', () => {
const note = createNote(db, OWNER_ID, { title: 'Lonely note' });
const reopened = getNote(db, OWNER_ID, note.id);
expect(reopened?.backlinks).toEqual([]);
});
});
+11 -1
View File
@@ -3,7 +3,9 @@ import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/sc
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { extractNoteLinks } from '$lib/utils/note-links.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { fetchSharesForNotes } from './shares-service.js';
@@ -155,7 +157,10 @@ export function getNote(db: Db, userId: number, id: string) {
}
const result = hydrateNotes(db, [effectiveNote], userId);
return result[0] ?? null;
const hydrated = result[0];
if (!hydrated) return null;
return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) };
}
export interface CreateNoteInput {
@@ -197,6 +202,9 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
});
syncNoteTags(db, id, extractedTags, userId);
const extractedLinks = extractNoteLinks(content);
syncNoteLinks(db, id, extractedLinks);
return { ...newNote, tags: extractedTags };
}
@@ -326,6 +334,8 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
const content = `${updated.title} ${updated.content}`;
const extractedTags = extractTags(content);
syncNoteTags(db, id, extractedTags, existing.userId);
const extractedLinks = extractNoteLinks(content);
syncNoteLinks(db, id, extractedLinks);
}
}
+6
View File
@@ -18,6 +18,12 @@ export interface Note {
isOwner?: boolean;
isShared?: boolean;
shareToken?: string;
backlinks?: NoteBacklink[];
}
export interface NoteBacklink {
id: string;
title: string;
}
export interface Collaborator {
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { canResetPassword, passwordResetBlockedReason } from './auth-methods.js';
const NON_PASSWORD_PROVIDERS = ['none', 'google', 'github', 'oidc'];
describe('canResetPassword', () => {
it('allows a reset for a password account', () => {
expect(canResetPassword({ authProvider: 'password' })).toBe(true);
});
it('blocks a reset for every non-password provider', () => {
// verifyPassword() filters on authProvider === 'password', so a hash
// written for any other provider could never be used to sign in.
for (const authProvider of NON_PASSWORD_PROVIDERS) {
expect(canResetPassword({ authProvider }), authProvider).toBe(false);
}
});
it('blocks a reset for an unenumerated provider', () => {
expect(canResetPassword({ authProvider: 'saml' })).toBe(false);
expect(canResetPassword({ authProvider: '' })).toBe(false);
});
});
describe('passwordResetBlockedReason', () => {
it('returns null for a password account', () => {
expect(passwordResetBlockedReason({ authProvider: 'password' })).toBeNull();
});
it('names the provider in the reason', () => {
expect(passwordResetBlockedReason({ authProvider: 'none' })).toBe(
'This account has no password — it signs in via OAuth.'
);
expect(passwordResetBlockedReason({ authProvider: 'google' })).toBe(
'Managed by Google — password login is disabled for this account.'
);
expect(passwordResetBlockedReason({ authProvider: 'github' })).toBe(
'Managed by GitHub — password login is disabled for this account.'
);
expect(passwordResetBlockedReason({ authProvider: 'oidc' })).toBe(
'Managed by SSO — password login is disabled for this account.'
);
});
it('falls back to a generic reason for an unenumerated provider', () => {
expect(passwordResetBlockedReason({ authProvider: 'saml' })).toBe(
'Password login is not available for this account.'
);
});
it('always explains a blocked reset', () => {
// authProvider is typed `string`, so no missing branch can be caught by
// the compiler. A null reason here would render a disabled control with
// no explanation — the confusion issue #77 reported.
//
// The Object.prototype keys are the interesting cases: with a plain object
// literal as the lookup table they resolve to inherited members, so a
// nullish fallback never fires and the function returns a function or an
// object in place of its declared string. Asserting typeof rather than
// truthiness is what catches that.
const providers = [
...NON_PASSWORD_PROVIDERS,
'saml',
'',
'PASSWORD',
'ldap',
'unknown',
'__proto__',
'toString',
'constructor',
'hasOwnProperty',
'valueOf'
];
for (const authProvider of providers) {
const user = { authProvider };
expect(canResetPassword(user), authProvider).toBe(false);
const reason = passwordResetBlockedReason(user);
expect(typeof reason, authProvider).toBe('string');
expect(reason, authProvider).not.toBe('');
}
});
});
describe('auth-methods module', () => {
it('does not use Math.random', () => {
// The reset-eligibility rule must be pure and deterministic.
const source = readFileSync(new URL('./auth-methods.ts', import.meta.url), 'utf8');
expect(source).not.toContain('Math.random');
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { User } from '$lib/types/index.js';
/**
* Human-readable reason a password reset is unavailable, keyed by auth provider.
* Anything absent from this map falls back to BLOCKED_REASON_FALLBACK, so the
* blocked path can never be left unexplained.
*
* A Map, not an object literal: an object inherits Object.prototype, so a lookup
* for 'toString' or '__proto__' would resolve to an inherited member instead of
* missing, and the fallback below would never fire. A Map has no such keys, so
* totality holds by construction rather than by a guard that could be dropped.
*/
const BLOCKED_REASONS = new Map<string, string>([
['none', 'This account has no password — it signs in via OAuth.'],
['google', 'Managed by Google — password login is disabled for this account.'],
['github', 'Managed by GitHub — password login is disabled for this account.'],
['oidc', 'Managed by SSO — password login is disabled for this account.']
]);
const BLOCKED_REASON_FALLBACK = 'Password login is not available for this account.';
/**
* Whether an admin may set a new password for this account.
*
* Password login is gated on `authProvider` — verifyPassword() only matches rows
* where it equals 'password' — so writing a hash for any other provider stores a
* credential that can never be used to sign in.
*/
export function canResetPassword(user: Pick<User, 'authProvider'>): boolean {
return user.authProvider === 'password';
}
/**
* Why a password reset is blocked, or null when it is allowed.
*
* `authProvider` is typed `string`, so the compiler cannot enforce exhaustiveness
* here. The fallback guarantees the invariant this module exists for: whenever
* `canResetPassword` is false, this returns a non-empty explanation, so the UI
* never shows a disabled control without saying why.
*/
export function passwordResetBlockedReason(user: Pick<User, 'authProvider'>): string | null {
if (canResetPassword(user)) return null;
return BLOCKED_REASONS.get(user.authProvider) ?? BLOCKED_REASON_FALLBACK;
}
+13
View File
@@ -85,3 +85,16 @@ describe('stripMarkdown', () => {
expect(stripMarkdown('')).toBe('');
});
});
describe('renderMarkdown note-link handling', () => {
it('should render a crumb-note:// link as plain text, not a clickable anchor', () => {
const html = renderMarkdown('See [Grocery List](crumb-note://abc-123) for details');
expect(html).not.toContain('<a');
expect(html).toContain('Grocery List');
});
it('should still render a normal external link as a real anchor', () => {
const html = renderMarkdown('See [Example](https://example.com) for details');
expect(html).toContain('<a href="https://example.com"');
});
});
+19
View File
@@ -39,6 +39,25 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
}
});
md.core.ruler.after('task-lists', 'note-links', (state) => {
for (const token of state.tokens) {
if (token.type !== 'inline' || !token.children) continue;
const children = token.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type !== 'link_open') continue;
const href = children[i].attrGet('href') ?? '';
if (!href.startsWith('crumb-note://')) continue;
let closeIndex = i + 1;
while (closeIndex < children.length && children[closeIndex].type !== 'link_close') closeIndex++;
children.splice(closeIndex, 1);
children.splice(i, 1);
i -= 1;
}
}
});
export function renderMarkdown(content: string): string {
return md.render(content);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { extractNoteLinks } from './note-links.js';
describe('extractNoteLinks', () => {
it('should extract a single note-link reference', () => {
expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']);
});
it('should extract multiple distinct references', () => {
const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)';
expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']);
});
it('should deduplicate repeated references', () => {
const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)';
expect(extractNoteLinks(content)).toEqual(['id-1']);
});
it('should return an empty array for content with no note-links', () => {
expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]);
});
it('should return an empty array for empty content', () => {
expect(extractNoteLinks('')).toEqual([]);
});
it('should not match crumb-note:// occurring inside a code block', () => {
const content = '```\n[A](crumb-note://id-1)\n```';
expect(extractNoteLinks(content)).toEqual([]);
});
it('should not match crumb-note:// occurring inside inline code', () => {
expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]);
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Extract crumb-note://<id> references from markdown content.
* Mirrors extractTags' code-block/inline-code exclusion so a reference
* mentioned as an example inside a code sample isn't treated as a real link.
*/
export function extractNoteLinks(content: string): string[] {
if (!content) return [];
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g);
if (!matches) return [];
const ids = matches.map((m) => m.replace('crumb-note://', ''));
return [...new Set(ids)];
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { generatePassword, PASSWORD_ALPHABET } from './password.js';
const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/;
const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/;
const DIGIT = /[23456789]/;
const SYMBOL = /[!@#$%^&*]/;
/** Visually ambiguous characters the charset deliberately excludes. */
const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I'];
describe('PASSWORD_ALPHABET', () => {
it('is exactly 64 distinct characters', () => {
// 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform.
// A charset edit that breaks this silently biases every password.
expect(PASSWORD_ALPHABET).toHaveLength(64);
expect(new Set(PASSWORD_ALPHABET).size).toBe(64);
});
it('excludes every visually ambiguous character', () => {
for (const char of AMBIGUOUS) {
expect(PASSWORD_ALPHABET.includes(char), char).toBe(false);
}
});
});
describe('generatePassword', () => {
it('returns a 20-character password by default', () => {
expect(generatePassword()).toHaveLength(20);
});
it('honours an explicit length', () => {
expect(generatePassword(8)).toHaveLength(8);
expect(generatePassword(32)).toHaveLength(32);
expect(generatePassword(64)).toHaveLength(64);
});
it('always includes at least one character from each of the four groups', () => {
// Not a statistical bet: the function only ever returns a class-complete
// password or throws, so every sample must pass.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
expect(password, `sample ${i}`).toMatch(LOWERCASE);
expect(password, `sample ${i}`).toMatch(UPPERCASE);
expect(password, `sample ${i}`).toMatch(DIGIT);
expect(password, `sample ${i}`).toMatch(SYMBOL);
}
});
it('never emits a visually ambiguous character', () => {
// 0 O o 1 l I survive neither being read aloud nor retyped.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
for (const char of AMBIGUOUS) {
expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false);
}
}
});
it('draws only from the pinned 64-character charset', () => {
const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/;
for (let i = 0; i < 20; i++) {
expect(generatePassword(), `sample ${i}`).toMatch(charset);
}
});
it('produces a different password on each call', () => {
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
expect(passwords.size).toBe(20);
});
it('throws below the 8-character server minimum', () => {
expect(() => generatePassword(7)).toThrow(/8/);
expect(() => generatePassword(0)).toThrow(/8/);
expect(() => generatePassword(-1)).toThrow(/8/);
});
it('does not use Math.random anywhere in the module', () => {
// Randomness must come from nanoid's CSPRNG, not Math.random.
const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8');
expect(source).not.toContain('Math.random');
});
});
+59
View File
@@ -0,0 +1,59 @@
import { customAlphabet } from 'nanoid';
// The excluded characters — 0 O o 1 l I — are visually ambiguous, so a generated
// password survives being read aloud or retyped from a screenshot.
const LOWERCASE = 'abcdefghijkmnpqrstuvwxyz'; // no l, no o
const UPPERCASE = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no I, no O
const DIGITS = '23456789'; // no 0, no 1
const SYMBOLS = '!@#$%^&*';
/**
* Pinned at exactly 64 characters. 64 is a power of two, so nanoid's masking
* takes its exactly-uniform fast path (256 % 64 === 0) and no character is
* favoured over another. At 64 symbols a 20-character password carries 120 bits
* of entropy — 119.79 once conditioned on the class-completeness retry below.
* Do not change the size without understanding both consequences.
*/
export const PASSWORD_ALPHABET = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS;
const GROUPS = [LOWERCASE, UPPERCASE, DIGITS, SYMBOLS];
/** The server rejects anything shorter. */
const MIN_LENGTH = 8;
/**
* Per-attempt failure is 13.5% at the default length of 20 and 61.9% at the
* minimum of 8, so exhausting the cap has probability ~1e-87 and ~1e-21
* respectively — unreachable in practice. The bound exists so a future charset
* edit cannot turn the retry into an infinite loop.
*/
const MAX_ATTEMPTS = 100;
const randomString = customAlphabet(PASSWORD_ALPHABET);
function containsAnyOf(password: string, group: string): boolean {
return [...password].some((char) => group.includes(char));
}
/**
* Generates a random password containing at least one lowercase letter, one
* uppercase letter, one digit and one symbol.
*
* A password missing a group is discarded whole and regenerated, which keeps the
* result uniform over exactly the set of valid passwords. Forcing characters
* into chosen positions and shuffling would need its own unbiased index source.
*/
export function generatePassword(length = 20): string {
if (length < MIN_LENGTH) {
throw new Error(`Password length must be at least ${MIN_LENGTH}, got ${length}`);
}
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const password = randomString(length);
if (GROUPS.every((group) => containsAnyOf(password, group))) return password;
}
throw new Error(
`Failed to generate a password with all character groups after ${MAX_ATTEMPTS} attempts`
);
}
+376 -33
View File
@@ -1,6 +1,15 @@
<script lang="ts">
import type { User } from '$lib/types/index.js';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
import { canResetPassword, passwordResetBlockedReason } from '$lib/utils/auth-methods.js';
import { generatePassword } from '$lib/utils/password.js';
import { tooltip } from '$lib/utils/tooltip.js';
import ShieldCheck from 'lucide-svelte/icons/shield-check';
import ShieldOff from 'lucide-svelte/icons/shield-off';
import KeyRound from 'lucide-svelte/icons/key-round';
import LogOut from 'lucide-svelte/icons/log-out';
import Trash2 from 'lucide-svelte/icons/trash-2';
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
let { data } = $props();
// svelte-ignore state_referenced_locally
@@ -15,6 +24,213 @@
let createMsg = $state('');
let createError = $state(false);
/**
* One row's password-reset state.
*
* The generated plaintext lives here and nowhere else — never localStorage,
* sessionStorage, IndexedDB, a sync payload or a log — so navigating away is
* enough to lose it. State is per row rather than shared because two rows'
* outcomes would otherwise overwrite each other, and a password destroyed that
* way is unrecoverable.
*/
type ResetState = {
inFlight: boolean;
/** Revealed plaintext. Set on success and on an unconfirmed outcome. */
password: string | null;
/** Row message. Always rendered *alongside* `password`, never instead of it. */
message: string | null;
copyStatus: string | null;
/**
* Reason the server refused, kept even after the message is dismissed. This
* list snapshots `authProvider` at page load and never refetches, so a row can
* still look eligible after the account moved to OAuth; without this the
* control would stay available and invite the same failure forever.
*/
serverReason: string | null;
};
const IDLE_RESET: ResetState = {
inFlight: false,
password: null,
message: null,
copyStatus: null,
serverReason: null
};
const SELF_RESET_REASON = 'Change your own password in Profile.';
/**
* The request left the device but no answer came back, so the server may well
* have stored this password. Revealing it anyway is deliberate: it is the only
* copy that exists, and dropping it could leave the account holding a credential
* nobody knows.
*/
const UNCONFIRMED_MESSAGE =
'Could not confirm the reset. This password may or may not have been applied. Keep it — if the user cannot sign in, reset again once you are back online.';
/**
* Definite, not hedged: the service worker caches only `GET /api/*`, so a PATCH
* issued while offline never leaves the browser and nothing can have changed.
*/
const OFFLINE_MESSAGE = "You're offline — nothing was changed. Try again when reconnected.";
let resets = $state<Record<number, ResetState>>({});
let announcement = $state('');
const resetButtons: Record<number, HTMLButtonElement | null> = {};
function userLabel(user: User): string {
return user.displayName || user.email;
}
/**
* Updates the live region's text. The region itself is rendered empty from the
* first paint and never inserted alongside its content — a region added to the
* DOM together with its text is not reliably announced, least of all by
* TalkBack.
*/
function announce(text: string) {
announcement = text;
}
/** Why this row's password cannot be reset, or null when it can. */
function resetBlockedReason(user: User): string | null {
// Takes precedence over eligibility: an admin changes their own password in
// the profile flow, which verifies the current one.
if (user.id === data.user?.id) return SELF_RESET_REASON;
const serverReason = resets[user.id]?.serverReason;
if (serverReason) return serverReason;
if (!canResetPassword(user)) {
// auth-methods guarantees a non-empty reason whenever the check fails; the
// ?? only satisfies the nullable return type, as on the API route.
return passwordResetBlockedReason(user) ?? 'Password login is not available for this account.';
}
return null;
}
/**
* How one row's control presents itself. `unavailable` is expressed with
* `aria-disabled`, never the `disabled` attribute: a disabled button leaves the
* tab order, so a keyboard or screen-reader user loses the element they just
* activated, and a `title` tooltip explains nothing on a touch screen.
*/
function resetControl(user: User): {
reason: string | null;
inFlight: boolean;
unavailable: boolean;
} {
const state = resets[user.id];
const reason = resetBlockedReason(user);
const inFlight = state?.inFlight === true;
return {
reason,
inFlight,
unavailable: reason !== null || inFlight || state?.password != null
};
}
/** Merges a partial update into one row's state, leaving untouched fields alone. */
function patchReset(userId: number, patch: Partial<ResetState>) {
resets[userId] = { ...IDLE_RESET, ...resets[userId], ...patch };
}
function revealUnconfirmed(user: User, password: string) {
patchReset(user.id, { inFlight: false, password, message: UNCONFIRMED_MESSAGE });
announce(
`Could not confirm the password reset for ${userLabel(user)}. The generated password is shown in their row — keep it.`
);
}
async function resetUserPassword(user: User) {
// The control stays focusable while unavailable, so activation is refused
// here instead of by the browser.
if (resetControl(user).unavailable) return;
// Checked before the request, so the offline outcome is reported as the
// certainty it is rather than as a lost response.
if (navigator.onLine === false) {
patchReset(user.id, { message: OFFLINE_MESSAGE, copyStatus: null });
announce(`${userLabel(user)}: ${OFFLINE_MESSAGE}`);
return;
}
const password = generatePassword();
patchReset(user.id, { inFlight: true, message: null, copyStatus: null });
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: password })
});
if (res.ok) {
patchReset(user.id, { inFlight: false, password, message: null });
announce(
`New password generated for ${userLabel(user)}. It is shown in their row — copy it now, it cannot be shown again.`
);
return;
}
if (res.status === 404) {
users = users.filter((u) => u.id !== user.id);
// No account, no credential: drop the row's plaintext with the row.
delete resets[user.id];
announce(
`${userLabel(user)} no longer exists. The row was removed and no password was set.`
);
return;
}
const body = await res.json().catch(() => ({}));
const message = typeof body.message === 'string' && body.message ? body.message : null;
if (message !== null && res.status < 500) {
// A refusal the server states outright: nothing was stored, so no
// password is revealed and the reason sticks to the control.
patchReset(user.id, { inFlight: false, message, serverReason: message });
announce(`Password reset failed for ${userLabel(user)}. ${message}`);
return;
}
// A status without a readable reason says nothing about what the server did.
revealUnconfirmed(user, password);
} catch {
// The response was lost, not refused — see UNCONFIRMED_MESSAGE.
revealUnconfirmed(user, password);
}
}
async function copyPassword(user: User) {
const password = resets[user.id]?.password;
if (!password) return;
try {
// Absent outside a secure context, which a LAN-IP HTTP deployment is not,
// so the failure below has to be a working fallback rather than a dead end.
await navigator.clipboard.writeText(password);
patchReset(user.id, { copyStatus: 'Copied to clipboard.' });
announce(`Password for ${userLabel(user)} copied to clipboard.`);
} catch {
patchReset(user.id, {
copyStatus: 'Could not copy automatically — select the password and copy it by hand.'
});
announce(
`Could not copy the password for ${userLabel(user)}. Select it in their row and copy it by hand.`
);
}
}
function dismissReset(user: User) {
const serverReason = resets[user.id]?.serverReason ?? null;
// A server refusal explains the control's state, which outlives the message
// being dismissed here.
if (serverReason) resets[user.id] = { ...IDLE_RESET, serverReason };
else delete resets[user.id];
announce(`Password reset message for ${userLabel(user)} cleared.`);
// Never drop the admin on <body>: give the row's control the focus back.
resetButtons[user.id]?.focus();
}
async function createUser() {
createMsg = '';
if (!newEmail) {
@@ -70,6 +286,7 @@
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
delete resets[userId];
createMsg = 'User deleted';
createError = false;
} else {
@@ -172,46 +389,172 @@
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
<!-- Rendered empty on first paint; only its text is ever replaced, so both
reveals and per-row failures are announced from a region assistive tech
has already been watching. -->
<p
role="status"
aria-live="polite"
data-testid="reset-password-live"
class="sr-only"
>{announcement}</p>
<div class="space-y-3">
{#each users as user (user.id)}
{@const reset = resets[user.id]}
{@const control = resetControl(user)}
{@const roleAction = user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
{@const resetLabel = control.inFlight ? 'Resetting…' : 'Reset password'}
<div
class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between"
class="flex flex-col gap-3 rounded-sm border border-[var(--border-subtle)] p-4"
data-testid="user-row-{user.id}"
>
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
{/if}
</p>
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<!-- One uniform 44x44 square per action, replacing four text labels that
wrapped onto three lines at phone width. `flex-wrap` is what keeps the
squares square: flex compresses items only on a line that overflows, so
breaking the line means no button is ever asked to shrink. Drop it and
the four flatten to 40x44 at 320px — under the minimum, on a row whose
Delete is irreversible. They hold one line down to 336px, which is
exactly 4x44 plus the gaps, and take a second line below that: correctly
sized targets over two lines beat cramped ones on one. The container's
own `shrink-0` is a different mechanism and does carry weight — from
640px the row turns `sm:flex-row`, where the truncating name block would
otherwise squeeze the strip. On the buttons themselves that same token
would do nothing, `flex-wrap` having already spared them. Each icon
carries both a tooltip, for a mouse, and an aria-label, since an icon
alone has no accessible name. -->
<div class="flex shrink-0 flex-wrap items-center gap-1">
<button
onclick={() => toggleRole(user)}
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={roleAction}
aria-label={roleAction}
>
{#if user.role === 'admin'}
<ShieldOff class="h-4 w-4" />
{:else}
<ShieldCheck class="h-4 w-4" />
{/if}
</button>
<!-- Offered on every row, the admin's own included: a control that
vanishes is the confusion this replaces. -->
<button
bind:this={resetButtons[user.id]}
onclick={() => resetUserPassword(user)}
data-testid="reset-password-btn-{user.id}"
aria-disabled={control.unavailable}
aria-describedby={control.reason ? `reset-password-reason-${user.id}` : undefined}
use:tooltip={resetLabel}
aria-label={resetLabel}
class="inline-flex size-11 items-center justify-center rounded-sm transition-colors duration-150 ease-out {control.unavailable
? 'cursor-not-allowed border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)]'
: 'text-[var(--text)] hover:bg-[var(--hover-wash)]/10'}"
>
{#if control.inFlight}
<!-- The label went with the text, so in-flight has to be carried by
the icon and by the accessible name above. -->
<LoaderCircle class="h-4 w-4 animate-spin" />
{:else}
<KeyRound class="h-4 w-4" />
{/if}
</button>
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={'Revoke all sessions'}
aria-label="Revoke all sessions"
>
<LogOut class="h-4 w-4" />
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
use:tooltip={'Delete user'}
aria-label="Delete user"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</p>
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
onclick={() => toggleRole(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Make user' : 'Make admin'}
</button>
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title="Force logout"
>
Revoke
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Delete
</button>
{/if}
</div>
</div>
{#if control.reason || reset?.message || reset?.password}
<div class="flex flex-col gap-2">
{#if control.reason}
<!-- Visible text, not a tooltip: a title attribute is unreachable
by touch, and colour alone explains nothing. -->
<p
id="reset-password-reason-{user.id}"
data-testid="reset-password-reason-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{control.reason}</p>
{/if}
{#if reset?.message}
<p
data-testid="reset-password-error-{user.id}"
class="rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-2 text-xs text-[var(--error-text)]"
>{reset.message}</p>
{/if}
{#if reset?.password}
<!-- Rendered independently of the message above, so a failure can
never replace a password that is already the account's only
credential. -->
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border)] bg-[var(--bg-base)] p-3 shadow-[var(--card-shadow)]">
<p class="text-xs text-[var(--text-muted)]">
New password for {userLabel(user)} — shown once, not stored anywhere.
</p>
<code
data-testid="generated-password-{user.id}"
class="block break-all select-all text-sm text-[var(--text)]"
>{reset.password}</code>
</div>
{/if}
{#if reset?.message || reset?.password}
<!-- 44px targets with a wide gap: dismissing is irreversible, so a
mis-tap next to Copy would destroy a live credential. -->
<div class="flex flex-wrap items-center gap-3">
{#if reset?.password}
<button
onclick={() => copyPassword(user)}
data-testid="copy-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--border-subtle)] px-3 text-xs text-[var(--text)] transition-colors duration-150 ease-out hover:border-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
>
Copy
</button>
{/if}
<button
onclick={() => dismissReset(user)}
data-testid="dismiss-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--destructive)] px-3 text-xs text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
>
Dismiss
</button>
</div>
{/if}
{#if reset?.copyStatus}
<p
data-testid="copy-status-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{reset.copyStatus}</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
import { canResetPassword, passwordResetBlockedReason } from '$lib/utils/auth-methods.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const admin = requireAdmin(event);
@@ -15,6 +16,9 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const origin = process.env.ORIGIN || 'http://localhost:3000';
if (body.role !== undefined) {
if (body.role !== 'admin' && body.role !== 'user') {
throw error(400, 'Invalid role');
}
// Prevent admin from demoting themselves
if (userId === admin.id && body.role !== 'admin') {
throw error(400, 'Cannot change your own role');
@@ -31,6 +35,20 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
if (body.newPassword.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
// Prevent admin from resetting their own password — changing your own
// password goes through the profile flow, which verifies the current one.
if (userId === admin.id) {
throw error(400, 'Cannot reset your own password — change it from your profile instead');
}
// Refuse accounts that do not sign in with a password. resetPassword()
// writes only passwordHash, and verifyPassword() matches only rows whose
// authProvider is 'password' — a hash written here could never be used to
// sign in, while the email below would claim the reset worked. The reason
// text comes from the same module as the check, so this matches what the
// UI explains; the ?? only satisfies error()'s string parameter.
if (!canResetPassword(user)) {
throw error(400, passwordResetBlockedReason(user) ?? 'This account does not use password login');
}
await resetPassword(userId, body.newPassword);
// Notify user of password reset (fire-and-forget)
@@ -23,6 +23,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
return new Response(buffer, {
headers: {
'Content-Type': mimeType,
'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=31536000, immutable'
}
@@ -31,6 +31,7 @@ export const GET: RequestHandler = async ({ params, url, getClientAddress }) =>
return new Response(buffer, {
headers: {
'Content-Type': mimeType,
'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=3600'
}
+1 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.27.1",
"version": "0.31.0",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
+808 -1
View File
@@ -1,4 +1,5 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, TEST_EMAIL } from './helpers/fixtures.js';
import { randomUUID } from 'crypto';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
@@ -23,6 +24,35 @@ async function createVictim(
return user.id;
}
/**
* Create a victim user with no password via the admin API, returning the user's id.
* Omitting the password is the OAuth-only path: createUser() stores authProvider
* 'none', which verifyPassword() never matches.
*/
async function createOAuthOnlyVictim(
page: import('@playwright/test').Page,
email: string,
displayName = email.split('@')[0]
): Promise<number> {
const res = await page.request.post('/api/admin/users', {
data: { email, displayName, role: 'user' }
});
expect(res.status()).toBe(201);
const user = await res.json();
expect(user.authProvider).toBe('none');
return user.id;
}
/** The id of the admin account the authenticated page is signed in as. */
async function currentAdminId(page: import('@playwright/test').Page): Promise<number> {
const res = await page.request.get('/api/admin/users');
expect(res.status()).toBe(200);
const users = (await res.json()) as Array<{ id: number; email: string }>;
const admin = users.find((u) => u.email === TEST_EMAIL);
if (!admin) throw new Error(`admin ${TEST_EMAIL} is missing from the users list`);
return admin.id;
}
test.describe.serial('Admin — User Deletion', () => {
test('Scenario: Admin deletes a user from the users list', async ({
authenticatedPage: page
@@ -151,3 +181,780 @@ test.describe.serial('Admin — User Deletion', () => {
expect(loginRes.status()).toBe(401);
});
});
test.describe.serial('Admin — Password Reset', () => {
test('Scenario: An account can sign in with a password the admin set', async ({
authenticatedPage: page,
browser
}) => {
// Given a password-auth user exists
const email = 'reset-target@test.com';
const victimId = await createVictim(page, email, 'Reset Target');
// When the admin sets a new password for that account
const newPassword = `reset-${randomUUID()}`;
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword }
});
// Then the reset succeeds
expect(res.status()).toBe(200);
// And the account can sign in with the new password. A separate browser
// context keeps the resulting session cookie out of the admin's jar.
const ctx = await browser.newContext();
const loginRes = await ctx.request.post('/api/auth/login', {
data: { email, password: newPassword }
});
expect(loginRes.status()).toBe(200);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An account that signs in via OAuth cannot be given a password', async ({
authenticatedPage: page,
browser
}) => {
// Given an OAuth-only user exists
const email = 'oauth-only@test.com';
const victimId = await createOAuthOnlyVictim(page, email, 'OAuth Only');
// When the admin attempts to set a password for that account
const attemptedPassword = `attempt-${randomUUID()}`;
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword: attemptedPassword }
});
// Then the attempt is rejected and the response says why
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.message).toMatch(/OAuth|password login/i);
// And the account still cannot sign in with the attempted password.
// This is the positive control for the rejection above: it confirms no
// usable credential was stored, not merely that the request 400'd.
const ctx = await browser.newContext();
const loginRes = await ctx.request.post('/api/auth/login', {
data: { email, password: attemptedPassword }
});
expect(loginRes.status()).toBe(401);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An admin cannot reset their own password', async ({
authenticatedPage: page
}) => {
// Given the admin's own account
const adminId = await currentAdminId(page);
// When the admin targets their own account for a password reset
const res = await page.request.patch(`/api/admin/users/${adminId}`, {
data: { newPassword: `self-${randomUUID()}` }
});
// Then the attempt is rejected and the response says why
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.message).toMatch(/your own password/i);
});
test('Scenario: A password below the minimum length is rejected for its length, whatever the account', async ({
authenticatedPage: page
}) => {
// Given an OAuth-only user exists, who is also ineligible for a reset
const victimId = await createOAuthOnlyVictim(page, 'short-pw-oauth@test.com', 'Short Pw');
// When the admin attempts to set a password shorter than the minimum
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword: 'short' }
});
// Then the length rule is what the response reports, taking precedence
// over the eligibility rule
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.message).toMatch(/at least 8 characters/i);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
});
/** Open the users settings page and wait for it to hydrate. */
async function openUsersPage(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
}
/**
* Reset a row's password through the UI and return the revealed plaintext.
* Trimmed because the reveal is markup-formatted text, not a form value.
*/
async function resetViaUi(
page: import('@playwright/test').Page,
victimId: number
): Promise<string> {
await page.getByTestId(`reset-password-btn-${victimId}`).click();
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
const revealed = (await reveal.textContent())?.trim() ?? '';
expect(revealed.length).toBeGreaterThanOrEqual(8);
return revealed;
}
/**
* The four row action buttons, in DOM order, named by their accessible name. An
* eligible row that is not the signed-in admin's own offers all four.
*/
const ROW_ACTION_LABELS = [
'Promote to admin',
'Reset password',
'Revoke all sessions',
'Delete user'
] as const;
test.describe('Admin — Password Reset UI', () => {
test('Scenario: Every user row offers a reset control, and an unavailable one says why', async ({
authenticatedPage: page
}) => {
// Given a password-auth user and an OAuth-only user exist
const eligibleId = await createVictim(page, 'ui-reason-pw@test.com', 'UI Reason Pw');
const oauthId = await createOAuthOnlyVictim(page, 'ui-reason-oauth@test.com', 'UI Reason OAuth');
const adminId = await currentAdminId(page);
// When the admin views the users list
const patchedIds: string[] = [];
page.on('request', (req) => {
if (req.method() === 'PATCH' && req.url().includes('/api/admin/users/')) {
patchedIds.push(req.url());
}
});
await openUsersPage(page);
// Then the control is offered on every row, including the admin's own
for (const id of [eligibleId, oauthId, adminId]) {
await expect(page.getByTestId(`reset-password-btn-${id}`)).toBeVisible();
}
// And it is available for the password-auth account
await expect(page.getByTestId(`reset-password-btn-${eligibleId}`)).toHaveAttribute(
'aria-disabled',
'false'
);
await expect(page.getByTestId(`reset-password-reason-${eligibleId}`)).toHaveCount(0);
// And the OAuth-only account explains in visible text why it is unavailable
const oauthBtn = page.getByTestId(`reset-password-btn-${oauthId}`);
await expect(oauthBtn).toHaveAttribute('aria-disabled', 'true');
const oauthReason = page.getByTestId(`reset-password-reason-${oauthId}`);
await expect(oauthReason).toBeVisible();
await expect(oauthReason).toContainText(/OAuth|password login/i);
await expect(oauthBtn).toHaveAttribute(
'aria-describedby',
`reset-password-reason-${oauthId}`
);
// And the admin's own row points them at their profile instead
const ownBtn = page.getByTestId(`reset-password-btn-${adminId}`);
await expect(ownBtn).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByTestId(`reset-password-reason-${adminId}`)).toContainText(/Profile/i);
// And an unavailable control stays reachable by keyboard
for (const btn of [oauthBtn, ownBtn]) {
await expect(btn).not.toHaveAttribute('disabled', /.*/);
await btn.focus();
await expect(btn).toBeFocused();
}
// And activating an unavailable control changes nothing. force: true skips
// Playwright's own aria-disabled actionability check, so what is under test is
// the handler refusing the activation, not the harness declining to send it.
await ownBtn.click({ force: true });
await oauthBtn.click({ force: true });
await expect(page.getByTestId(`generated-password-${adminId}`)).toHaveCount(0);
await expect(page.getByTestId(`generated-password-${oauthId}`)).toHaveCount(0);
expect(patchedIds).toEqual([]);
// Clean up
await page.request.delete(`/api/admin/users/${eligibleId}`);
await page.request.delete(`/api/admin/users/${oauthId}`);
});
test('Scenario: An account signs in with the password the admin generated, and its old one stops working', async ({
authenticatedPage: page,
browser
}) => {
// Given a password-auth user exists
const email = 'ui-roundtrip@test.com';
const victimId = await createVictim(page, email, 'UI Roundtrip');
await openUsersPage(page);
// And the announcement region is present and silent
await expect(page.getByTestId('reset-password-live')).toHaveText('');
// When the admin resets that account's password
const patch = page.waitForResponse(
(res) =>
res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'PATCH'
);
const revealed = await resetViaUi(page, victimId);
expect((await patch).status()).toBe(200);
// Then the reveal is announced
await expect(page.getByTestId('reset-password-live')).toContainText(/password/i);
// And that row's control is unavailable until the reveal is dismissed
await expect(page.getByTestId(`reset-password-btn-${victimId}`)).toHaveAttribute(
'aria-disabled',
'true'
);
// And the account signs in with the generated password, from a context whose
// cookie jar is separate from the admin's
const ctx = await browser.newContext();
const newLogin = await ctx.request.post('/api/auth/login', {
data: { email, password: revealed }
});
expect(newLogin.status()).toBe(200);
// And the password it had before is refused. 401 exactly: a 429 would mean the
// shared test IP is rate-limited, which proves nothing about the credential.
const oldLogin = await ctx.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(oldLogin.status()).toBe(401);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A revealed password is never persisted or logged', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists
const victimId = await createVictim(page, 'ui-nostore@test.com', 'UI No Store');
const consoleMessages: string[] = [];
page.on('console', (msg) => consoleMessages.push(msg.text()));
await openUsersPage(page);
// When the admin resets that account's password
const revealed = await resetViaUi(page, victimId);
// Then the plaintext reaches no persistent client storage
const leaks = await page.evaluate(async (secret: string) => {
const hits: string[] = [];
const scanWebStorage = (store: Storage, label: string) => {
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
if (key === null) continue;
if (key.includes(secret) || (store.getItem(key) ?? '').includes(secret)) {
hits.push(`${label}:${key}`);
}
}
};
scanWebStorage(localStorage, 'localStorage');
scanWebStorage(sessionStorage, 'sessionStorage');
const databases = (await indexedDB.databases?.()) ?? [];
for (const info of databases) {
if (!info.name) continue;
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(info.name as string);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
for (const storeName of Array.from(db.objectStoreNames)) {
const records = await new Promise<unknown[]>((resolve, reject) => {
const request = db.transaction(storeName, 'readonly').objectStore(storeName).getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
let serialised = '';
try {
serialised = JSON.stringify(records) ?? '';
} catch {
serialised = '';
}
if (serialised.includes(secret)) hits.push(`indexedDB:${info.name}/${storeName}`);
}
db.close();
}
return hits;
}, revealed);
expect(leaks).toEqual([]);
// And it appears in no console output
expect(consoleMessages.filter((m) => m.includes(revealed))).toEqual([]);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A revealed password is shown whole on a phone-sized screen', async ({
authenticatedPage: page
}) => {
// Given a password-auth user seen on a 360px-wide screen
const victimId = await createVictim(page, 'ui-narrow@test.com', 'UI Narrow');
await page.setViewportSize({ width: 360, height: 800 });
await openUsersPage(page);
// When the admin resets that account's password
const revealed = await resetViaUi(page, victimId);
// Then none of it is clipped away — truncating it would quietly corrupt the
// fallback of copying it by hand
const shown = await page.getByTestId(`generated-password-${victimId}`).evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
text: (el.textContent ?? '').trim()
}));
expect(shown.text).toBe(revealed);
expect(shown.clientWidth).toBeGreaterThan(0);
expect(shown.scrollWidth).toBeLessThanOrEqual(shown.clientWidth);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Copying a revealed password confirms that it was copied', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the clipboard is available
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const victimId = await createVictim(page, 'ui-copy-ok@test.com', 'UI Copy Ok');
await openUsersPage(page);
// When the admin resets the password and copies it
const revealed = await resetViaUi(page, victimId);
await page.getByTestId(`copy-password-btn-${victimId}`).click();
// Then the copy is confirmed and the clipboard holds the password
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/copied/i);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(revealed);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A refused clipboard leaves the password copyable by hand', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the clipboard refuses writes.
// defineProperty, not assignment: navigator.clipboard is a getter-only
// accessor, so assigning to it silently leaves the real API in place.
const victimId = await createVictim(page, 'ui-copy-denied@test.com', 'UI Copy Denied');
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
get: () => ({
writeText: () => Promise.reject(new DOMException('denied', 'NotAllowedError'))
})
});
});
await openUsersPage(page);
// When the admin resets the password and tries to copy it
const revealed = await resetViaUi(page, victimId);
await page.getByTestId(`copy-password-btn-${victimId}`).click();
// Then the failure is reported as a manual-copy fallback
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/by hand|manually/i);
// And the password is still shown, selectable, for copying by hand
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect((await reveal.textContent())?.trim()).toBe(revealed);
expect(await reveal.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Dismissing a reveal clears the password and leaves focus on that row', async ({
authenticatedPage: page
}) => {
// Given a password-auth user whose password was just reset
const victimId = await createVictim(page, 'ui-dismiss@test.com', 'UI Dismiss');
await openUsersPage(page);
await resetViaUi(page, victimId);
// When the admin dismisses the reveal
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
// Then the password is gone from the page
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toHaveCount(0);
// And focus is back on that row's control, which is available again
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await expect(btn).toBeFocused();
await expect(btn).toHaveAttribute('aria-disabled', 'false');
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Each of the three controls is large enough to hit on a phone', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists, seen on a phone-sized screen
const victimId = await createVictim(page, 'ui-target@test.com', 'UI Target');
await page.setViewportSize({ width: 360, height: 800 });
await openUsersPage(page);
// When the admin resets that account's password
await resetViaUi(page, victimId);
// Then every control in the flow meets the 44px minimum. Dismiss destroys the
// only copy of the credential, so a cramped target is a data-loss risk.
for (const testId of [
`reset-password-btn-${victimId}`,
`copy-password-btn-${victimId}`,
`dismiss-password-btn-${victimId}`
]) {
const box = await page.getByTestId(testId).boundingBox();
expect(box, testId).not.toBeNull();
expect(Math.min(box!.width, box!.height), testId).toBeGreaterThanOrEqual(44);
}
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Row actions stay full-size square targets at every phone width', async ({
authenticatedPage: page
}) => {
// Given a password-auth user, whose row therefore offers all four actions
const victimId = await createVictim(page, 'ui-strip@test.com', 'UI Strip');
// The strip needs 188px of room, so it holds one line to 336px and wraps
// below. 360px covers the fitting case; 320px covers the case that used to
// shrink; 300px is narrow enough that a strip which refused to wrap would be
// pushed clear of its row, which the containment check below then catches.
for (const [width, fitsOneLine] of [
[360, true],
[320, false],
[300, false]
] as const) {
// When the admin views the users list on a screen that wide
await page.setViewportSize({ width, height: 800 });
await openUsersPage(page);
const row = page.getByTestId(`user-row-${victimId}`);
const boxes = [];
for (const label of ROW_ACTION_LABELS) {
const box = await row.getByRole('button', { name: label, exact: true }).boundingBox();
expect(box, `${label} at ${width}px`).not.toBeNull();
boxes.push(box!);
}
// Then every action is a square that clears the 44px minimum. One of them
// deletes an account irreversibly, so an action that shrank to fit its row
// would put a data-losing target under a thumb.
for (const [i, box] of boxes.entries()) {
const at = `${ROW_ACTION_LABELS[i]} at ${width}px`;
expect(Math.min(box.width, box.height), at).toBeGreaterThanOrEqual(44);
expect(Math.abs(box.width - box.height), at).toBeLessThan(1);
}
// And they sit on one line wherever there is room for one
const lines = new Set(boxes.map((box) => Math.round(box.y)));
if (fitsOneLine) expect(lines.size, `lines at ${width}px`).toBe(1);
// And where there is not, they wrap rather than spill out of their row or
// push the page sideways
const rowBox = await row.boundingBox();
expect(rowBox, `row at ${width}px`).not.toBeNull();
for (const [i, box] of boxes.entries()) {
expect(box.x + box.width, `${ROW_ACTION_LABELS[i]} at ${width}px`).toBeLessThanOrEqual(
rowBox!.x + rowBox!.width
);
}
const overflows = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
);
expect(overflows, `horizontal overflow at ${width}px`).toBe(false);
}
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A refused reset reports the server reason and reveals no password', async ({
authenticatedPage: page
}) => {
// Given a user the server refuses to reset — an account whose sign-in method
// changed after the list was loaded, so the row still looks eligible
const victimId = await createVictim(page, 'ui-rejected@test.com', 'UI Rejected');
const serverReason = 'Managed by SSO — password login is disabled for this account.';
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ message: serverReason })
});
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the refusal is shown in that row, and announced
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(serverReason);
await expect(page.getByTestId('reset-password-live')).toContainText(serverReason);
// And no password is revealed
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
// And the control no longer invites a retry that would fail the same way
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await expect(btn).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toContainText(serverReason);
// And dismissing the message keeps that explanation in place
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toHaveCount(0);
await expect(btn).toBeFocused();
await expect(btn).toHaveAttribute('aria-disabled', 'true');
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A reset whose outcome is unknown keeps the generated password', async ({
authenticatedPage: page
}) => {
// Given a user whose reset response never arrives, so the server may or may
// not have stored the new password
const victimId = await createVictim(page, 'ui-unknown@test.com', 'UI Unknown');
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.abort('connectionreset');
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the uncertainty is stated
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/may or may not/i
);
// And the generated password is kept, because it may be the account's only
// working credential and nothing else holds a copy of it
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toBeVisible();
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A server-side failure keeps the generated password even when it gives a reason', async ({
authenticatedPage: page
}) => {
// Given a user whose reset fails inside the server, with a reason attached —
// a reason says why the server is unhappy, never whether it already stored
// the password, so the outcome is unknown however readable the body is
const victimId = await createVictim(page, 'ui-5xx-reason@test.com', 'UI 5xx Reason');
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ message: 'Database is temporarily read-only.' })
});
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the uncertainty is stated rather than the server's reason being taken
// as a refusal
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/may or may not/i
);
// And the generated password is kept, because the server may already hold it
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A rejection nobody can read keeps the generated password', async ({
authenticatedPage: page
}) => {
// Given a user whose reset comes back as an unreadable error page — what a
// reverse proxy returns when it answers instead of the app, so the request may
// have reached the server or may not have
const victimId = await createVictim(page, 'ui-html-error@test.com', 'UI Html Error');
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'text/html',
body: '<html><body><h1>400 Bad Request</h1></body></html>'
});
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the uncertainty is stated rather than the status alone being read as a
// refusal
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/may or may not/i
);
// And the generated password is kept, because nothing said it was not stored
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
// And the control is not left explaining a reason it never received
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toHaveCount(0);
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A reset attempted offline reports that nothing was changed', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the browser has no connection
const victimId = await createVictim(page, 'ui-offline@test.com', 'UI Offline');
await openUsersPage(page);
const patchRequests: string[] = [];
page.on('request', (req) => {
if (req.method() === 'PATCH') patchRequests.push(req.url());
});
await page.context().setOffline(true);
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the outcome is stated as definite: nothing left the device
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/offline.*nothing was changed/i
);
expect(patchRequests).toEqual([]);
// And no password is revealed, since none was set
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
// Restore connectivity — Playwright does not reliably emit the online event
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: One row\'s reset never touches another row', async ({
authenticatedPage: page
}) => {
// Given two password-auth users exist
const firstId = await createVictim(page, 'ui-isolation-a@test.com', 'UI Isolation A');
const secondId = await createVictim(page, 'ui-isolation-b@test.com', 'UI Isolation B');
await openUsersPage(page);
// When the first account's password is reset
const revealed = await resetViaUi(page, firstId);
// Then the second row shows neither a password nor a message
await expect(page.getByTestId(`generated-password-${secondId}`)).toHaveCount(0);
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toHaveCount(0);
await expect(page.getByTestId(`reset-password-btn-${secondId}`)).toHaveAttribute(
'aria-disabled',
'false'
);
// And when the second account's reset is refused
await page.route(`**/api/admin/users/${secondId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ message: 'Password login is not available for this account.' })
});
});
await page.getByTestId(`reset-password-btn-${secondId}`).click();
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toBeVisible();
// Then the first row keeps its password and stays free of that message
expect((await page.getByTestId(`generated-password-${firstId}`).textContent())?.trim()).toBe(
revealed
);
await expect(page.getByTestId(`reset-password-error-${firstId}`)).toHaveCount(0);
// Clean up
await page.unroute(`**/api/admin/users/${secondId}`);
await page.request.delete(`/api/admin/users/${firstId}`);
await page.request.delete(`/api/admin/users/${secondId}`);
});
test('Scenario: Resetting an account that no longer exists drops it from the list', async ({
authenticatedPage: page
}) => {
// Given a user who is deleted after the list was loaded
const victimId = await createVictim(page, 'ui-missing@test.com', 'UI Missing');
await openUsersPage(page);
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
expect((await page.request.delete(`/api/admin/users/${victimId}`)).status()).toBe(200);
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the row disappears and no password is left behind
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
});
test('Scenario: A reset in flight cannot be issued twice', async ({
authenticatedPage: page
}) => {
// Given a password-auth user whose reset response is slow to arrive
const victimId = await createVictim(page, 'ui-inflight@test.com', 'UI In Flight');
await openUsersPage(page);
let patchCount = 0;
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
patchCount++;
await new Promise((resolve) => setTimeout(resolve, 1500));
await route.continue();
});
// When the admin activates the control twice in a row
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await btn.click();
await expect(btn).toHaveAccessibleName(/Resetting/);
await expect(btn).toHaveAttribute('aria-disabled', 'true');
await btn.click({ force: true });
// Then only one reset is issued
await expect(page.getByTestId(`generated-password-${victimId}`)).toBeVisible();
expect(patchCount).toBe(1);
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
});
+10
View File
@@ -13,6 +13,14 @@ async function runTiptapCommand(page: Page, commandFn: string) {
);
}
async function waitForTiptapEditor(page: Page) {
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="tiptap-editor"]');
return el && (el as any).__tiptapEditor != null;
});
}
/**
* Read the `checked` attribute of the first task item from the live ProseMirror
* document. This reflects the editor's document state, not just the native
@@ -57,6 +65,7 @@ test.describe('Rich text formatting', () => {
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "world" and applies bold formatting
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleBold().run()');
// Then the selected text appears bold in the editor
@@ -99,6 +108,7 @@ test.describe('Rich text formatting', () => {
// Given the user is editing a note with selected text
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'visit example');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:14}).run()');
// When the user opens the link popover and enters a URL
+11 -2
View File
@@ -13,9 +13,18 @@ const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = collabPassword;
const COLLAB_NAME = 'Collaborator User';
/** Find a specific note card by its title text */
/**
* Find a specific note card by its exact title text. Matches only the
* card's <h3> title, not its full text content — with note-linking, a
* card's content preview can render another note's title as plain text
* (e.g. a stripped note-link), so a substring match against the whole
* card can ambiguously resolve to more than one card.
*/
export function noteCard(page: Page, title: string): Locator {
return page.locator('[data-testid="note-card"]', { hasText: title });
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return page.locator('[data-testid="note-card"]').filter({
has: page.locator('h3', { hasText: new RegExp(`^${escaped}$`) })
});
}
/**
+110
View File
@@ -0,0 +1,110 @@
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
test.describe('Note Linking', () => {
test('Scenario: Linking to a note renders a live chip and clicking it opens that note', async ({ authenticatedPage: page }) => {
await createNote(page, 'Target Note', 'target content');
await createNote(page, 'Source Note', 'source content');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Linker Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Target');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Target Note' }).click();
await expect(page.getByTestId('tiptap-editor').locator('[data-note-id]')).toBeVisible();
await page.getByTestId('tiptap-editor').locator('[data-note-id]').click();
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Target Note');
});
test('Scenario: Renaming a linked note updates the displayed link text', async ({ authenticatedPage: page }) => {
await createNote(page, 'Original Title', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Linker Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Original');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Original Title' }).click();
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Original Title').click();
await page.getByTestId('note-title-input').fill('Renamed Title');
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Linker Note').click();
await expect(editor.getByText('Renamed Title')).toBeVisible();
await expect(editor.getByText('Original Title')).not.toBeVisible();
});
test('Scenario: A link to a trashed note renders inert and does not navigate', async ({ authenticatedPage: page }) => {
await createNote(page, 'Doomed Note', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Linker Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Doomed');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Doomed Note' }).click();
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Doomed Note').click();
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('trash-note-btn').click();
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
await noteCard(page, 'Linker Note').click();
await expect(editor.getByText('Note not found')).toBeVisible();
await editor.getByText('Note not found').click();
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Linker Note');
});
test('Scenario: The linked-to note shows a Referenced by backlink', async ({ authenticatedPage: page }) => {
await createNote(page, 'Popular Note', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Referencing Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Popular');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Popular Note' }).click();
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Popular Note').click();
await expect(page.getByTestId('backlink-chip').filter({ hasText: 'Referencing Note' })).toBeVisible();
});
test('Scenario: The note-picker excludes the currently-open note from its own search results', async ({ authenticatedPage: page }) => {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Self Reference Test');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Self Reference');
await expect(page.getByTestId('format-link-note-result').filter({ hasText: 'Self Reference Test' })).toHaveCount(0);
});
test('Scenario: Hovering a note-link chip reveals a remove button that deletes it without navigating', async ({ authenticatedPage: page }) => {
await createNote(page, 'Removable Target', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Has A Link');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Removable');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Removable Target' }).click();
const chip = page.getByTestId('tiptap-editor').locator('[data-note-id]');
await expect(chip).toBeVisible();
await chip.hover();
await page.getByRole('button', { name: 'Remove note link' }).click();
await expect(chip).toHaveCount(0);
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Has A Link');
});
});
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.31.0</span>
<span class="footer-version">v0.33.0</span>
</div>
</footer>