Compare commits

..
24 Commits
Author SHA1 Message Date
github-actions[bot] eb65425220 chore: bump version to 0.35.0 2026-09-18 06:15:07 +00:00
Claude 011113b7e1 fix(e2e): keep preview server keep-alive sockets open longer than clients
CI on main (run 310, commit 58e5b5d) failed one public-sharing scenario
with "apiRequestContext.post: socket hang up" on its first request, with
no server-side error. The same tree had passed E2E on the PR run minutes
earlier, and the failing call is the first request of the test.

That is the keep-alive race: Playwright's request context reuses idle
sockets to `vite preview`, whose Node http server closes an idle socket
after 5s. A request that lands on a socket the server is closing at that
instant dies client-side. Raising the preview server's keepAliveTimeout
to 65s (headersTimeout 66s, which Node requires to be larger) makes the
client always the side that closes, so the race cannot occur. A tiny Vite
plugin does this only for `vite preview`; production runs `node build`
and is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-18 08:12:40 +02:00
Claude 58e5b5dd66 feat(editor): make code snippets readable and syntax highlighted
Closes #90.

Code blocks and inline code inherited the typography plugin's defaults,
which shrank them to ~86% of an already-monospace body and painted them
--bg-surface — the same colour as the default note card — so a snippet
was only recognisable by being smaller. Long lines also grew a horizontal
scrollbar inside card previews.

- New --code-bg surface (one step below the page in light mode, page-level
  ink in dark) with a subtle border, and code keeps the surrounding text
  size. Decorative backtick pseudo-elements dropped now that the chip
  carries the signal.
- Card previews clip wide code instead of scrolling; editor and share page
  keep horizontal scroll.
- Syntax highlighting via one shared lowlight instance feeding both the
  TipTap editor (extension-code-block-lowlight) and the markdown-it
  renderer, so a fence looks identical in the editor, on cards and on the
  share page. Only highlight.js "common" grammars are bundled.
- No auto-detection: highlight.js misreads note-sized snippets too often
  (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the
  language comes from the fence info string or a toolbar picker shown
  while the cursor is in a code block.
- Six --hl-* token colours per theme; a unit test parses app.css and
  asserts AA contrast against --code-bg and mutual distinguishability.
- Sanitizer now allows only language-* / hljs-* classes on code and span.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-17 21:42:35 +02:00
Frederic CabassutandClaude Fable 5.1 7bf06edd97 perf(e2e): log in once and share the admin session via storage state
Every test in the `app` project submitted the login form through the
authenticatedPage fixture: a page load, an Argon2 verification on the
server, a redirect and a networkidle wait. Over 137 tests that was
roughly half of the median test's 1.8s.

A new `login-state` setup project (tests/e2e/auth.setup.ts) authenticates
once after auth.spec has created the account and saves the session cookie
to data/test-storage-state.json. The `app` project loads it into every
context, so setupAndLogin takes its "session still valid" path and the
fixture is reduced to a page load.

pwa.spec and mcp.spec describe anonymous visitors and opt out with an
empty storageState so their baseline is unchanged.

Local run, 4 workers: summed test time 276s -> 256s, median 1.8s -> 1.6s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:29:46 +02:00
github-actions[bot] af7186675e chore: pin docker-compose to v0.34.2 [skip ci] 2026-09-07 17:43:47 +00:00
github-actions[bot] b0ecc6b74d chore: bump version to 0.34.2 2026-09-07 17:37:47 +00:00
Frederic CabassutandClaude Fable 5.1 521acc718c docs: require all work to happen in a dedicated worktree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:53 +02:00
Frederic CabassutandClaude Fable 5.1 082b8632c6 fix: render empty task list items as checkboxes in note previews
The task-list markdown rule only matched a marker followed by a space
("[ ] text"). markdown-it trims trailing whitespace from inline content,
so an item with no text arrived as exactly "[ ]" and rendered as literal
brackets in the card preview, while the editor showed a checkbox.

Match the marker followed by a space or end-of-content instead, and strip
only what was matched so "[ ]x" glued to text stays literal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:53 +02:00
Frederic CabassutandClaude Fable 5.1 6bdcc3c214 fix(theme): map all prose colour variables to theme tokens for dark mode
The typography plugin gives each prose element its own --tw-prose-*
variable with a hardcoded grey default. Only body, headings, links and
code were remapped to parchment tokens, so bold text (and quotes, kbd,
bullets, counters, hr and table borders) kept their light-mode greys in
dark mode. Bold rendered near-black on the dark surface.

Map every variable to a theme token so [data-theme="dark"] flips them
all, and add an e2e scenario asserting bold text in the card preview and
editor computes to --text in dark mode.

Fixes #86

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:32 +02:00
Frederic Cabassut e8c30c518c revert: pin better-sqlite3 back to v11.10.0, breaks CI native builds
v13.0.3 has no prebuilt binary for the exact platform/Node combo used
by both this project's Docker build stage (node:22-slim) and the
GitHub Actions E2E runner, so it falls back to compiling from source
via node-gyp. Neither environment has python3/make available for that
fallback, so both the Docker Build and E2E Tests CI jobs failed with
"Could not find any Python installation" / "not found: make".

This wasn't fixing a known vulnerability (audit was already clean), so
revert rather than start patching the Docker image and CI workflow's
system packages for a version-currency bump.
2026-09-02 09:34:03 +02:00
Frederic Cabassut 84145de3b1 chore(deps): remove obsolete @types/uuid stub
uuid v14 ships its own type definitions; @types/uuid now publishes as
an empty stub explicitly saying it's no longer needed.
2026-09-02 09:34:03 +02:00
Frederic Cabassut ea66e23178 chore(deps): update better-sqlite3 to v13 (N-API rewrite)
v13 rewrote the native binding on top of N-API for better cross-version
binary compatibility; no application-facing API changes for the query
methods this app uses. Compiled and verified against the full DB test
suite (notes, versions, shares, collaborators, tags, search, api-keys).
Bumped @types/better-sqlite3 to match.
2026-09-02 09:34:03 +02:00
Frederic Cabassut feda983393 chore(deps): update argon2 to v0.45.1
No API changes (build system tweaks, dropped Node 18/20 support - this
project already targets Node 22+). Verified against the password
hash/verify test suite.
2026-09-02 09:34:03 +02:00
Frederic Cabassut bf5bfdd98e chore(deps): update uuid to v14
No code changes needed - the project is already full ESM ("type":
"module") and only uses the ESM v4 import, so v12's CommonJS removal
doesn't apply here.
2026-09-02 09:34:03 +02:00
Frederic Cabassut 12ff6d9c4d chore(deps): update markdown-it to v15 for quadratic-complexity fixes
v15.0.1 fixes quadratic-complexity bugs in the linkify inline rule and
fuzzy-link replacement - relevant here since this app renders notes
with linkify: true. Also drops @types/markdown-it, which v15 makes
redundant by bundling its own TypeScript declarations (conflicting
otherwise), and adapts two call sites to the now-more-precise
attrGet() return type (string | number | null vs the community types'
string | null).
2026-09-02 09:34:03 +02:00
Frederic Cabassut 44963bf684 chore(deps): fix nanoid vulnerability and update dependencies within semver range
- Add a scoped pnpm override forcing postcss's transitive nanoid to
  >=3.3.18 <4, patching GHSA-2v37-7h3g-55p8 (indefinite loop in custom
  generators with size=0). Scoped to postcss's own dependency rather
  than the top-level nanoid so the app's own nanoid@5 usage is
  untouched.
- Update all other dependencies within their existing package.json
  semver ranges (tiptap, markdown-it, nodemailer, svelte-dnd-action,
  zod, sveltejs/kit, svelte, svelte-check, tsx and their @types).

pnpm audit: 1 high -> 0 vulnerabilities.
2026-09-02 09:34:03 +02:00
github-actions[bot] dfaf1c474d chore: pin docker-compose to v0.34.1 [skip ci] 2026-09-01 10:08:28 +00:00
github-actions[bot] 881a85a09e chore: bump version to 0.34.1 2026-09-01 10:01:25 +00:00
Frederic Cabassut 27c3e7dccd fix(editor): render the table-with-list HTML fallback safely instead of escaping it
The table markdown fallback (previous commit) embeds raw HTML in saved
markdown when a cell holds a bullet list, since GFM can't express block
content in a table cell. But the read-only renderer (note-card preview,
public share page) ran with html:false, so that HTML rendered as
escaped literal text instead of a table - moving the bug rather than
fixing it.

Enable html mode and sanitize the rendered output with sanitize-html.
Sanitizing matters beyond this one fallback: note content isn't only
ever written through the editor - the MCP server and notes API accept
a raw markdown string directly, bypassing the editor's schema, so any
HTML this renders has to be treated as untrusted regardless of source.

Also make the table's HTML fallback respect the editor's own `html`
option (falls back to a `[table]` placeholder when disabled), matching
every other node type's markdown storage in tiptap-markdown.

Found via a 3-agent adversarial review of the previous fix.
2026-09-01 12:00:36 +02:00
Frederic Cabassut 64f96b21a3 fix(editor): preserve bullet lists inside table cells on save/reopen
tiptap-markdown's bundled table serializer only rejects cells with
multiple children or spans, not a single non-paragraph block child. A
cell holding just a bullet list slipped through and got fed to
renderInline(), which is only valid for textblocks, corrupting the row
on save. Now such cells fall back to serializing the whole table as
raw HTML, same as tiptap-markdown does for other unsupported layouts.

Fixes #83
2026-09-01 12:00:36 +02:00
github-actions[bot] ff24fb6d5b chore: pin docker-compose to v0.34.0 [skip ci] 2026-08-19 13:22:25 +00:00
github-actions[bot] 7f6ad2dc12 chore: bump version to 0.34.0 2026-08-19 13:15:42 +00:00
Frederic Cabassut e4606cfc70 feat(auth): enable password login on OAuth-only accounts, with disable control
Admins can now reset the password of an OAuth-only user, which creates
their credentials and enables password login; the new 'Disable password
login' row action removes those credentials again. Resetting a password
revokes all existing sessions.

- add users.password_login_enabled column (migration 0005)
- admin reset: set password, enable login, revoke sessions
- disable action: clear password, disable login (only on OAuth-linked rows)
- account endpoint: refuse password updates on OAuth-linked users
- e2e: wipe the test DB before the webServer starts (Playwright starts
  the server before globalSetup, so wiping there orphaned the DB file)
- row-actions strip test: five square actions, wrap on phone widths
2026-08-19 13:18:56 +02:00
github-actions[bot] e315b8c7ec chore: pin docker-compose to v0.33.0 [skip ci] 2026-08-18 10:39:09 +00:00
42 changed files with 3972 additions and 1048 deletions
+5 -1
View File
@@ -39,6 +39,8 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
| `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout |
| `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity |
| `--code-bg` | `#dfd5c1` (light) / `#141312` (dark) | Code block and inline code surface — one step below the page so it stands out on every note colour, including the default card which is `--bg-surface` itself |
| `--hl-keyword` … `--hl-tag` | six token colours per theme | Syntax highlighting roles (keyword, string, number, comment, title, tag). Must keep AA contrast against `--code-bg`; asserted in `src/lib/utils/highlight.test.ts` |
Note colors for cards are defined in `src/lib/utils/colors.ts`.
@@ -171,7 +173,8 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
- **Main branch**: `main` — use as base for all PRs and feature branches
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
- **Always work in a worktree**: Never edit files in the main checkout. Before touching any file, create a dedicated worktree (`EnterWorktree` or `git worktree add .worktrees/<name> -b <branch>`) on a fresh branch from `origin/main`, do all work, commits, and PR creation from there. This keeps the main checkout clean and lets several tasks run side by side without clobbering each other's uncommitted changes.
- **Feature branches**: Use `feat/<name>` (or `fix/<name>`) branches, one per worktree
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
## CI/CD
@@ -218,6 +221,7 @@ Write tests first or alongside features. Tests serve as living documentation of
- Config: `playwright.config.ts` (Chromium only, builds + previews app on port 4173)
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
- **Shared login state**: `tests/e2e/auth.setup.ts` (project `login-state`) logs in once and saves the admin session cookie to `data/test-storage-state.json`; the `app` project loads it into every context so tests start authenticated instead of submitting the login form each time. A spec that needs an anonymous baseline opts out with `test.use({ storageState: { cookies: [], origins: [] } })` (see `pwa.spec.ts`, `mcp.spec.ts`). Never write a test that revokes or logs out the admin's own session — it would break every test in other workers
- Auth tests use `test.describe.serial` because they depend on sequential database state
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.32.0
image: ghcr.io/bretzel-app/crumbs:0.34.2
container_name: crumbs
ports:
- "3000:3000"
+4 -1
View File
@@ -82,6 +82,9 @@ For a single-user app with multiple devices:
- Built-in dark mode support (`dark:` variants)
- New v4 uses Vite plugin (faster builds)
### lowlight (highlight.js)
Code blocks are highlighted by one shared lowlight instance (`src/lib/utils/highlight.ts`) that feeds both the TipTap editor (`@tiptap/extension-code-block-lowlight`, live decorations) and the markdown-it renderer used for card previews and the share page, so a fence looks the same everywhere. Only the highlight.js "common" grammar set is bundled. Language auto-detection is deliberately disabled: it misreads note-sized snippets too often, so an unlabelled fence renders as plain text and the language comes from the fence info string or the toolbar picker. Token colours are the six `--hl-*` variables in `src/app.css`; a unit test asserts AA contrast against `--code-bg` in both themes.
### Argon2
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
@@ -91,7 +94,7 @@ For a single-user app with multiple devices:
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - User accounts (password hash, OAuth provider link, role)
- `users` - User accounts (password hash, `password_login_enabled`, OAuth provider link, role)
- `sessions` - Auth sessions (30-day TTL)
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
+7 -7
View File
@@ -14,20 +14,20 @@ On first launch, Crumbs presents a setup screen to create the initial admin acco
### 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.
An admin can reset the password of any user other than themselves. 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:
The control appears on every row, but is unavailable in two cases:
- **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.
- **While a generated password is still on screen** — dismiss it first if you need to generate another.
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 enables password login for that account if it was not already enabled — including OAuth-only accounts — without removing their SSO link. Both sign-in methods can coexist.
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 reset **revokes all of the user's sessions** — they must sign in again with the new password (or via OAuth if linked). **Disable password login** is a separate action on rows where password login is enabled; it clears the stored hash, turns off password login, and revokes all sessions. It is not offered on your own row.
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.
> **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. An admin can also enable password login on an OAuth-linked account via **Settings > Users**. With no other admin and no provider configured, there is no way back in through the app.
## OAuth / SSO
@@ -52,7 +52,7 @@ OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
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.
Tick **OAuth-only (no password)** when creating these accounts — the provider is how they will sign in. An admin can later enable password login via **Settings > Users** if the IdP becomes unavailable. If you set a password at invite time, it keeps working after the user's first OAuth sign-in — both methods coexist independently.
### Environment variables
+4 -3
View File
@@ -23,7 +23,7 @@
- Blockquotes (`> quote`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Code blocks (triple backtick) with syntax highlighting for 30+ languages — set the language in the fence (```ts) or from the toolbar picker while the cursor is inside the block; unlabelled blocks stay plain rather than guessing
- Inline code (single backtick)
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
@@ -143,9 +143,10 @@
- Admin dashboard to create, list, and delete users
- Assign roles (admin / 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
- Works for any account other than your own, including OAuth-only — the reset deliberately enables password login and revokes all sessions
- **Disable password login** turns off password auth, clears the hash, and revokes sessions (not offered on your own row)
- 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
- Use **Revoke all sessions** separately if you need to force a logout without changing credentials
- Revoke all sessions (force logout) for any user other than yourself
### PWA / Offline-First
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `users` ADD `password_login_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint
UPDATE `users` SET `password_login_enabled` = 1 WHERE `auth_provider` = 'password';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1785824271578,
"tag": "0004_violet_nicolaos",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1787064677945,
"tag": "0005_green_raider",
"breakpoints": true
}
]
}
+30 -27
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.33.0",
"version": "0.35.0",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
@@ -36,55 +36,57 @@
"dependencies": {
"@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",
"@tiptap/core": "^3.31.0",
"@tiptap/extension-code-block-lowlight": "^3.31.0",
"@tiptap/extension-link": "^3.31.0",
"@tiptap/extension-placeholder": "^3.31.0",
"@tiptap/extension-table": "^3.31.0",
"@tiptap/extension-table-cell": "^3.31.0",
"@tiptap/extension-table-header": "^3.31.0",
"@tiptap/extension-table-row": "^3.31.0",
"@tiptap/extension-task-item": "^3.31.0",
"@tiptap/extension-task-list": "^3.31.0",
"@tiptap/extension-text-align": "^3.31.0",
"@tiptap/extension-underline": "^3.31.0",
"@tiptap/pm": "^3.31.0",
"@tiptap/starter-kit": "^3.31.0",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"argon2": "^0.45.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.45.2",
"idb": "^8.0.3",
"lowlight": "^3.3.0",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.3.0",
"markdown-it": "^15.0.1",
"nanoid": "^5.1.16",
"node-diff3": "^3.2.1",
"nodemailer": "^9.0.3",
"svelte-dnd-action": "^0.9.77",
"nodemailer": "^9.1.0",
"sanitize-html": "^2.17.7",
"svelte-dnd-action": "^0.9.79",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.1",
"zod": "^4.4.3",
"uuid": "^14.0.2",
"zod": "^4.5.4",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^8.0.1",
"@types/uuid": "^10.0.0",
"@types/sanitize-html": "^2.16.1",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.7",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"nyc": "^18.0.0",
"svelte": "^5.56.8",
"svelte-check": "^4.7.4",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.5",
"tsx": "^4.23.13",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-plugin-istanbul": "^7.2.1",
@@ -106,7 +108,8 @@
"hono": ">=4.12.7",
"devalue": ">=5.8.1",
"path-to-regexp@8": ">=8.4.0",
"fast-uri": ">=3.1.2"
"fast-uri": ">=3.1.2",
"postcss>nanoid": ">=3.3.18 <4"
}
}
}
+21 -3
View File
@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE_FILE } from './tests/e2e/global-setup';
export default defineConfig({
testDir: './tests/e2e',
@@ -22,14 +23,31 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: 'auth.spec.ts',
// Logs in once and saves the session cookie for the `app` project.
name: 'login-state',
testMatch: 'auth.setup.ts',
dependencies: ['auth-setup'],
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: ['auth.spec.ts', 'auth.setup.ts'],
dependencies: ['login-state'],
// Every context starts with the admin already logged in. Specs that
// need an unauthenticated baseline opt out with
// `test.use({ storageState: { cookies: [], origins: [] } })`.
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_FILE }
}
],
webServer: {
command: 'pnpm preview --port 4173',
// The wipe runs here (before the server opens the database), not in
// globalSetup: Playwright starts the webServer before globalSetup, so
// deleting the SQLite files later would orphan the server's open
// database onto a deleted inode. Tests that read the database file
// directly (see linkUserToOAuth in admin-users.spec.ts) must see the
// server's real database file.
command:
'rm -f data/test-crumbs.db data/test-crumbs.db-wal data/test-crumbs.db-shm data/test-crumbs.db-journal && pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
+1043 -794
View File
File diff suppressed because it is too large Load Diff
+132 -3
View File
@@ -26,6 +26,17 @@
light styling is byte-identical; dark mode keeps the cream that --border
used to supply, because a mid-grey wash is invisible on a dark surface. */
--hover-wash: var(--border);
/* Code surface: an inset one step below the page so it reads as a panel on
every note colour (the default card is --bg-surface itself, so neither
surface nor base would stand out on it). Token colours must keep AA
contrast against it — see src/lib/utils/highlight.test.ts. */
--code-bg: #dfd5c1;
--hl-keyword: #7d5306;
--hl-string: #325d28;
--hl-number: #1f5c7a;
--hl-comment: #5d5462;
--hl-title: #6a2c7a;
--hl-tag: #8b2e1f;
}
[data-theme="dark"] {
@@ -49,6 +60,15 @@
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
--hover-wash: #ece3d3;
/* Dark code sinks to page level (the ink behind the cards) rather than
lifting, so a snippet on a dark card reads as a cut-out, not a slab. */
--code-bg: #141312;
--hl-keyword: #e0a030;
--hl-string: #8fc97a;
--hl-number: #7fb8e0;
--hl-comment: #a89b8c;
--hl-title: #cfa3e6;
--hl-tag: #e2705c;
}
html, body {
@@ -88,20 +108,129 @@ body::before {
to { transform: translateY(0); }
}
/* Prose overrides for parchment theme */
/* Prose overrides for parchment theme.
The typography plugin gives every element type its own --tw-prose-* variable,
each defaulting to a fixed grey. Anything not remapped here stays that grey in
dark mode (bold text was near-black on ink — see issue #86), so map them all. */
.prose {
--tw-prose-body: var(--text);
--tw-prose-headings: var(--text);
--tw-prose-lead: var(--text-muted);
--tw-prose-links: var(--primary);
--tw-prose-bold: var(--text);
--tw-prose-counters: var(--text-muted);
--tw-prose-bullets: var(--text-muted);
--tw-prose-hr: var(--border-subtle);
--tw-prose-quotes: var(--text);
--tw-prose-quote-borders: var(--border-subtle);
--tw-prose-captions: var(--text-muted);
--tw-prose-kbd: var(--text);
--tw-prose-kbd-shadows: color-mix(in oklab, var(--text) 10%, transparent);
--tw-prose-code: var(--text);
--tw-prose-pre-bg: var(--bg-surface);
--tw-prose-pre-code: var(--text);
--tw-prose-pre-bg: var(--code-bg);
--tw-prose-th-borders: var(--border-subtle);
--tw-prose-td-borders: var(--border-subtle);
}
/* Code — the body font is already monospace, so the typography plugin's
"shrink code to 86%" default only makes snippets harder to read. Code keeps
the surrounding text size and gets a distinct surface instead. */
.prose :where(code):not(:where([class~="not-prose"] *)) {
background-color: var(--bg-surface);
background-color: var(--code-bg);
border-radius: 2px;
padding: 0.125rem 0.25rem;
font-size: inherit;
}
/* The chip is the signal now; the plugin's decorative backticks doubled it. */
.prose :where(code):not(:where([class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"] *))::after {
content: none;
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
border: 1px solid var(--border-subtle);
border-radius: 2px;
font-size: inherit;
line-height: 1.6;
padding: 0.625rem 0.75rem;
tab-size: 4;
}
.prose :where(pre code):not(:where([class~="not-prose"] *)) {
background-color: transparent;
border-radius: 0;
padding: 0;
}
/* Card previews are a glance, not a workspace: clip long lines instead of
growing a scrollbar inside the card. The editor and share page keep the
typography default of scrolling horizontally. */
.card-preview :where(pre) {
overflow: hidden;
/* line-clamp is a legacy -webkit-box; without this a wide <pre> takes its
content width and runs out past the card edge instead of being clipped. */
max-width: 100%;
}
/* Syntax highlighting (highlight.js classes via lowlight). Six roles are
enough to tell structure apart without turning a snippet into confetti;
the colours live in --hl-* above so the dark theme can swap them. */
.hljs-keyword,
.hljs-selector-tag,
.hljs-built_in,
.hljs-type,
.hljs-literal {
color: var(--hl-keyword);
}
.hljs-string,
.hljs-regexp,
.hljs-quote,
.hljs-addition {
color: var(--hl-string);
}
.hljs-number,
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-attr,
.hljs-attribute,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: var(--hl-number);
}
.hljs-comment,
.hljs-meta,
.hljs-doctag {
color: var(--hl-comment);
font-style: italic;
}
.hljs-title,
.hljs-section,
.hljs-selector-id,
.hljs-selector-class {
color: var(--hl-title);
}
.hljs-tag,
.hljs-name,
.hljs-deletion {
color: var(--hl-tag);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 600;
}
.prose :where(a):not(:where([class~="not-prose"] *)) {
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { CODE_LANGUAGES } from '$lib/utils/highlight.js';
import {
Undo2,
Redo2,
@@ -47,6 +48,18 @@
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)) || {}; }
// The fence info string may carry an alias (```js) or a language the picker
// doesn't list; surface it as-is rather than silently showing "Plain text".
function codeBlockLanguage(): string {
const lang = getAttrs('codeBlock').language;
return typeof lang === 'string' ? lang : '';
}
function setCodeBlockLanguage(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
editor?.chain().focus().updateAttributes('codeBlock', { language: value || null }).run();
}
let openDropdown: string | null = $state(null);
let dropdownAnchorEl: HTMLElement | null = $state(null);
let linkUrl: string = $state('');
@@ -378,6 +391,25 @@
>
<CodeXml size={iconSize} />
</button>
{#if isActive('codeBlock')}
{@const currentLanguage = codeBlockLanguage()}
<select
value={currentLanguage}
onchange={setCodeBlockLanguage}
class="shrink-0 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-1 text-xs text-[var(--text)] outline-none focus:border-[var(--primary)]"
title="Code block language"
aria-label="Code block language"
data-testid="format-code-language"
>
<option value="">Plain text</option>
{#if currentLanguage && !CODE_LANGUAGES.some((l) => l.id === currentLanguage)}
<option value={currentLanguage}>{currentLanguage}</option>
{/if}
{#each CODE_LANGUAGES as lang (lang.id)}
<option value={lang.id}>{lang.label}</option>
{/each}
</select>
{/if}
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
+1 -1
View File
@@ -154,7 +154,7 @@
{/if}
</ul>
{:else if note.content}
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
<div class="card-preview prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
+5 -2
View File
@@ -6,14 +6,16 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import { Table } from './tiptap/Table.js';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { NoteLink } from './tiptap/NoteLink.js';
import { editorLowlight } from '$lib/utils/highlight.js';
import { getAllNotes } from '$lib/sync/idb.js';
interface Props {
@@ -92,7 +94,8 @@
editorInstance = new Editor({
element: element!,
extensions: [
StarterKit.configure({ link: false, underline: false }),
StarterKit.configure({ link: false, underline: false, codeBlock: false }),
CodeBlockLowlight.configure({ lowlight: editorLowlight }),
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
+164
View File
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { Editor, type JSONContent } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import { Markdown } from 'tiptap-markdown';
import { Table } from './Table.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
function createEditor(content: string | object, markdownOptions?: { html?: boolean }) {
return new Editor({
extensions: [
StarterKit,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown.configure(markdownOptions ?? {})
],
content
});
}
function getMarkdown(editor: Editor): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (editor.storage as Record<string, any>).markdown.getMarkdown();
}
function docWithListInCell(): JSONContent {
return {
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }]
},
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }]
}
]
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
content: [
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] },
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] }
]
}
]
},
{
type: 'tableCell',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }]
}
]
}
]
}
]
};
}
// crumbs#83: a bulleted list inside a table cell survives in the live editor
// (never touches markdown) but breaks once the note is saved and reopened,
// because saving/reopening round-trips the doc through markdown text.
describe('Table markdown round-trip with a bullet list in a cell', () => {
it('preserves a bullet list inside a table cell after saving and reopening', () => {
const editor = createEditor(docWithListInCell());
// "Save": serialize the crumb to markdown, the way onUpdate() does.
const savedMarkdown = getMarkdown(editor);
// "Reopen": load that markdown into a fresh editor, the way opening the
// crumb again does (content is passed in as a markdown string).
const reopened = createEditor(savedMarkdown);
const json = reopened.getJSON() as JSONContent;
const table = json.content?.find((node) => node.type === 'table');
expect(table).toBeDefined();
const rows = table!.content!;
expect(rows).toHaveLength(2);
const [headerRow, bodyRow] = rows;
expect(headerRow.content!.map((cell) => cell.content?.[0]?.content?.[0]?.text)).toEqual(['Header A', 'Header B']);
const [listCell, plainCell] = bodyRow.content!;
expect(listCell.content?.[0]?.type).toBe('bulletList');
const items = listCell.content?.[0]?.content ?? [];
expect(items.map((item) => item.content?.[0]?.content?.[0]?.text)).toEqual(['First', 'Second']);
expect(plainCell.content?.[0]?.content?.[0]?.text).toBe('Plain cell');
});
it('still renders as a real table (not escaped HTML) on the read-only preview/share path', () => {
const editor = createEditor(docWithListInCell());
const savedMarkdown = getMarkdown(editor);
const html = renderMarkdown(savedMarkdown);
expect(html).toContain('<table>');
expect(html).toContain('<ul');
expect(html).toContain('First');
expect(html).toContain('Second');
expect(html).not.toContain('&lt;table&gt;');
});
it('still serializes a table with only plain-text cells as GFM markdown, not HTML', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] }
]
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] },
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2' }] }] }
]
}
]
}
]
});
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('| A | B |');
expect(savedMarkdown).toContain('| 1 | 2 |');
});
it('falls back to a placeholder instead of raw HTML when the editor has html mode disabled', () => {
const editor = createEditor(docWithListInCell(), { html: false });
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('[table]');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { elementFromString, getHTMLFromFragment } from '@tiptap/core';
import { Table as BaseTable } from '@tiptap/extension-table';
import { Fragment, type Node as ProseMirrorNode } from '@tiptap/pm/model';
interface MarkdownSerializerState {
write(text: string): void;
ensureNewLine(): void;
closeBlock(node: ProseMirrorNode): void;
renderInline(node: ProseMirrorNode): void;
inTable: boolean;
}
interface SerializeContext {
editor: { storage: { markdown: { options: { html: boolean } } } };
}
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
const nodes: ProseMirrorNode[] = [];
node.forEach((child) => nodes.push(child));
return nodes;
}
function hasSpan(cell: ProseMirrorNode): boolean {
return cell.attrs.colspan > 1 || cell.attrs.rowspan > 1;
}
/**
* A cell can only be squeezed onto a single `| cell |` markdown line via
* `state.renderInline()` if its content is itself a textblock (a paragraph,
* whose children are inline text runs). A block child instead - e.g. a
* bullet list - has to be rejected here, or renderInline() misreads its
* block-level children as inline ones and corrupts the row (crumbs#83).
*/
function hasOnlyInlineContent(cell: ProseMirrorNode): boolean {
const content = cell.firstChild;
return content === null || content.type.isTextblock;
}
function isMarkdownSerializable(node: ProseMirrorNode): boolean {
const rows = childNodes(node);
const firstRow = rows[0];
const bodyRows = rows.slice(1);
if (
childNodes(firstRow).some(
(cell) => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
) {
return false;
}
return !bodyRows.some((row) =>
childNodes(row).some(
(cell) => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
);
}
/** Mirrors tiptap-markdown's internal HTML-block fallback (its HTMLNode spec isn't exported). */
function formatBlock(html: string): string {
const element = elementFromString(html).firstElementChild!;
element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n';
return element.outerHTML;
}
/**
* Same as tiptap-markdown's HTMLNode fallback: when the editor's `html`
* option is off, other node types degrade to a `[nodename]` placeholder
* instead of emitting HTML. A table with non-inline cell content has to
* follow the same rule, or it'd be the only node type still leaking raw HTML
* with html mode disabled.
*/
function serializeAsHTML(
context: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!context.editor.storage.markdown.options.html) {
state.write(`[${node.type.name}]`);
state.closeBlock(node);
return;
}
const schema = node.type.schema;
const html = getHTMLFromFragment(Fragment.from(node), schema);
const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name;
state.write(isTopLevel ? formatBlock(html) : html);
state.closeBlock(node);
}
/**
* Overrides @tiptap/extension-table's markdown storage (tiptap-markdown ships
* a default for it, but only guards against multi-child/spanning cells, not
* a single block child - see hasOnlyInlineContent above).
*/
export const Table = BaseTable.extend({
addStorage() {
return {
markdown: {
serialize(
this: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!isMarkdownSerializable(node)) {
serializeAsHTML(this, state, node, parent);
return;
}
state.inTable = true;
node.forEach((row, _rowOffset, rowIndex) => {
state.write('| ');
row.forEach((cell, _cellOffset, cellIndex) => {
if (cellIndex) {
state.write(' | ');
}
const cellContent = cell.firstChild;
if (cellContent && cellContent.textContent.trim()) {
state.renderInline(cellContent);
}
});
state.write(' |');
state.ensureNewLine();
if (!rowIndex) {
const delimiterRow = Array.from({ length: row.childCount }, () => '---').join(' | ');
state.write(`| ${delimiterRow} |`);
state.ensureNewLine();
}
});
state.closeBlock(node);
state.inTable = false;
},
parse: {
// handled by markdown-it, same as tiptap-markdown's default table spec
}
}
};
}
});
+119
View File
@@ -6,6 +6,13 @@ import * as argon2 from 'argon2';
import { randomBytes, randomUUID } from 'crypto';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
import {
verifyPassword,
resetPassword,
findOrLinkOAuthUser,
createUser,
disablePasswordLogin
} from './auth.js';
let db: BetterSQLite3Database<typeof schema>;
@@ -125,3 +132,115 @@ describe('Auth - Sessions', () => {
expect(session).toBeUndefined();
});
});
describe('Auth - password login enabled', () => {
it('verifyPassword succeeds when password login is enabled', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'pw@test.com',
displayName: 'PW User',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
});
const user = await verifyPassword('pw@test.com', password, db);
expect(user?.email).toBe('pw@test.com');
});
it('verifyPassword rejects when password login is disabled even with a hash', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'oauth@test.com',
displayName: 'OAuth User',
passwordHash: hash,
passwordLoginEnabled: false,
authProvider: 'google',
providerId: 'gid-1',
createdAt: new Date()
});
expect(await verifyPassword('oauth@test.com', password, db)).toBeNull();
});
it('resetPassword enables password login on an OAuth-only account', async () => {
const [row] = await db
.insert(users)
.values({
email: 'enable@test.com',
displayName: 'Enable',
passwordLoginEnabled: false,
authProvider: 'none',
createdAt: new Date()
})
.returning();
const newPassword = randomUUID();
await resetPassword(row.id, newPassword, db);
const updated = await db.select().from(users).where(eq(users.id, row.id)).get();
expect(updated?.passwordLoginEnabled).toBe(true);
expect(await argon2.verify(updated!.passwordHash!, newPassword)).toBe(true);
expect(await verifyPassword('enable@test.com', newPassword, db)).not.toBeNull();
});
it('findOrLinkOAuthUser preserves password login when linking SSO', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'both@test.com',
displayName: 'Both',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
});
const linked = findOrLinkOAuthUser('google', 'google-id-1', 'both@test.com', 'Both', db);
expect(linked?.authProvider).toBe('google');
expect(linked?.passwordLoginEnabled).toBe(true);
expect(await verifyPassword('both@test.com', password, db)).not.toBeNull();
});
it('createUser sets passwordLoginEnabled from whether a password was given', async () => {
const withPassword = await createUser('a@test.com', 'A', 'password123', 'user', db);
expect(withPassword.passwordLoginEnabled).toBe(true);
const oauthOnly = await createUser('b@test.com', 'B', null, 'user', db);
expect(oauthOnly.passwordLoginEnabled).toBe(false);
});
it('disablePasswordLogin clears the hash, disables login, and removes sessions', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
const [row] = await db
.insert(users)
.values({
email: 'disable@test.com',
displayName: 'Disable',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
})
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: row.id,
expiresAt: new Date(Date.now() + 86400000)
});
disablePasswordLogin(row.id, db);
const updated = await db.select().from(users).where(eq(users.id, row.id)).get();
expect(updated?.passwordLoginEnabled).toBe(false);
expect(updated?.passwordHash).toBeNull();
expect(await verifyPassword('disable@test.com', password, db)).toBeNull();
expect(await db.select().from(sessions).where(eq(sessions.userId, row.id)).all()).toHaveLength(0);
});
});
+43 -17
View File
@@ -1,4 +1,4 @@
import { db, sqlite } from './db/index.js';
import { db, sqlite, type Db } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq, lt, and } from 'drizzle-orm';
import * as argon2 from 'argon2';
@@ -16,6 +16,7 @@ function toUser(row: typeof users.$inferSelect): User {
displayName: row.displayName,
role: row.role,
authProvider: row.authProvider,
passwordLoginEnabled: row.passwordLoginEnabled,
providerId: row.providerId,
createdAt: row.createdAt
};
@@ -29,19 +30,21 @@ export async function isSetupComplete(): Promise<boolean> {
export async function setupUser(
email: string,
password: string,
displayName?: string
displayName?: string,
dbInstance: Db = db
): Promise<User | null> {
const existing = db.select().from(users).get();
const existing = dbInstance.select().from(users).get();
if (existing) return null; // Already set up
const hash = await argon2.hash(password);
const result = db
const result = dbInstance
.insert(users)
.values({
email,
displayName: displayName || email.split('@')[0] || 'Admin',
role: 'admin',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
})
@@ -52,12 +55,13 @@ export async function setupUser(
export async function verifyPassword(
email: string,
password: string
password: string,
dbInstance: Db = db
): Promise<User | null> {
const user = db
const user = dbInstance
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.authProvider, 'password')))
.where(and(eq(users.email, email), eq(users.passwordLoginEnabled, true)))
.get();
if (!user || !user.passwordHash) return null;
@@ -195,16 +199,18 @@ export async function createUser(
email: string,
displayName: string,
password: string | null,
role: 'admin' | 'user' = 'user'
role: 'admin' | 'user' = 'user',
dbInstance: Db = db
): Promise<User> {
const hash = password ? await argon2.hash(password) : null;
const result = db
const result = dbInstance
.insert(users)
.values({
email,
displayName,
role,
passwordHash: hash,
passwordLoginEnabled: !!password,
authProvider: password ? 'password' : 'none',
createdAt: new Date()
})
@@ -282,7 +288,7 @@ export async function changePassword(
newPassword: string
): Promise<boolean> {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user || !user.passwordHash) return false;
if (!user || !user.passwordHash || !user.passwordLoginEnabled) return false;
const valid = await argon2.verify(user.passwordHash, currentPassword);
if (!valid) return false;
@@ -292,9 +298,26 @@ export async function changePassword(
return true;
}
export async function resetPassword(userId: number, newPassword: string): Promise<void> {
export async function resetPassword(
userId: number,
newPassword: string,
dbInstance: Db = db
): Promise<void> {
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
dbInstance
.update(users)
.set({ passwordHash: hash, passwordLoginEnabled: true })
.where(eq(users.id, userId))
.run();
}
export function disablePasswordLogin(userId: number, dbInstance: Db = db): void {
dbInstance
.update(users)
.set({ passwordHash: null, passwordLoginEnabled: false })
.where(eq(users.id, userId))
.run();
dbInstance.delete(sessions).where(eq(sessions.userId, userId)).run();
}
export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
@@ -303,15 +326,17 @@ export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
/**
* Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user.
* Linking OAuth updates the provider fields only — password login stays as configured.
*/
export function findOrLinkOAuthUser(
provider: string,
providerId: string,
email: string,
name: string
name: string,
dbInstance: Db = db
): User | null {
// First try exact provider+providerId match
const byProvider = db
const byProvider = dbInstance
.select()
.from(users)
.where(and(eq(users.authProvider, provider), eq(users.providerId, providerId)))
@@ -319,11 +344,12 @@ export function findOrLinkOAuthUser(
if (byProvider) return toUser(byProvider);
// Try matching by email (invite-only: user must already exist)
const byEmail = db.select().from(users).where(eq(users.email, email)).get();
const byEmail = dbInstance.select().from(users).where(eq(users.email, email)).get();
if (!byEmail) return null; // No matching user — not invited
// Link this OAuth provider to the existing user
db.update(users)
// Link this OAuth provider to the existing user without touching password login.
dbInstance
.update(users)
.set({ authProvider: provider, providerId, displayName: byEmail.displayName || name })
.where(eq(users.id, byEmail.id))
.run();
+18 -2
View File
@@ -28,6 +28,7 @@ sqlite.exec(`
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
@@ -269,17 +270,32 @@ if (pwCol && pwCol.notnull) {
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
INSERT INTO __new_users(id, email, display_name, role, password_hash, password_login_enabled, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash,
CASE WHEN auth_provider = 'password' THEN 1 ELSE 0 END,
auth_provider, provider_id, created_at FROM users;
DROP TABLE users;
ALTER TABLE __new_users RENAME TO users;
`);
}
// Migration: add password_login_enabled (independent of OAuth provider)
const userColumnsAfterPw = sqlite
.prepare("PRAGMA table_info('users')")
.all() as { name: string }[];
if (!userColumnsAfterPw.some((col) => col.name === 'password_login_enabled')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN password_login_enabled INTEGER NOT NULL DEFAULT 0;`);
// Backfill: only accounts whose sign-in method was password keep it enabled.
// OAuth-linked rows may carry a legacy hash; leaving them disabled avoids
// silently widening access — an admin reset is the deliberate enable act.
sqlite.exec(`UPDATE users SET password_login_enabled = 1 WHERE auth_provider = 'password';`);
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
+3
View File
@@ -6,6 +6,9 @@ export const users = sqliteTable('users', {
displayName: text('display_name').notNull().default(''),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
passwordHash: text('password_hash'),
passwordLoginEnabled: integer('password_login_enabled', { mode: 'boolean' })
.notNull()
.default(false),
authProvider: text('auth_provider').notNull().default('password'),
providerId: text('provider_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
+2
View File
@@ -19,6 +19,7 @@ export function createTestDb(options?: { seedUser?: boolean }) {
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
@@ -173,6 +174,7 @@ export function createTestDb(options?: { seedUser?: boolean }) {
displayName: 'Test User',
role: 'admin',
authProvider: 'password',
passwordLoginEnabled: true,
createdAt: new Date()
}).run();
}
+1
View File
@@ -75,6 +75,7 @@ export interface User {
displayName: string;
role: 'admin' | 'user';
authProvider: string;
passwordLoginEnabled: boolean;
providerId: string | null;
createdAt: Date;
}
+77 -73
View File
@@ -1,91 +1,95 @@
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'];
import {
canResetPassword,
passwordResetBlockedReason,
requiresPasswordForAccountDeletion,
canDisablePasswordLogin
} from './auth-methods.js';
describe('canResetPassword', () => {
it('allows a reset for a password account', () => {
expect(canResetPassword({ authProvider: 'password' })).toBe(true);
expect(canResetPassword({ authProvider: 'password', passwordLoginEnabled: true })).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('allows a reset for an OAuth-only account', () => {
expect(canResetPassword({ authProvider: 'none', passwordLoginEnabled: false })).toBe(true);
});
it('blocks a reset for an unenumerated provider', () => {
expect(canResetPassword({ authProvider: 'saml' })).toBe(false);
expect(canResetPassword({ authProvider: '' })).toBe(false);
it('allows a reset for an OAuth-linked account', () => {
expect(canResetPassword({ authProvider: 'google', passwordLoginEnabled: true })).toBe(true);
});
});
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('');
it('returns null for every account type', () => {
for (const user of [
{ authProvider: 'password', passwordLoginEnabled: true },
{ authProvider: 'none', passwordLoginEnabled: false },
{ authProvider: 'google', passwordLoginEnabled: true },
{ authProvider: 'oidc', passwordLoginEnabled: false }
]) {
expect(passwordResetBlockedReason(user)).toBeNull();
}
});
});
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');
describe('requiresPasswordForAccountDeletion', () => {
it('requires password for a pure password account', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'password', passwordLoginEnabled: true })
).toBe(true);
});
it('requires password for OAuth-only with admin-enabled password login', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'none', passwordLoginEnabled: true })
).toBe(true);
});
it('does not require password for OAuth-linked accounts even when password login is enabled', () => {
for (const authProvider of ['google', 'github', 'oidc']) {
expect(
requiresPasswordForAccountDeletion({ authProvider, passwordLoginEnabled: true }),
authProvider
).toBe(false);
}
});
it('does not require password for OAuth-only without password login', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'none', passwordLoginEnabled: false })
).toBe(false);
});
});
describe('canDisablePasswordLogin', () => {
it('allows disabling for an OAuth-linked user with password login enabled', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'google' }, 1)
).toBe(true);
});
it('blocks disabling on the admin own row', () => {
expect(
canDisablePasswordLogin({ id: 1, passwordLoginEnabled: true, authProvider: 'google' }, 1)
).toBe(false);
});
it('blocks disabling when password login is already off', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: false, authProvider: 'google' }, 1)
).toBe(false);
});
it('blocks disabling on a password-only account', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'password' }, 1)
).toBe(false);
});
it('blocks disabling on OAuth-only with admin-enabled password but no provider link', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'none' }, 1)
).toBe(false);
});
});
+38 -31
View File
@@ -1,44 +1,51 @@
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.
* Password login is gated on `passwordLoginEnabled`, which resetPassword() sets
* deliberately — so an admin can enable password auth on any account, including
* OAuth-only ones, without clearing their SSO link.
*/
export function canResetPassword(user: Pick<User, 'authProvider'>): boolean {
return user.authProvider === 'password';
export function canResetPassword(_user: Pick<User, 'passwordLoginEnabled' | 'authProvider'>): boolean {
return true;
}
/**
* 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.
* Eligibility is checked in the UI for the signed-in admin's own row; every
* other account may receive a reset that enables password login.
*/
export function passwordResetBlockedReason(user: Pick<User, 'authProvider'>): string | null {
if (canResetPassword(user)) return null;
return BLOCKED_REASONS.get(user.authProvider) ?? BLOCKED_REASON_FALLBACK;
export function passwordResetBlockedReason(
_user: Pick<User, 'passwordLoginEnabled' | 'authProvider'>
): string | null {
return null;
}
/**
* Whether self-service account deletion must verify the user's password.
*
* OAuth-linked accounts may delete with their active SSO session alone, even
* when an admin has enabled password login. Pure password accounts, and
* OAuth-only rows with password login enabled but no provider link, still
* require the password.
*/
export function requiresPasswordForAccountDeletion(
user: Pick<User, 'authProvider' | 'passwordLoginEnabled'>
): boolean {
if (user.authProvider === 'password') return true;
if (user.authProvider === 'none' && user.passwordLoginEnabled) return true;
return false;
}
/** Whether an admin may turn off password login for this account. */
export function canDisablePasswordLogin(
user: Pick<User, 'id' | 'passwordLoginEnabled' | 'authProvider'>,
adminId: number
): boolean {
if (user.id === adminId || !user.passwordLoginEnabled) return false;
// Only when another sign-in method exists — disabling on a password-only row
// would lock the user out with no OAuth fallback.
return user.authProvider !== 'password' && user.authProvider !== 'none';
}
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CODE_LANGUAGES, editorLowlight, highlightCode, isKnownLanguage, lowlight } from './highlight.js';
describe('highlightCode', () => {
it('wraps tokens in hljs-* spans for a known language', () => {
const html = highlightCode('const x = 1;', 'javascript');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('accepts highlight.js aliases the way a fence info string carries them', () => {
expect(highlightCode('let a = 1', 'js')).toContain('hljs-keyword');
expect(highlightCode('print(1)', 'py')).toContain('hljs-built_in');
expect(highlightCode('<div></div>', 'html')).toContain('hljs-tag');
});
it('returns null for an absent or unknown language so the caller falls back to plain text', () => {
expect(highlightCode('SELECT 1', null)).toBeNull();
expect(highlightCode('SELECT 1', '')).toBeNull();
expect(highlightCode('SELECT 1', 'not-a-language')).toBeNull();
});
it('escapes HTML in the source so a snippet cannot inject markup', () => {
const html = highlightCode('const s = "<img src=x onerror=alert(1)>";', 'javascript');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
});
it('keeps highlight.js sub-scope classes alongside the hljs-* class', () => {
const html = highlightCode('function foo() {}', 'javascript');
expect(html).toContain('<span class="hljs-title function_">foo</span>');
});
it('round-trips the source text exactly once tags are stripped', () => {
const source = 'if (a < b && c > d) {\n\treturn "x&y";\n}';
const html = highlightCode(source, 'typescript')!;
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(text).toBe(source);
});
});
describe('CODE_LANGUAGES', () => {
it('lists only languages the shared lowlight instance can actually highlight', () => {
for (const { id } of CODE_LANGUAGES) {
expect(isKnownLanguage(id), id).toBe(true);
}
});
it('has no duplicate ids', () => {
const ids = CODE_LANGUAGES.map((l) => l.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('editorLowlight', () => {
it('never auto-detects: an unlabelled block comes back as a single plain text node', () => {
const result = editorLowlight.highlightAuto('SELECT * FROM users;');
expect(result.children).toEqual([{ type: 'text', value: 'SELECT * FROM users;' }]);
});
it('highlights a labelled block with the same grammars as the renderer', () => {
const viaEditor = editorLowlight.highlight('python', 'def f(): pass');
const viaRenderer = lowlight.highlight('python', 'def f(): pass');
expect(viaEditor.children).toEqual(viaRenderer.children);
});
it('reports the languages the picker offers as registered', () => {
for (const { id } of CODE_LANGUAGES) {
expect(editorLowlight.registered(id), id).toBe(true);
}
expect(editorLowlight.listLanguages()).toContain('javascript');
});
});
// The palette lives in CSS, so the contrast guarantees are checked against the
// stylesheet itself rather than a TypeScript mirror that could drift.
describe('code palette in src/app.css', () => {
const css = readFileSync(resolve(__dirname, '../../app.css'), 'utf8');
const light = readVars(css.slice(css.indexOf(':root {'), css.indexOf('[data-theme="dark"] {')));
const dark = readVars(css.slice(css.indexOf('[data-theme="dark"] {')));
const tokenNames = ['--hl-keyword', '--hl-string', '--hl-number', '--hl-comment', '--hl-title', '--hl-tag'];
it('defines the code surface and every token colour in both themes', () => {
for (const name of ['--code-bg', ...tokenNames]) {
expect(light[name], `${name} light`).toMatch(/^#[0-9a-f]{6}$/i);
expect(dark[name], `${name} dark`).toMatch(/^#[0-9a-f]{6}$/i);
}
});
it('keeps AA contrast (4.5:1) between every token and the code surface', () => {
for (const [theme, vars] of [
['light', light],
['dark', dark]
] as const) {
for (const name of tokenNames) {
const ratio = contrastRatio(vars[name], vars['--code-bg']);
expect(ratio, `${theme} ${name} ${vars[name]} on ${vars['--code-bg']}`).toBeGreaterThanOrEqual(4.5);
}
}
});
it('keeps AA contrast between body text and the code surface', () => {
expect(contrastRatio(light['--text'], light['--code-bg'])).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(dark['--text'], dark['--code-bg'])).toBeGreaterThanOrEqual(4.5);
});
it('keeps token colours distinguishable from each other', () => {
for (const vars of [light, dark]) {
for (let i = 0; i < tokenNames.length; i++) {
for (let j = i + 1; j < tokenNames.length; j++) {
const distance = rgbDistance(vars[tokenNames[i]], vars[tokenNames[j]]);
expect(distance, `${tokenNames[i]} vs ${tokenNames[j]}`).toBeGreaterThanOrEqual(40);
}
}
}
});
});
function readVars(block: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const match of block.matchAll(/(--[\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) {
vars[match[1]] = match[2].toLowerCase();
}
return vars;
}
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
+108
View File
@@ -0,0 +1,108 @@
import { common, createLowlight } from 'lowlight';
/**
* One lowlight instance shared by the TipTap editor (live decorations) and the
* markdown-it renderer (card previews, share page), so a fence is coloured
* identically wherever it shows up.
*
* There is deliberately no language auto-detection. highlight.js guesses
* wrongly on note-sized snippets often enough (JavaScript read as CSS, SQL as
* VB.NET) that a wrong palette is worse than none. An unlabelled fence renders
* as plain text on the code surface; the language comes from the fence info
* string (```ts) or the toolbar picker.
*/
export const lowlight = createLowlight(common);
type HighlightRoot = ReturnType<typeof lowlight.highlight>;
type HighlightNode = HighlightRoot['children'][number];
/** Languages offered by the editor's picker. Aliases (js, py, sh…) still highlight. */
export const CODE_LANGUAGES: readonly { id: string; label: string }[] = [
{ id: 'bash', label: 'Bash' },
{ id: 'c', label: 'C' },
{ id: 'cpp', label: 'C++' },
{ id: 'csharp', label: 'C#' },
{ id: 'css', label: 'CSS' },
{ id: 'diff', label: 'Diff' },
{ id: 'go', label: 'Go' },
{ id: 'graphql', label: 'GraphQL' },
{ id: 'xml', label: 'HTML / XML' },
{ id: 'ini', label: 'INI / TOML' },
{ id: 'java', label: 'Java' },
{ id: 'javascript', label: 'JavaScript' },
{ id: 'json', label: 'JSON' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'less', label: 'Less' },
{ id: 'lua', label: 'Lua' },
{ id: 'makefile', label: 'Makefile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'objectivec', label: 'Objective-C' },
{ id: 'perl', label: 'Perl' },
{ id: 'php', label: 'PHP' },
{ id: 'python', label: 'Python' },
{ id: 'r', label: 'R' },
{ id: 'ruby', label: 'Ruby' },
{ id: 'rust', label: 'Rust' },
{ id: 'scss', label: 'SCSS' },
{ id: 'shell', label: 'Shell session' },
{ id: 'sql', label: 'SQL' },
{ id: 'swift', label: 'Swift' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'vbnet', label: 'VB.NET' },
{ id: 'wasm', label: 'WebAssembly' },
{ id: 'yaml', label: 'YAML' }
];
export function isKnownLanguage(lang: string | null | undefined): lang is string {
return typeof lang === 'string' && lang.length > 0 && lowlight.registered(lang);
}
/**
* Highlighted HTML (spans with `hljs-*` classes, text escaped) for a code
* block, or `null` when the language is absent or unknown so the caller can
* fall back to plain escaped text.
*/
export function highlightCode(code: string, lang: string | null | undefined): string | null {
if (!isKnownLanguage(lang)) return null;
return toHtml(lowlight.highlight(lang, code).children);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// lowlight emits only `element` (span with a className list) and `text`
// nodes, so a full hast serializer would be dead weight here.
function toHtml(nodes: HighlightNode[]): string {
let html = '';
for (const node of nodes) {
if (node.type === 'text') {
html += escapeHtml(node.value);
} else if (node.type === 'element') {
const classes = node.properties?.className;
const classAttr = Array.isArray(classes) && classes.length ? ` class="${classes.join(' ')}"` : '';
html += `<span${classAttr}>${toHtml(node.children as HighlightNode[])}</span>`;
}
}
return html;
}
/**
* What `@tiptap/extension-code-block-lowlight` needs from a lowlight instance,
* with auto-detection stubbed out so an unlabelled block stays plain (see the
* note at the top of this file).
*/
export const editorLowlight = {
highlight: (language: string, value: string) => lowlight.highlight(language, value),
highlightAuto: (value: string): HighlightRoot => ({
type: 'root',
children: [{ type: 'text', value }],
data: { language: undefined, relevance: 0 }
}),
listLanguages: () => lowlight.listLanguages(),
registered: (aliasOrLanguage: string) => lowlight.registered(aliasOrLanguage)
};
+86
View File
@@ -35,6 +35,40 @@ describe('renderMarkdown', () => {
expect(html).toContain('const x = 1;');
});
it('should syntax-highlight a fenced block that names its language', () => {
const html = renderMarkdown('```ts\nconst x: number = 1;\n```');
expect(html).toContain('<code class="language-ts">');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('should leave an unlabelled fence as plain text rather than guessing a language', () => {
const html = renderMarkdown('```\nSELECT * FROM users;\n```');
expect(html).toContain('<pre><code>SELECT * FROM users;');
expect(html).not.toContain('hljs-');
});
it('should leave a fence with an unknown language as plain text but keep its label', () => {
const html = renderMarkdown('```brainfuck\n+++\n```');
expect(html).toContain('<code class="language-brainfuck">+++');
expect(html).not.toContain('hljs-');
});
it('should keep code in a highlighted fence escaped', () => {
const html = renderMarkdown('```html\n<script>alert(1)</script>\n```');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;<span class="hljs-name">script</span>&gt;');
});
it('should strip classes other than highlighting classes from raw HTML spans and code', () => {
const html = renderMarkdown(
'<p><span class="hljs-keyword">kept</span> <span class="evil">dropped</span> <code class="language-js other">c</code></p>'
);
expect(html).toContain('<span class="hljs-keyword">kept</span>');
expect(html).toContain('<span>dropped</span>');
expect(html).toContain('<code class="language-js">c</code>');
});
it('should render inline code', () => {
const html = renderMarkdown('Use `npm install`');
expect(html).toContain('<code>npm install</code>');
@@ -62,6 +96,58 @@ describe('renderMarkdown', () => {
const html = renderMarkdown('line 1\nline 2');
expect(html).toContain('<br>');
});
it('should render a raw HTML table block (the table-with-a-list-in-a-cell fallback) instead of escaping it', () => {
// crumbs#83: a bulleted list inside a table cell can't be expressed in
// plain GFM, so the editor falls back to embedding the whole table as
// raw HTML in the saved markdown. The preview/share renderer must
// actually render that block, not show it as literal escaped text.
const html = renderMarkdown(
'<table><tbody><tr><td><ul><li>First</li><li>Second</li></ul></td><td>Plain cell</td></tr></tbody></table>'
);
expect(html).toContain('<table>');
expect(html).toContain('<li>First</li>');
expect(html).toContain('<li>Second</li>');
expect(html).not.toContain('&lt;table&gt;');
});
it('should strip a script tag from raw HTML content', () => {
// Note content isn't only ever written through the Tiptap editor - the
// MCP server and the notes API accept a raw markdown string directly,
// so this renderer can't trust that any HTML it's asked to render is
// safe just because rendering HTML at all is now allowed.
const html = renderMarkdown('<p>hello</p><script>alert(1)</script><img src=x onerror="alert(2)">');
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).toContain('hello');
});
it('should still render task-list checkboxes after sanitization', () => {
const html = renderMarkdown('[ ] todo\n\n[x] done');
expect(html).toContain('type="checkbox"');
expect(html).toContain('checked');
expect(html).toContain('disabled');
expect(html).toContain('class="task-checkbox"');
});
it('should render an empty task item as a checkbox, not literal brackets', () => {
const html = renderMarkdown('- [ ] Train tickets\n- [ ]');
expect(html).not.toContain('[ ]');
expect(html.match(/type="checkbox"/g)).toHaveLength(2);
});
it('should render an empty checked task item as a checked checkbox', () => {
const html = renderMarkdown('- [x]');
expect(html).not.toContain('[x]');
expect(html).toContain('<input type="checkbox" checked disabled class="task-checkbox">');
expect(html).toContain('class="task-list"');
});
it('should not treat brackets glued to text as a task marker', () => {
const html = renderMarkdown('- [ ]x');
expect(html).not.toContain('type="checkbox"');
expect(html).toContain('[ ]x');
});
});
describe('stripMarkdown', () => {
+43 -8
View File
@@ -1,24 +1,59 @@
import MarkdownIt from 'markdown-it';
import sanitizeHtml from 'sanitize-html';
import { highlightCode } from './highlight.js';
const md = new MarkdownIt({
html: false,
html: true,
linkify: true,
typographer: true,
breaks: true
breaks: true,
// Returning '' makes markdown-it escape and wrap the block itself, so an
// unlabelled or unknown-language fence still renders as plain <pre><code>.
highlight: (code, lang) => highlightCode(code, lang) ?? ''
});
// Raw HTML has to render (not just be escaped as visible text): a table with
// a list in a cell can't be expressed in plain GFM, so the editor falls back
// to embedding it as an HTML block (see tiptap/Table.ts). Note content also
// isn't only ever written through that editor - the MCP server and the notes
// API accept a raw markdown string directly - so any HTML this renders has to
// be sanitized regardless of where it came from.
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'input'],
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
input: ['type', 'checked', 'disabled', 'class'],
ul: ['class']
},
// Syntax highlighting is carried by classes: `language-*` on the <code>
// of a fence and `hljs-*` (plus highlight.js sub-scopes like `function_`)
// on the token spans. Nothing else may put a class on these tags.
allowedClasses: {
code: [/^language-[\w+#.-]+$/],
span: [/^hljs-[\w-]+$/, /^language-[\w+#.-]+$/, /^[a-z]+_{1,2}$/]
},
// Keep markdown-it's bare void-element style (<br>, not <br />) so
// sanitizing doesn't change output for content that had no raw HTML to
// begin with.
selfClosing: []
};
// Add task list support
md.core.ruler.after('inline', 'task-lists', (state) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type !== 'inline') continue;
const content = tokens[i].content;
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
const checked = content.startsWith('[x] ');
// markdown-it trims trailing whitespace from inline content, so an item
// with no text after the marker arrives as exactly "[ ]" — match the
// marker followed by a space or end-of-content.
const marker = /^\[( |x)\](?: |$)/.exec(content);
if (marker) {
const checked = marker[1] === 'x';
const checkbox = checked
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
: '<input type="checkbox" disabled class="task-checkbox" /> ';
tokens[i].content = content.slice(4);
tokens[i].content = content.slice(marker[0].length);
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
// Prepend checkbox
const token = new state.Token('html_inline', '', 0);
@@ -28,7 +63,7 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
// Add task-list class to parent <ul> (once)
for (let j = i - 1; j >= 0; j--) {
if (tokens[j].type === 'bullet_list_open') {
const cls = tokens[j].attrGet('class') ?? '';
const cls = String(tokens[j].attrGet('class') ?? '');
if (!cls.includes('task-list')) {
tokens[j].attrJoin('class', 'task-list');
}
@@ -46,7 +81,7 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
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') ?? '';
const href = String(children[i].attrGet('href') ?? '');
if (!href.startsWith('crumb-note://')) continue;
let closeIndex = i + 1;
@@ -59,7 +94,7 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
});
export function renderMarkdown(content: string): string {
return md.render(content);
return sanitizeHtml(md.render(content), SANITIZE_OPTIONS);
}
export function stripMarkdown(content: string): string {
+44 -1
View File
@@ -1,12 +1,13 @@
<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 { canResetPassword, passwordResetBlockedReason, canDisablePasswordLogin } 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 LockKeyhole from 'lucide-svelte/icons/lock-keyhole';
import LogOut from 'lucide-svelte/icons/log-out';
import Trash2 from 'lucide-svelte/icons/trash-2';
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
@@ -331,6 +332,37 @@
// ignore
}
}
async function disablePasswordLogin(user: User) {
if (!canDisablePasswordLogin(user, data.user?.id ?? -1)) return;
if (
!confirm(
`Disable password login for ${userLabel(user)}? They will need to sign in via their linked OAuth provider. All sessions will be revoked.`
)
) {
return;
}
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disablePasswordLogin: true })
});
if (res.ok) {
const updated = await res.json();
users = users.map((u) => (u.id === user.id ? updated : u));
createMsg = `Password login disabled for ${userLabel(user)}`;
createError = false;
} else {
const d = await res.json().catch(() => ({}));
createMsg = d.message || 'Failed to disable password login';
createError = true;
}
} catch {
createMsg = 'Connection error';
createError = true;
}
}
</script>
<!-- Create User -->
@@ -468,6 +500,17 @@
<KeyRound class="h-4 w-4" />
{/if}
</button>
{#if canDisablePasswordLogin(user, data.user?.id ?? -1)}
<button
onclick={() => disablePasswordLogin(user)}
data-testid="disable-password-btn-{user.id}"
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={'Disable password login'}
aria-label="Disable password login"
>
<LockKeyhole class="h-4 w-4" />
</button>
{/if}
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
+18 -11
View File
@@ -1,9 +1,8 @@
import { json, error } from '@sveltejs/kit';
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 { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions, disablePasswordLogin } 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);
@@ -40,16 +39,8 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
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);
revokeAllSessions(userId);
// Notify user of password reset (fire-and-forget)
if (isEmailConfigured() && user.email) {
@@ -57,6 +48,22 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
}
}
if (body.disablePasswordLogin) {
if (userId === admin.id) {
throw error(400, 'Cannot disable password login on your own account');
}
if (!user.passwordLoginEnabled) {
throw error(400, 'Password login is not enabled for this account');
}
if (user.authProvider === 'password' || user.authProvider === 'none') {
throw error(
400,
'Password login can only be disabled when the account is linked to OAuth'
);
}
disablePasswordLogin(userId);
}
if (body.revokeSessions) {
revokeAllSessions(userId);
}
+4 -2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
import { requiresPasswordForAccountDeletion } from '$lib/utils/auth-methods.js';
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
const userId = getUserId(event);
@@ -11,8 +12,9 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
const body = await request.json();
// OAuth users don't have passwords — allow deletion without password
if (user.authProvider === 'password') {
// Password required for pure password accounts; OAuth-linked accounts may delete
// with their active SSO session even when password login was admin-enabled.
if (requiresPasswordForAccountDeletion(user)) {
if (!body.password) throw error(400, 'Password is required');
const verified = await verifyPassword(user.email, body.password);
if (!verified) throw error(401, 'Invalid password');
+1 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.31.0",
"version": "0.33.0",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
+113 -47
View File
@@ -3,12 +3,25 @@ import { randomUUID } from 'crypto';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import Database from 'better-sqlite3';
import { TEST_CREDENTIALS_FILE } from './global-setup';
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
const TEST_DB = './data/test-crumbs.db';
/** Link a user row to an OAuth provider in the test database (simulates first SSO sign-in). */
function linkUserToOAuth(userId: number, provider = 'google', providerId?: string): void {
const db = new Database(TEST_DB);
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?').run(
provider,
providerId ?? `test-${userId}`,
userId
);
db.close();
}
/** Create a victim user via the admin API, returning the user's id. */
async function createVictim(
@@ -27,7 +40,7 @@ async function createVictim(
/**
* 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.
* 'none' with passwordLoginEnabled false until an admin reset enables it.
*/
async function createOAuthOnlyVictim(
page: import('@playwright/test').Page,
@@ -40,6 +53,7 @@ async function createOAuthOnlyVictim(
expect(res.status()).toBe(201);
const user = await res.json();
expect(user.authProvider).toBe('none');
expect(user.passwordLoginEnabled).toBe(false);
return user.id;
}
@@ -213,7 +227,7 @@ test.describe.serial('Admin — Password Reset', () => {
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An account that signs in via OAuth cannot be given a password', async ({
test('Scenario: An OAuth-only account can be given a password by an admin', async ({
authenticatedPage: page,
browser
}) => {
@@ -221,31 +235,93 @@ test.describe.serial('Admin — Password Reset', () => {
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()}`;
// When the admin sets a password for that account
const newPassword = `oauth-pw-${randomUUID()}`;
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword: attemptedPassword }
data: { newPassword }
});
// 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);
// Then the reset succeeds and enables password login
expect(res.status()).toBe(200);
const updated = await res.json();
expect(updated.passwordLoginEnabled).toBe(true);
// 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.
// And the account can sign in with the new password while keeping OAuth
const ctx = await browser.newContext();
const loginRes = await ctx.request.post('/api/auth/login', {
data: { email, password: attemptedPassword }
data: { email, password: newPassword }
});
expect(loginRes.status()).toBe(401);
expect(loginRes.status()).toBe(200);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A password reset revokes all existing sessions', async ({
authenticatedPage: page,
browser
}) => {
// Given a password-auth user with an active session
const email = 'revoke-on-reset@test.com';
const victimId = await createVictim(page, email, 'Revoke Reset');
const ctx = await browser.newContext();
const loginRes = await ctx.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(loginRes.status()).toBe(200);
// When the admin sets a new password for that account
const newPassword = `revoke-${randomUUID()}`;
const resetRes = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword }
});
expect(resetRes.status()).toBe(200);
// Then the old session is no longer valid
expect((await ctx.request.get('/api/notes')).status()).toBe(401);
await ctx.close();
// And the new password still works
const fresh = await browser.newContext();
expect(
(
await fresh.request.post('/api/auth/login', {
data: { email, password: newPassword }
})
).status()
).toBe(200);
await fresh.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An admin can disable password login on an OAuth-linked user', async ({
authenticatedPage: page
}) => {
// Given an OAuth-linked user with password login enabled
const email = 'disable-pw@test.com';
const victimId = await createVictim(page, email, 'Disable Pw');
linkUserToOAuth(victimId);
// When the admin disables password login for that account
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { disablePasswordLogin: true }
});
// Then password login is off and the old password no longer works
expect(res.status()).toBe(200);
expect((await res.json()).passwordLoginEnabled).toBe(false);
expect(
(await page.request.post('/api/auth/login', { data: { email, password: userPassword } }))
.status()
).toBe(401);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An admin cannot reset their own password', async ({
authenticatedPage: page
}) => {
@@ -314,6 +390,7 @@ async function resetViaUi(
const ROW_ACTION_LABELS = [
'Promote to admin',
'Reset password',
'Disable password login',
'Revoke all sessions',
'Delete user'
] as const;
@@ -341,23 +418,14 @@ test.describe('Admin — Password Reset UI', () => {
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 it is available for both password and OAuth-only accounts
for (const id of [eligibleId, oauthId]) {
await expect(page.getByTestId(`reset-password-btn-${id}`)).toHaveAttribute(
'aria-disabled',
'false'
);
await expect(page.getByTestId(`reset-password-reason-${id}`)).toHaveCount(0);
}
// And the admin's own row points them at their profile instead
const ownBtn = page.getByTestId(`reset-password-btn-${adminId}`);
@@ -365,19 +433,13 @@ test.describe('Admin — Password Reset UI', () => {
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();
}
await expect(ownBtn).not.toHaveAttribute('disabled', /.*/);
await ownBtn.focus();
await expect(ownBtn).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.
// And activating the admin's own control changes nothing
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
@@ -630,15 +692,19 @@ test.describe('Admin — Password Reset UI', () => {
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
// Given an OAuth-linked user, whose row therefore offers all five actions
// (the disable-password control is only offered where another sign-in
// method exists)
const victimId = await createVictim(page, 'ui-strip@test.com', 'UI Strip');
linkUserToOAuth(victimId);
// 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.
// The strip fits four of its five 44px squares on one line and wraps the
// fifth below it at every phone width, so all three widths exercise the
// wrap case; 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],
[360, false],
[320, false],
[300, false]
] as const) {
+18
View File
@@ -0,0 +1,18 @@
import { test as setup } from '@playwright/test';
import { setupAndLogin } from './helpers/fixtures';
import { STORAGE_STATE_FILE } from './global-setup';
/**
* Log in as the admin once and persist the session cookie. The `app` project
* loads this storage state into every browser context, so tests start already
* authenticated instead of each one submitting the login form (~0.8s per test,
* roughly half of a typical test's duration).
*
* The admin's own session is never revoked by any test: admin-users only
* resets/revokes victim accounts, and auth.spec's logout scenario runs in the
* auth-setup project with its own context before this setup executes.
*/
setup('authenticate as admin and save storage state', async ({ page }) => {
await setupAndLogin(page);
await page.context().storageState({ path: STORAGE_STATE_FILE });
});
+164
View File
@@ -0,0 +1,164 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
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;
});
}
async function runTiptapCommand(page: Page, commandFn: string) {
await page.getByTestId('tiptap-editor').evaluate(
(el, fn) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
new Function('editor', fn)(editor);
},
commandFn
);
}
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
}
/** Write note content as markdown, then return to the rich editor. */
async function typeViaMarkdown(page: Page, content: string) {
await toggleMarkdownMode(page);
await page.getByTestId('note-content-input').fill(content);
await toggleMarkdownMode(page);
}
test.describe('Code snippets', () => {
test('Scenario: A fenced block with a language is syntax highlighted in the editor and on the card', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a JavaScript code block
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('JS Snippet');
await typeViaMarkdown(page, '```js\nconst answer = 42;\n```');
await waitForTiptapEditor(page);
// Then the editor colours the keyword and number as separate tokens
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre .hljs-keyword')).toHaveText('const');
await expect(editor.locator('pre .hljs-number')).toHaveText('42');
// When the note is closed
await page.getByTestId('close-editor-btn').click();
// Then the card preview shows the same highlighting
const preview = noteCard(page, 'JS Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre .hljs-keyword')).toHaveText('const');
await expect(preview.locator('pre .hljs-number')).toHaveText('42');
});
test('Scenario: An unlabelled code block stays plain instead of guessing a language', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a code block with no language
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Plain Snippet');
await typeViaMarkdown(page, '```\nSELECT * FROM users;\n```');
await waitForTiptapEditor(page);
// Then the editor shows the block without any token colouring
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(editor.locator('pre [class*="hljs-"]')).toHaveCount(0);
// And so does the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Plain Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(preview.locator('pre [class*="hljs-"]')).toHaveCount(0);
});
test('Scenario: Choosing a language for a code block is saved in the markdown fence', async ({
authenticatedPage: page
}) => {
// Given the user is editing a note with a plain code block
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, '```\ndef greet():\n return "hi"\n```');
await waitForTiptapEditor(page);
// And the language picker is offered only while the cursor is in the block
await runTiptapCommand(page, 'editor.chain().focus("start").run()');
const picker = page.getByTestId('format-code-language');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue('');
// When the user picks Python
await picker.selectOption('python');
// Then the block is highlighted as Python
await expect(page.getByTestId('tiptap-editor').locator('pre .hljs-keyword').first()).toHaveText('def');
await expect(picker).toHaveValue('python');
// And the markdown fence carries the language
await toggleMarkdownMode(page);
await expect(page.getByTestId('note-content-input')).toHaveValue('```python\ndef greet():\n return "hi"\n```');
});
test('Scenario: The language picker is hidden outside a code block', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with a paragraph
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'just prose');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus("end").run()');
// Then no language picker is shown
await expect(page.getByTestId('format-code-language')).toHaveCount(0);
});
test('Scenario: Code keeps the body text size instead of shrinking', async ({ authenticatedPage: page }) => {
// Given a note with a code block and a paragraph
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sized Snippet');
await typeViaMarkdown(page, 'Some prose\n\n```js\nconst a = 1;\n```');
await waitForTiptapEditor(page);
// Then the code block is set at the same size as the prose around it
const editor = page.getByTestId('tiptap-editor');
const fontSize = (locator: ReturnType<Page['locator']>) =>
locator.evaluate((el) => getComputedStyle(el).fontSize);
expect(await fontSize(editor.locator('pre code'))).toBe(await fontSize(editor.locator('p').first()));
// And the same holds on the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Sized Snippet').getByTestId('note-content-preview');
expect(await fontSize(preview.locator('pre code'))).toBe(await fontSize(preview.locator('p').first()));
});
test('Scenario: A long code line is clipped on the card instead of adding a scrollbar', async ({
authenticatedPage: page
}) => {
// Given a note with a code line far wider than a card
const longLine = 'const url = "https://example.com/' + 'segment/'.repeat(30) + '";';
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Wide Snippet');
await typeViaMarkdown(page, '```js\n' + longLine + '\n```');
await page.getByTestId('close-editor-btn').click();
// Then the card's code block stays inside the card and does not scroll horizontally
const preview = noteCard(page, 'Wide Snippet').getByTestId('note-content-preview');
const pre = preview.locator('pre');
await expect(pre).toBeVisible();
const overflow = await pre.evaluate((el) => {
const style = getComputedStyle(el);
const box = el.getBoundingClientRect();
const container = el.parentElement!.getBoundingClientRect();
return {
x: style.overflowX,
wider: el.scrollWidth > el.clientWidth,
spill: box.right - container.right
};
});
expect(overflow.wider, 'the line must actually be wider than the card for this test to mean anything').toBe(true);
expect(overflow.x).toBe('hidden');
expect(overflow.spill).toBeLessThanOrEqual(1);
});
});
+35 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, createNote } from './helpers/fixtures.js';
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
test.describe.serial('Dark mode', () => {
test('Scenario: Theme toggle persists selection across page reload', async ({
@@ -110,4 +110,38 @@ test.describe.serial('Dark mode', () => {
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Bold text in notes uses the theme text colour in dark mode', async ({
authenticatedPage: page
}) => {
// Given a note with bold content exists
await page.goto('/');
await page.waitForLoadState('networkidle');
await createNote(page, 'Bold Contrast Note', 'Plain then **bold words** here');
// And dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When viewing the note on the notes page
await page.goto('/');
await page.waitForLoadState('networkidle');
const preview = noteCard(page, 'Bold Contrast Note').getByTestId('note-content-preview');
const boldInCard = preview.locator('strong');
await expect(boldInCard).toHaveText('bold words');
// Then the bold text is rendered in the dark theme's text colour (--text = #ece3d3),
// not the typography plugin's hardcoded near-black
await expect(boldInCard).toHaveCSS('color', 'rgb(236, 227, 211)');
// And the same holds inside the editor
await noteCard(page, 'Bold Contrast Note').click();
const boldInEditor = page.getByTestId('tiptap-editor').locator('.tiptap strong');
await expect(boldInEditor).toHaveText('bold words');
await expect(boldInEditor).toHaveCSS('color', 'rgb(236, 227, 211)');
await page.getByTestId('close-editor-btn').click();
});
});
+8 -9
View File
@@ -1,17 +1,16 @@
import { existsSync, unlinkSync, writeFileSync } from 'fs';
import { writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
const TEST_DB = './data/test-crumbs.db';
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
/** Admin session cookie captured by auth.setup.ts and shared by the `app` project. */
export const STORAGE_STATE_FILE = './data/test-storage-state.json';
export default function globalSetup() {
// Clean test database before E2E run so each run starts fresh
for (const ext of ['', '-journal', '-wal', '-shm']) {
const path = `${TEST_DB}${ext}`;
if (existsSync(path)) {
unlinkSync(path);
}
}
// The test database is wiped before the webServer starts (see the
// webServer.command in playwright.config.ts), NOT here: Playwright brings
// the webServer up before globalSetup runs, so deleting the SQLite files
// here would orphan the server's open database onto a deleted inode and
// tests that read the database file directly would see an empty schema.
// Generate random passwords for this test run, shared across all workers
writeFileSync(
+4
View File
@@ -9,6 +9,10 @@ import { setupAndLogin } from './helpers/fixtures.js';
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
*/
// Start unauthenticated: the "rejects requests without API key" scenario must
// not carry the shared admin session cookie, and the apiKey fixture logs in itself.
base.use({ storageState: { cookies: [], origins: [] } });
const test = base.extend<{ apiKey: string }>({
apiKey: async ({ page }, use) => {
await setupAndLogin(page);
+4
View File
@@ -1,5 +1,9 @@
import { test, expect } from '@playwright/test';
// These scenarios describe what an anonymous visitor receives, so drop the
// shared admin session the `app` project injects.
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('PWA', () => {
test('Scenario: Web manifest provides correct app metadata', async ({ page }) => {
// When the browser requests the web manifest
+24 -1
View File
@@ -2,13 +2,36 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import istanbul from 'vite-plugin-istanbul';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
/**
* The e2e suite drives `vite preview` and its API helpers reuse keep-alive
* sockets. Node's http server drops an idle keep-alive socket after 5s by
* default, and a request that lands on a socket the server is closing at that
* instant fails client-side with "socket hang up" (seen on main CI in
* public-sharing.spec). Keeping the server's idle timeout well above any
* client's means the client always closes first, so the race cannot happen.
*/
function previewKeepAlive(): Plugin {
return {
name: 'crumbs:preview-keep-alive',
configurePreviewServer({ httpServer }) {
// HTTP/2 servers have no keep-alive timeout; preview is HTTP/1 here.
if ('keepAliveTimeout' in httpServer) {
httpServer.keepAliveTimeout = 65_000;
// Node requires headersTimeout to exceed keepAliveTimeout.
httpServer.headersTimeout = 66_000;
}
}
};
}
export default defineConfig({
build: {
chunkSizeWarningLimit: 850
},
plugins: [
previewKeepAlive(),
tailwindcss(),
sveltekit(),
...(process.env.VITE_COVERAGE === 'true'
+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.33.0</span>
<span class="footer-version">v0.35.0</span>
</div>
</footer>