mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f663d9d6 | ||
|
|
9cf0c0285b | ||
|
|
faf926c081 | ||
|
|
31732e8144 | ||
|
|
d71dbe0f4a | ||
|
|
effe7e3e72 | ||
|
|
6f4938180a | ||
|
|
eb65425220 | ||
|
|
011113b7e1 | ||
|
|
58e5b5dd66 | ||
|
|
7bf06edd97 | ||
|
|
af7186675e | ||
|
|
b0ecc6b74d | ||
|
|
521acc718c | ||
|
|
082b8632c6 | ||
|
|
6bdcc3c214 | ||
|
|
e8c30c518c | ||
|
|
84145de3b1 | ||
|
|
ea66e23178 | ||
|
|
feda983393 | ||
|
|
bf5bfdd98e | ||
|
|
12ff6d9c4d | ||
|
|
44963bf684 | ||
|
|
dfaf1c474d | ||
|
|
881a85a09e | ||
|
|
27c3e7dccd | ||
|
|
64f96b21a3 | ||
|
|
ff24fb6d5b | ||
|
|
7f6ad2dc12 | ||
|
|
e4606cfc70 | ||
|
|
e315b8c7ec | ||
|
|
7d9bd737ab | ||
|
|
4812eaf1ed | ||
|
|
afce024cac | ||
|
|
ffde151b75 | ||
|
|
a6f86ec1f0 | ||
|
|
51dd844f96 | ||
|
|
0d21f7efda |
@@ -125,5 +125,21 @@ jobs:
|
||||
push: false
|
||||
provenance: false
|
||||
tags: crumbs:ci
|
||||
load: true
|
||||
cache-from: type=gha,scope=ci-docker
|
||||
cache-to: type=gha,scope=ci-docker,mode=max
|
||||
|
||||
# Regression for #93: a host folder bind-mounted at /data is owned by the
|
||||
# host user (the runner, uid 1001), not the container's node user (1000)
|
||||
- name: Start with a host-owned bind mount
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/crumbs-data"
|
||||
docker run -d --name crumbs -p 3000:3000 -v "$RUNNER_TEMP/crumbs-data:/data" crumbs:ci
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS -o /dev/null http://localhost:3000/login; then
|
||||
test -f "$RUNNER_TEMP/crumbs-data/crumbs.db" && exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
docker logs crumbs
|
||||
exit 1
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+4
-2
@@ -37,8 +37,10 @@ COPY --from=build /app/package.json ./
|
||||
# Create data directory
|
||||
RUN mkdir -p /data && chown -R node:node /data
|
||||
|
||||
# Use non-root user
|
||||
USER node
|
||||
# Runs as root only long enough to hand the data folder (often a host bind
|
||||
# mount) to the node user, then drops privileges — see crumbs-entrypoint.sh
|
||||
COPY crumbs-entrypoint.sh /usr/local/bin/crumbs-entrypoint.sh
|
||||
ENTRYPOINT ["crumbs-entrypoint.sh"]
|
||||
|
||||
# Configuration
|
||||
ENV NODE_ENV=production
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Starts as root so a bind-mounted data folder owned by the host user can be
|
||||
# handed to the unprivileged `node` user, then drops privileges before running
|
||||
# the app. Without this, a host folder mounted at /data is not writable by
|
||||
# uid 1000 and SQLite fails with SQLITE_CANTOPEN.
|
||||
set -e
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
for dir in "${DATA_DIR:-/data}" "$(dirname "${DATABASE_URL:-/data/crumbs.db}")"; do
|
||||
mkdir -p "$dir"
|
||||
# Only touch entries not already owned by node, so restarts on a large
|
||||
# attachment folder stay fast.
|
||||
find "$dir" \! -user node -exec chown node:node {} + ||
|
||||
echo "warning: could not change ownership of $dir; it must be writable by uid 1000" >&2
|
||||
done
|
||||
exec setpriv --reuid=node --regid=node --init-groups "$@"
|
||||
fi
|
||||
|
||||
# Started with an explicit non-root user (e.g. `user:` in compose): run as-is.
|
||||
exec "$@"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.31.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.35.2
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-3
@@ -10,7 +10,24 @@ On first launch, Crumbs presents a setup screen to create the initial admin acco
|
||||
|
||||
- Passwords are hashed with **Argon2** (argon2id variant)
|
||||
- Sessions use httpOnly cookies with 30-day expiry
|
||||
- Admins can reset passwords and revoke sessions for any user via Settings
|
||||
- Admins can revoke the sessions of, and reset the password of, users other than themselves via **Settings > Users** — see [Admin password resets](#admin-password-resets)
|
||||
|
||||
### Admin password resets
|
||||
|
||||
An admin can reset the password of any user other than themselves. 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:
|
||||
|
||||
- **Your own row** — change your own password in **Settings > Profile**, which verifies your current password first.
|
||||
- **While a generated password is still on screen** — dismiss it 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 **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. 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
|
||||
|
||||
@@ -29,12 +46,14 @@ OAuth providers are **auto-enabled** when their environment variables are set
|
||||
|
||||
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
|
||||
|
||||
1. Go to **Settings > Users > Invite User**
|
||||
1. Go to **Settings > Users** and fill in the **Create User** form
|
||||
2. Create the user with the **same email** they use on the OAuth provider
|
||||
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
|
||||
|
||||
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
|
||||
|
||||
Tick **OAuth-only (no password)** when creating these accounts — the provider is how they will sign in. 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
|
||||
|
||||
#### Google OAuth
|
||||
@@ -130,7 +149,7 @@ services:
|
||||
|
||||
**4. Pre-create users:**
|
||||
|
||||
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
|
||||
In Crumbs, go to **Settings > Users** and create accounts with emails matching your Authentik users, ticking **OAuth-only (no password)**. They can then log in via the "Authentik" button on the login page.
|
||||
|
||||
#### Keycloak
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ services:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
The app runs as the image's `node` user (uid 1000). On startup the container hands ownership of a
|
||||
bind-mounted data folder to uid 1000, so a folder created by any host user works as-is. If you set
|
||||
`user:` in compose, that step is skipped and the folder must already be writable by that user.
|
||||
|
||||
### Reverse Proxy (nginx)
|
||||
|
||||
```nginx
|
||||
|
||||
+7
-3
@@ -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)
|
||||
@@ -142,8 +142,12 @@
|
||||
### User Management (admin)
|
||||
- Admin dashboard to create, list, and delete users
|
||||
- Assign roles (admin / user)
|
||||
- Reset passwords for any user
|
||||
- Revoke all sessions (force logout) for any user
|
||||
- Reset another user's password — Crumbs generates a 20-character password and shows it once in that user's row, with a copy button; it cannot be shown again
|
||||
- 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
|
||||
- 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
|
||||
- Installable as standalone app
|
||||
|
||||
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.32.0",
|
||||
"version": "0.35.2",
|
||||
"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
@@ -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,
|
||||
|
||||
Generated
+1043
-794
File diff suppressed because it is too large
Load Diff
+132
-3
@@ -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())}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
|
||||
let rawMarkdownMode = $state(prefs.defaultNoteMode === 'markdown');
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
|
||||
@@ -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'] }),
|
||||
|
||||
@@ -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('<table>');
|
||||
});
|
||||
|
||||
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]');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface User {
|
||||
displayName: string;
|
||||
role: 'admin' | 'user';
|
||||
authProvider: string;
|
||||
passwordLoginEnabled: boolean;
|
||||
providerId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
canResetPassword,
|
||||
passwordResetBlockedReason,
|
||||
requiresPasswordForAccountDeletion,
|
||||
canDisablePasswordLogin
|
||||
} from './auth-methods.js';
|
||||
|
||||
describe('canResetPassword', () => {
|
||||
it('allows a reset for a password account', () => {
|
||||
expect(canResetPassword({ authProvider: 'password', passwordLoginEnabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a reset for an OAuth-only account', () => {
|
||||
expect(canResetPassword({ authProvider: 'none', passwordLoginEnabled: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a reset for an OAuth-linked account', () => {
|
||||
expect(canResetPassword({ authProvider: 'google', passwordLoginEnabled: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('passwordResetBlockedReason', () => {
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { User } from '$lib/types/index.js';
|
||||
|
||||
/**
|
||||
* Whether an admin may set a new password for this account.
|
||||
*
|
||||
* 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, 'passwordLoginEnabled' | 'authProvider'>): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a password reset is blocked, or null when it is allowed.
|
||||
*
|
||||
* 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, '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';
|
||||
}
|
||||
@@ -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('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/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);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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)
|
||||
};
|
||||
@@ -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('<<span class="hljs-name">script</span>>');
|
||||
});
|
||||
|
||||
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('<table>');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { generatePassword, PASSWORD_ALPHABET } from './password.js';
|
||||
|
||||
const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/;
|
||||
const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/;
|
||||
const DIGIT = /[23456789]/;
|
||||
const SYMBOL = /[!@#$%^&*]/;
|
||||
|
||||
/** Visually ambiguous characters the charset deliberately excludes. */
|
||||
const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I'];
|
||||
|
||||
describe('PASSWORD_ALPHABET', () => {
|
||||
it('is exactly 64 distinct characters', () => {
|
||||
// 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform.
|
||||
// A charset edit that breaks this silently biases every password.
|
||||
expect(PASSWORD_ALPHABET).toHaveLength(64);
|
||||
expect(new Set(PASSWORD_ALPHABET).size).toBe(64);
|
||||
});
|
||||
|
||||
it('excludes every visually ambiguous character', () => {
|
||||
for (const char of AMBIGUOUS) {
|
||||
expect(PASSWORD_ALPHABET.includes(char), char).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePassword', () => {
|
||||
it('returns a 20-character password by default', () => {
|
||||
expect(generatePassword()).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('honours an explicit length', () => {
|
||||
expect(generatePassword(8)).toHaveLength(8);
|
||||
expect(generatePassword(32)).toHaveLength(32);
|
||||
expect(generatePassword(64)).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('always includes at least one character from each of the four groups', () => {
|
||||
// Not a statistical bet: the function only ever returns a class-complete
|
||||
// password or throws, so every sample must pass.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const password = generatePassword();
|
||||
expect(password, `sample ${i}`).toMatch(LOWERCASE);
|
||||
expect(password, `sample ${i}`).toMatch(UPPERCASE);
|
||||
expect(password, `sample ${i}`).toMatch(DIGIT);
|
||||
expect(password, `sample ${i}`).toMatch(SYMBOL);
|
||||
}
|
||||
});
|
||||
|
||||
it('never emits a visually ambiguous character', () => {
|
||||
// 0 O o 1 l I survive neither being read aloud nor retyped.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const password = generatePassword();
|
||||
for (const char of AMBIGUOUS) {
|
||||
expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('draws only from the pinned 64-character charset', () => {
|
||||
const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(generatePassword(), `sample ${i}`).toMatch(charset);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces a different password on each call', () => {
|
||||
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
|
||||
expect(passwords.size).toBe(20);
|
||||
});
|
||||
|
||||
it('throws below the 8-character server minimum', () => {
|
||||
expect(() => generatePassword(7)).toThrow(/8/);
|
||||
expect(() => generatePassword(0)).toThrow(/8/);
|
||||
expect(() => generatePassword(-1)).toThrow(/8/);
|
||||
});
|
||||
|
||||
it('does not use Math.random anywhere in the module', () => {
|
||||
// Randomness must come from nanoid's CSPRNG, not Math.random.
|
||||
const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8');
|
||||
expect(source).not.toContain('Math.random');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
// The excluded characters — 0 O o 1 l I — are visually ambiguous, so a generated
|
||||
// password survives being read aloud or retyped from a screenshot.
|
||||
const LOWERCASE = 'abcdefghijkmnpqrstuvwxyz'; // no l, no o
|
||||
const UPPERCASE = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no I, no O
|
||||
const DIGITS = '23456789'; // no 0, no 1
|
||||
const SYMBOLS = '!@#$%^&*';
|
||||
|
||||
/**
|
||||
* Pinned at exactly 64 characters. 64 is a power of two, so nanoid's masking
|
||||
* takes its exactly-uniform fast path (256 % 64 === 0) and no character is
|
||||
* favoured over another. At 64 symbols a 20-character password carries 120 bits
|
||||
* of entropy — 119.79 once conditioned on the class-completeness retry below.
|
||||
* Do not change the size without understanding both consequences.
|
||||
*/
|
||||
export const PASSWORD_ALPHABET = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS;
|
||||
|
||||
const GROUPS = [LOWERCASE, UPPERCASE, DIGITS, SYMBOLS];
|
||||
|
||||
/** The server rejects anything shorter. */
|
||||
const MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Per-attempt failure is 13.5% at the default length of 20 and 61.9% at the
|
||||
* minimum of 8, so exhausting the cap has probability ~1e-87 and ~1e-21
|
||||
* respectively — unreachable in practice. The bound exists so a future charset
|
||||
* edit cannot turn the retry into an infinite loop.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 100;
|
||||
|
||||
const randomString = customAlphabet(PASSWORD_ALPHABET);
|
||||
|
||||
function containsAnyOf(password: string, group: string): boolean {
|
||||
return [...password].some((char) => group.includes(char));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random password containing at least one lowercase letter, one
|
||||
* uppercase letter, one digit and one symbol.
|
||||
*
|
||||
* A password missing a group is discarded whole and regenerated, which keeps the
|
||||
* result uniform over exactly the set of valid passwords. Forcing characters
|
||||
* into chosen positions and shuffling would need its own unbiased index source.
|
||||
*/
|
||||
export function generatePassword(length = 20): string {
|
||||
if (length < MIN_LENGTH) {
|
||||
throw new Error(`Password length must be at least ${MIN_LENGTH}, got ${length}`);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const password = randomString(length);
|
||||
if (GROUPS.every((group) => containsAnyOf(password, group))) return password;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to generate a password with all character groups after ${MAX_ATTEMPTS} attempts`
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { User } from '$lib/types/index.js';
|
||||
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
|
||||
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';
|
||||
|
||||
let { data } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -15,6 +25,213 @@
|
||||
let createMsg = $state('');
|
||||
let createError = $state(false);
|
||||
|
||||
/**
|
||||
* One row's password-reset state.
|
||||
*
|
||||
* The generated plaintext lives here and nowhere else — never localStorage,
|
||||
* sessionStorage, IndexedDB, a sync payload or a log — so navigating away is
|
||||
* enough to lose it. State is per row rather than shared because two rows'
|
||||
* outcomes would otherwise overwrite each other, and a password destroyed that
|
||||
* way is unrecoverable.
|
||||
*/
|
||||
type ResetState = {
|
||||
inFlight: boolean;
|
||||
/** Revealed plaintext. Set on success and on an unconfirmed outcome. */
|
||||
password: string | null;
|
||||
/** Row message. Always rendered *alongside* `password`, never instead of it. */
|
||||
message: string | null;
|
||||
copyStatus: string | null;
|
||||
/**
|
||||
* Reason the server refused, kept even after the message is dismissed. This
|
||||
* list snapshots `authProvider` at page load and never refetches, so a row can
|
||||
* still look eligible after the account moved to OAuth; without this the
|
||||
* control would stay available and invite the same failure forever.
|
||||
*/
|
||||
serverReason: string | null;
|
||||
};
|
||||
|
||||
const IDLE_RESET: ResetState = {
|
||||
inFlight: false,
|
||||
password: null,
|
||||
message: null,
|
||||
copyStatus: null,
|
||||
serverReason: null
|
||||
};
|
||||
|
||||
const SELF_RESET_REASON = 'Change your own password in Profile.';
|
||||
|
||||
/**
|
||||
* The request left the device but no answer came back, so the server may well
|
||||
* have stored this password. Revealing it anyway is deliberate: it is the only
|
||||
* copy that exists, and dropping it could leave the account holding a credential
|
||||
* nobody knows.
|
||||
*/
|
||||
const UNCONFIRMED_MESSAGE =
|
||||
'Could not confirm the reset. This password may or may not have been applied. Keep it — if the user cannot sign in, reset again once you are back online.';
|
||||
|
||||
/**
|
||||
* Definite, not hedged: the service worker caches only `GET /api/*`, so a PATCH
|
||||
* issued while offline never leaves the browser and nothing can have changed.
|
||||
*/
|
||||
const OFFLINE_MESSAGE = "You're offline — nothing was changed. Try again when reconnected.";
|
||||
|
||||
let resets = $state<Record<number, ResetState>>({});
|
||||
let announcement = $state('');
|
||||
const resetButtons: Record<number, HTMLButtonElement | null> = {};
|
||||
|
||||
function userLabel(user: User): string {
|
||||
return user.displayName || user.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the live region's text. The region itself is rendered empty from the
|
||||
* first paint and never inserted alongside its content — a region added to the
|
||||
* DOM together with its text is not reliably announced, least of all by
|
||||
* TalkBack.
|
||||
*/
|
||||
function announce(text: string) {
|
||||
announcement = text;
|
||||
}
|
||||
|
||||
/** Why this row's password cannot be reset, or null when it can. */
|
||||
function resetBlockedReason(user: User): string | null {
|
||||
// Takes precedence over eligibility: an admin changes their own password in
|
||||
// the profile flow, which verifies the current one.
|
||||
if (user.id === data.user?.id) return SELF_RESET_REASON;
|
||||
|
||||
const serverReason = resets[user.id]?.serverReason;
|
||||
if (serverReason) return serverReason;
|
||||
|
||||
if (!canResetPassword(user)) {
|
||||
// auth-methods guarantees a non-empty reason whenever the check fails; the
|
||||
// ?? only satisfies the nullable return type, as on the API route.
|
||||
return passwordResetBlockedReason(user) ?? 'Password login is not available for this account.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How one row's control presents itself. `unavailable` is expressed with
|
||||
* `aria-disabled`, never the `disabled` attribute: a disabled button leaves the
|
||||
* tab order, so a keyboard or screen-reader user loses the element they just
|
||||
* activated, and a `title` tooltip explains nothing on a touch screen.
|
||||
*/
|
||||
function resetControl(user: User): {
|
||||
reason: string | null;
|
||||
inFlight: boolean;
|
||||
unavailable: boolean;
|
||||
} {
|
||||
const state = resets[user.id];
|
||||
const reason = resetBlockedReason(user);
|
||||
const inFlight = state?.inFlight === true;
|
||||
return {
|
||||
reason,
|
||||
inFlight,
|
||||
unavailable: reason !== null || inFlight || state?.password != null
|
||||
};
|
||||
}
|
||||
|
||||
/** Merges a partial update into one row's state, leaving untouched fields alone. */
|
||||
function patchReset(userId: number, patch: Partial<ResetState>) {
|
||||
resets[userId] = { ...IDLE_RESET, ...resets[userId], ...patch };
|
||||
}
|
||||
|
||||
function revealUnconfirmed(user: User, password: string) {
|
||||
patchReset(user.id, { inFlight: false, password, message: UNCONFIRMED_MESSAGE });
|
||||
announce(
|
||||
`Could not confirm the password reset for ${userLabel(user)}. The generated password is shown in their row — keep it.`
|
||||
);
|
||||
}
|
||||
|
||||
async function resetUserPassword(user: User) {
|
||||
// The control stays focusable while unavailable, so activation is refused
|
||||
// here instead of by the browser.
|
||||
if (resetControl(user).unavailable) return;
|
||||
|
||||
// Checked before the request, so the offline outcome is reported as the
|
||||
// certainty it is rather than as a lost response.
|
||||
if (navigator.onLine === false) {
|
||||
patchReset(user.id, { message: OFFLINE_MESSAGE, copyStatus: null });
|
||||
announce(`${userLabel(user)}: ${OFFLINE_MESSAGE}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const password = generatePassword();
|
||||
patchReset(user.id, { inFlight: true, message: null, copyStatus: null });
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ newPassword: password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
patchReset(user.id, { inFlight: false, password, message: null });
|
||||
announce(
|
||||
`New password generated for ${userLabel(user)}. It is shown in their row — copy it now, it cannot be shown again.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
users = users.filter((u) => u.id !== user.id);
|
||||
// No account, no credential: drop the row's plaintext with the row.
|
||||
delete resets[user.id];
|
||||
announce(
|
||||
`${userLabel(user)} no longer exists. The row was removed and no password was set.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const message = typeof body.message === 'string' && body.message ? body.message : null;
|
||||
if (message !== null && res.status < 500) {
|
||||
// A refusal the server states outright: nothing was stored, so no
|
||||
// password is revealed and the reason sticks to the control.
|
||||
patchReset(user.id, { inFlight: false, message, serverReason: message });
|
||||
announce(`Password reset failed for ${userLabel(user)}. ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// A status without a readable reason says nothing about what the server did.
|
||||
revealUnconfirmed(user, password);
|
||||
} catch {
|
||||
// The response was lost, not refused — see UNCONFIRMED_MESSAGE.
|
||||
revealUnconfirmed(user, password);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword(user: User) {
|
||||
const password = resets[user.id]?.password;
|
||||
if (!password) return;
|
||||
try {
|
||||
// Absent outside a secure context, which a LAN-IP HTTP deployment is not,
|
||||
// so the failure below has to be a working fallback rather than a dead end.
|
||||
await navigator.clipboard.writeText(password);
|
||||
patchReset(user.id, { copyStatus: 'Copied to clipboard.' });
|
||||
announce(`Password for ${userLabel(user)} copied to clipboard.`);
|
||||
} catch {
|
||||
patchReset(user.id, {
|
||||
copyStatus: 'Could not copy automatically — select the password and copy it by hand.'
|
||||
});
|
||||
announce(
|
||||
`Could not copy the password for ${userLabel(user)}. Select it in their row and copy it by hand.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissReset(user: User) {
|
||||
const serverReason = resets[user.id]?.serverReason ?? null;
|
||||
// A server refusal explains the control's state, which outlives the message
|
||||
// being dismissed here.
|
||||
if (serverReason) resets[user.id] = { ...IDLE_RESET, serverReason };
|
||||
else delete resets[user.id];
|
||||
announce(`Password reset message for ${userLabel(user)} cleared.`);
|
||||
// Never drop the admin on <body>: give the row's control the focus back.
|
||||
resetButtons[user.id]?.focus();
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
createMsg = '';
|
||||
if (!newEmail) {
|
||||
@@ -70,6 +287,7 @@
|
||||
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
users = users.filter((u) => u.id !== userId);
|
||||
delete resets[userId];
|
||||
createMsg = 'User deleted';
|
||||
createError = false;
|
||||
} else {
|
||||
@@ -114,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 -->
|
||||
@@ -172,46 +421,183 @@
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
|
||||
|
||||
<!-- Rendered empty on first paint; only its text is ever replaced, so both
|
||||
reveals and per-row failures are announced from a region assistive tech
|
||||
has already been watching. -->
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reset-password-live"
|
||||
class="sr-only"
|
||||
>{announcement}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user (user.id)}
|
||||
{@const reset = resets[user.id]}
|
||||
{@const control = resetControl(user)}
|
||||
{@const roleAction = user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||
{@const resetLabel = control.inFlight ? 'Resetting…' : 'Reset password'}
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
class="flex flex-col gap-3 rounded-sm border border-[var(--border-subtle)] p-4"
|
||||
data-testid="user-row-{user.id}"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<!-- One uniform 44x44 square per action, replacing four text labels that
|
||||
wrapped onto three lines at phone width. `flex-wrap` is what keeps the
|
||||
squares square: flex compresses items only on a line that overflows, so
|
||||
breaking the line means no button is ever asked to shrink. Drop it and
|
||||
the four flatten to 40x44 at 320px — under the minimum, on a row whose
|
||||
Delete is irreversible. They hold one line down to 336px, which is
|
||||
exactly 4x44 plus the gaps, and take a second line below that: correctly
|
||||
sized targets over two lines beat cramped ones on one. The container's
|
||||
own `shrink-0` is a different mechanism and does carry weight — from
|
||||
640px the row turns `sm:flex-row`, where the truncating name block would
|
||||
otherwise squeeze the strip. On the buttons themselves that same token
|
||||
would do nothing, `flex-wrap` having already spared them. Each icon
|
||||
carries both a tooltip, for a mouse, and an aria-label, since an icon
|
||||
alone has no accessible name. -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1">
|
||||
<button
|
||||
onclick={() => toggleRole(user)}
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={roleAction}
|
||||
aria-label={roleAction}
|
||||
>
|
||||
{#if user.role === 'admin'}
|
||||
<ShieldOff class="h-4 w-4" />
|
||||
{:else}
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Offered on every row, the admin's own included: a control that
|
||||
vanishes is the confusion this replaces. -->
|
||||
<button
|
||||
bind:this={resetButtons[user.id]}
|
||||
onclick={() => resetUserPassword(user)}
|
||||
data-testid="reset-password-btn-{user.id}"
|
||||
aria-disabled={control.unavailable}
|
||||
aria-describedby={control.reason ? `reset-password-reason-${user.id}` : undefined}
|
||||
use:tooltip={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm transition-colors duration-150 ease-out {control.unavailable
|
||||
? 'cursor-not-allowed border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)]'
|
||||
: 'text-[var(--text)] hover:bg-[var(--hover-wash)]/10'}"
|
||||
>
|
||||
{#if control.inFlight}
|
||||
<!-- The label went with the text, so in-flight has to be carried by
|
||||
the icon and by the accessible name above. -->
|
||||
<LoaderCircle class="h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<KeyRound class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if 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}
|
||||
</p>
|
||||
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onclick={() => toggleRole(user)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||
>
|
||||
{user.role === 'admin' ? 'Make user' : 'Make admin'}
|
||||
</button>
|
||||
{#if user.id !== data.user?.id}
|
||||
<button
|
||||
onclick={() => revokeSessions(user)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
title="Force logout"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
data-testid="delete-user-btn-{user.id}"
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
{#if user.id !== data.user?.id}
|
||||
<button
|
||||
onclick={() => revokeSessions(user)}
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={'Revoke all sessions'}
|
||||
aria-label="Revoke all sessions"
|
||||
>
|
||||
<LogOut class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
data-testid="delete-user-btn-{user.id}"
|
||||
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
|
||||
use:tooltip={'Delete user'}
|
||||
aria-label="Delete user"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if control.reason || reset?.message || reset?.password}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if control.reason}
|
||||
<!-- Visible text, not a tooltip: a title attribute is unreachable
|
||||
by touch, and colour alone explains nothing. -->
|
||||
<p
|
||||
id="reset-password-reason-{user.id}"
|
||||
data-testid="reset-password-reason-{user.id}"
|
||||
class="text-xs text-[var(--text-muted)]"
|
||||
>{control.reason}</p>
|
||||
{/if}
|
||||
|
||||
{#if reset?.message}
|
||||
<p
|
||||
data-testid="reset-password-error-{user.id}"
|
||||
class="rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-2 text-xs text-[var(--error-text)]"
|
||||
>{reset.message}</p>
|
||||
{/if}
|
||||
|
||||
{#if reset?.password}
|
||||
<!-- Rendered independently of the message above, so a failure can
|
||||
never replace a password that is already the account's only
|
||||
credential. -->
|
||||
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border)] bg-[var(--bg-base)] p-3 shadow-[var(--card-shadow)]">
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
New password for {userLabel(user)} — shown once, not stored anywhere.
|
||||
</p>
|
||||
<code
|
||||
data-testid="generated-password-{user.id}"
|
||||
class="block break-all select-all text-sm text-[var(--text)]"
|
||||
>{reset.password}</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if reset?.message || reset?.password}
|
||||
<!-- 44px targets with a wide gap: dismissing is irreversible, so a
|
||||
mis-tap next to Copy would destroy a live credential. -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{#if reset?.password}
|
||||
<button
|
||||
onclick={() => copyPassword(user)}
|
||||
data-testid="copy-password-btn-{user.id}"
|
||||
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--border-subtle)] px-3 text-xs text-[var(--text)] transition-colors duration-150 ease-out hover:border-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => dismissReset(user)}
|
||||
data-testid="dismiss-password-btn-{user.id}"
|
||||
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--destructive)] px-3 text-xs text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if reset?.copyStatus}
|
||||
<p
|
||||
data-testid="copy-status-{user.id}"
|
||||
class="text-xs text-[var(--text-muted)]"
|
||||
>{reset.copyStatus}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
@@ -34,7 +34,13 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
if (body.newPassword.length < 8) {
|
||||
throw error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
// Prevent admin from resetting their own password — changing your own
|
||||
// password goes through the profile flow, which verifies the current one.
|
||||
if (userId === admin.id) {
|
||||
throw error(400, 'Cannot reset your own password — change it from your profile instead');
|
||||
}
|
||||
await resetPassword(userId, body.newPassword);
|
||||
revokeAllSessions(userId);
|
||||
|
||||
// Notify user of password reset (fire-and-forget)
|
||||
if (isEmailConfigured() && user.email) {
|
||||
@@ -42,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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, TEST_EMAIL } from './helpers/fixtures.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
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(
|
||||
@@ -23,6 +37,36 @@ async function createVictim(
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a victim user with no password via the admin API, returning the user's id.
|
||||
* Omitting the password is the OAuth-only path: createUser() stores authProvider
|
||||
* 'none' with passwordLoginEnabled false until an admin reset enables it.
|
||||
*/
|
||||
async function createOAuthOnlyVictim(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string,
|
||||
displayName = email.split('@')[0]
|
||||
): Promise<number> {
|
||||
const res = await page.request.post('/api/admin/users', {
|
||||
data: { email, displayName, role: 'user' }
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const user = await res.json();
|
||||
expect(user.authProvider).toBe('none');
|
||||
expect(user.passwordLoginEnabled).toBe(false);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/** The id of the admin account the authenticated page is signed in as. */
|
||||
async function currentAdminId(page: import('@playwright/test').Page): Promise<number> {
|
||||
const res = await page.request.get('/api/admin/users');
|
||||
expect(res.status()).toBe(200);
|
||||
const users = (await res.json()) as Array<{ id: number; email: string }>;
|
||||
const admin = users.find((u) => u.email === TEST_EMAIL);
|
||||
if (!admin) throw new Error(`admin ${TEST_EMAIL} is missing from the users list`);
|
||||
return admin.id;
|
||||
}
|
||||
|
||||
test.describe.serial('Admin — User Deletion', () => {
|
||||
test('Scenario: Admin deletes a user from the users list', async ({
|
||||
authenticatedPage: page
|
||||
@@ -151,3 +195,832 @@ test.describe.serial('Admin — User Deletion', () => {
|
||||
expect(loginRes.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('Admin — Password Reset', () => {
|
||||
test('Scenario: An account can sign in with a password the admin set', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a password-auth user exists
|
||||
const email = 'reset-target@test.com';
|
||||
const victimId = await createVictim(page, email, 'Reset Target');
|
||||
|
||||
// When the admin sets a new password for that account
|
||||
const newPassword = `reset-${randomUUID()}`;
|
||||
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
|
||||
data: { newPassword }
|
||||
});
|
||||
|
||||
// Then the reset succeeds
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
// And the account can sign in with the new password. A separate browser
|
||||
// context keeps the resulting session cookie out of the admin's jar.
|
||||
const ctx = await browser.newContext();
|
||||
const loginRes = await ctx.request.post('/api/auth/login', {
|
||||
data: { email, password: newPassword }
|
||||
});
|
||||
expect(loginRes.status()).toBe(200);
|
||||
await ctx.close();
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: An OAuth-only account can be given a password by an admin', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given an OAuth-only user exists
|
||||
const email = 'oauth-only@test.com';
|
||||
const victimId = await createOAuthOnlyVictim(page, email, 'OAuth Only');
|
||||
|
||||
// When the admin sets a password for that account
|
||||
const newPassword = `oauth-pw-${randomUUID()}`;
|
||||
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
|
||||
data: { newPassword }
|
||||
});
|
||||
|
||||
// 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 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: newPassword }
|
||||
});
|
||||
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
|
||||
}) => {
|
||||
// Given the admin's own account
|
||||
const adminId = await currentAdminId(page);
|
||||
|
||||
// When the admin targets their own account for a password reset
|
||||
const res = await page.request.patch(`/api/admin/users/${adminId}`, {
|
||||
data: { newPassword: `self-${randomUUID()}` }
|
||||
});
|
||||
|
||||
// Then the attempt is rejected and the response says why
|
||||
expect(res.status()).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.message).toMatch(/your own password/i);
|
||||
});
|
||||
|
||||
test('Scenario: A password below the minimum length is rejected for its length, whatever the account', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given an OAuth-only user exists, who is also ineligible for a reset
|
||||
const victimId = await createOAuthOnlyVictim(page, 'short-pw-oauth@test.com', 'Short Pw');
|
||||
|
||||
// When the admin attempts to set a password shorter than the minimum
|
||||
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
|
||||
data: { newPassword: 'short' }
|
||||
});
|
||||
|
||||
// Then the length rule is what the response reports, taking precedence
|
||||
// over the eligibility rule
|
||||
expect(res.status()).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.message).toMatch(/at least 8 characters/i);
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
});
|
||||
|
||||
/** Open the users settings page and wait for it to hydrate. */
|
||||
async function openUsersPage(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/settings/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a row's password through the UI and return the revealed plaintext.
|
||||
* Trimmed because the reveal is markup-formatted text, not a form value.
|
||||
*/
|
||||
async function resetViaUi(
|
||||
page: import('@playwright/test').Page,
|
||||
victimId: number
|
||||
): Promise<string> {
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
const reveal = page.getByTestId(`generated-password-${victimId}`);
|
||||
await expect(reveal).toBeVisible();
|
||||
const revealed = (await reveal.textContent())?.trim() ?? '';
|
||||
expect(revealed.length).toBeGreaterThanOrEqual(8);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four row action buttons, in DOM order, named by their accessible name. An
|
||||
* eligible row that is not the signed-in admin's own offers all four.
|
||||
*/
|
||||
const ROW_ACTION_LABELS = [
|
||||
'Promote to admin',
|
||||
'Reset password',
|
||||
'Disable password login',
|
||||
'Revoke all sessions',
|
||||
'Delete user'
|
||||
] as const;
|
||||
|
||||
test.describe('Admin — Password Reset UI', () => {
|
||||
test('Scenario: Every user row offers a reset control, and an unavailable one says why', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user and an OAuth-only user exist
|
||||
const eligibleId = await createVictim(page, 'ui-reason-pw@test.com', 'UI Reason Pw');
|
||||
const oauthId = await createOAuthOnlyVictim(page, 'ui-reason-oauth@test.com', 'UI Reason OAuth');
|
||||
const adminId = await currentAdminId(page);
|
||||
|
||||
// When the admin views the users list
|
||||
const patchedIds: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.method() === 'PATCH' && req.url().includes('/api/admin/users/')) {
|
||||
patchedIds.push(req.url());
|
||||
}
|
||||
});
|
||||
await openUsersPage(page);
|
||||
|
||||
// Then the control is offered on every row, including the admin's own
|
||||
for (const id of [eligibleId, oauthId, adminId]) {
|
||||
await expect(page.getByTestId(`reset-password-btn-${id}`)).toBeVisible();
|
||||
}
|
||||
|
||||
// And it is available for 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}`);
|
||||
await expect(ownBtn).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(page.getByTestId(`reset-password-reason-${adminId}`)).toContainText(/Profile/i);
|
||||
|
||||
// And an unavailable control stays reachable by keyboard
|
||||
await expect(ownBtn).not.toHaveAttribute('disabled', /.*/);
|
||||
await ownBtn.focus();
|
||||
await expect(ownBtn).toBeFocused();
|
||||
|
||||
// And activating the admin's own control changes nothing
|
||||
await ownBtn.click({ force: true });
|
||||
await expect(page.getByTestId(`generated-password-${adminId}`)).toHaveCount(0);
|
||||
expect(patchedIds).toEqual([]);
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${eligibleId}`);
|
||||
await page.request.delete(`/api/admin/users/${oauthId}`);
|
||||
});
|
||||
|
||||
test('Scenario: An account signs in with the password the admin generated, and its old one stops working', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a password-auth user exists
|
||||
const email = 'ui-roundtrip@test.com';
|
||||
const victimId = await createVictim(page, email, 'UI Roundtrip');
|
||||
await openUsersPage(page);
|
||||
|
||||
// And the announcement region is present and silent
|
||||
await expect(page.getByTestId('reset-password-live')).toHaveText('');
|
||||
|
||||
// When the admin resets that account's password
|
||||
const patch = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'PATCH'
|
||||
);
|
||||
const revealed = await resetViaUi(page, victimId);
|
||||
expect((await patch).status()).toBe(200);
|
||||
|
||||
// Then the reveal is announced
|
||||
await expect(page.getByTestId('reset-password-live')).toContainText(/password/i);
|
||||
|
||||
// And that row's control is unavailable until the reveal is dismissed
|
||||
await expect(page.getByTestId(`reset-password-btn-${victimId}`)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
|
||||
// And the account signs in with the generated password, from a context whose
|
||||
// cookie jar is separate from the admin's
|
||||
const ctx = await browser.newContext();
|
||||
const newLogin = await ctx.request.post('/api/auth/login', {
|
||||
data: { email, password: revealed }
|
||||
});
|
||||
expect(newLogin.status()).toBe(200);
|
||||
|
||||
// And the password it had before is refused. 401 exactly: a 429 would mean the
|
||||
// shared test IP is rate-limited, which proves nothing about the credential.
|
||||
const oldLogin = await ctx.request.post('/api/auth/login', {
|
||||
data: { email, password: userPassword }
|
||||
});
|
||||
expect(oldLogin.status()).toBe(401);
|
||||
await ctx.close();
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A revealed password is never persisted or logged', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user exists
|
||||
const victimId = await createVictim(page, 'ui-nostore@test.com', 'UI No Store');
|
||||
const consoleMessages: string[] = [];
|
||||
page.on('console', (msg) => consoleMessages.push(msg.text()));
|
||||
await openUsersPage(page);
|
||||
|
||||
// When the admin resets that account's password
|
||||
const revealed = await resetViaUi(page, victimId);
|
||||
|
||||
// Then the plaintext reaches no persistent client storage
|
||||
const leaks = await page.evaluate(async (secret: string) => {
|
||||
const hits: string[] = [];
|
||||
const scanWebStorage = (store: Storage, label: string) => {
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const key = store.key(i);
|
||||
if (key === null) continue;
|
||||
if (key.includes(secret) || (store.getItem(key) ?? '').includes(secret)) {
|
||||
hits.push(`${label}:${key}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
scanWebStorage(localStorage, 'localStorage');
|
||||
scanWebStorage(sessionStorage, 'sessionStorage');
|
||||
|
||||
const databases = (await indexedDB.databases?.()) ?? [];
|
||||
for (const info of databases) {
|
||||
if (!info.name) continue;
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(info.name as string);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
for (const storeName of Array.from(db.objectStoreNames)) {
|
||||
const records = await new Promise<unknown[]>((resolve, reject) => {
|
||||
const request = db.transaction(storeName, 'readonly').objectStore(storeName).getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
let serialised = '';
|
||||
try {
|
||||
serialised = JSON.stringify(records) ?? '';
|
||||
} catch {
|
||||
serialised = '';
|
||||
}
|
||||
if (serialised.includes(secret)) hits.push(`indexedDB:${info.name}/${storeName}`);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
return hits;
|
||||
}, revealed);
|
||||
expect(leaks).toEqual([]);
|
||||
|
||||
// And it appears in no console output
|
||||
expect(consoleMessages.filter((m) => m.includes(revealed))).toEqual([]);
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A revealed password is shown whole on a phone-sized screen', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user seen on a 360px-wide screen
|
||||
const victimId = await createVictim(page, 'ui-narrow@test.com', 'UI Narrow');
|
||||
await page.setViewportSize({ width: 360, height: 800 });
|
||||
await openUsersPage(page);
|
||||
|
||||
// When the admin resets that account's password
|
||||
const revealed = await resetViaUi(page, victimId);
|
||||
|
||||
// Then none of it is clipped away — truncating it would quietly corrupt the
|
||||
// fallback of copying it by hand
|
||||
const shown = await page.getByTestId(`generated-password-${victimId}`).evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth,
|
||||
text: (el.textContent ?? '').trim()
|
||||
}));
|
||||
expect(shown.text).toBe(revealed);
|
||||
expect(shown.clientWidth).toBeGreaterThan(0);
|
||||
expect(shown.scrollWidth).toBeLessThanOrEqual(shown.clientWidth);
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: Copying a revealed password confirms that it was copied', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user exists and the clipboard is available
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
const victimId = await createVictim(page, 'ui-copy-ok@test.com', 'UI Copy Ok');
|
||||
await openUsersPage(page);
|
||||
|
||||
// When the admin resets the password and copies it
|
||||
const revealed = await resetViaUi(page, victimId);
|
||||
await page.getByTestId(`copy-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the copy is confirmed and the clipboard holds the password
|
||||
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/copied/i);
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(revealed);
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A refused clipboard leaves the password copyable by hand', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user exists and the clipboard refuses writes.
|
||||
// defineProperty, not assignment: navigator.clipboard is a getter-only
|
||||
// accessor, so assigning to it silently leaves the real API in place.
|
||||
const victimId = await createVictim(page, 'ui-copy-denied@test.com', 'UI Copy Denied');
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
get: () => ({
|
||||
writeText: () => Promise.reject(new DOMException('denied', 'NotAllowedError'))
|
||||
})
|
||||
});
|
||||
});
|
||||
await openUsersPage(page);
|
||||
|
||||
// When the admin resets the password and tries to copy it
|
||||
const revealed = await resetViaUi(page, victimId);
|
||||
await page.getByTestId(`copy-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the failure is reported as a manual-copy fallback
|
||||
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/by hand|manually/i);
|
||||
|
||||
// And the password is still shown, selectable, for copying by hand
|
||||
const reveal = page.getByTestId(`generated-password-${victimId}`);
|
||||
await expect(reveal).toBeVisible();
|
||||
expect((await reveal.textContent())?.trim()).toBe(revealed);
|
||||
expect(await reveal.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: Dismissing a reveal clears the password and leaves focus on that row', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user whose password was just reset
|
||||
const victimId = await createVictim(page, 'ui-dismiss@test.com', 'UI Dismiss');
|
||||
await openUsersPage(page);
|
||||
await resetViaUi(page, victimId);
|
||||
|
||||
// When the admin dismisses the reveal
|
||||
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the password is gone from the page
|
||||
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toHaveCount(0);
|
||||
|
||||
// And focus is back on that row's control, which is available again
|
||||
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
|
||||
await expect(btn).toBeFocused();
|
||||
await expect(btn).toHaveAttribute('aria-disabled', 'false');
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: Each of the three controls is large enough to hit on a phone', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user exists, seen on a phone-sized screen
|
||||
const victimId = await createVictim(page, 'ui-target@test.com', 'UI Target');
|
||||
await page.setViewportSize({ width: 360, height: 800 });
|
||||
await openUsersPage(page);
|
||||
|
||||
// When the admin resets that account's password
|
||||
await resetViaUi(page, victimId);
|
||||
|
||||
// Then every control in the flow meets the 44px minimum. Dismiss destroys the
|
||||
// only copy of the credential, so a cramped target is a data-loss risk.
|
||||
for (const testId of [
|
||||
`reset-password-btn-${victimId}`,
|
||||
`copy-password-btn-${victimId}`,
|
||||
`dismiss-password-btn-${victimId}`
|
||||
]) {
|
||||
const box = await page.getByTestId(testId).boundingBox();
|
||||
expect(box, testId).not.toBeNull();
|
||||
expect(Math.min(box!.width, box!.height), testId).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: Row actions stay full-size square targets at every phone width', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given 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 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, false],
|
||||
[320, false],
|
||||
[300, false]
|
||||
] as const) {
|
||||
// When the admin views the users list on a screen that wide
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await openUsersPage(page);
|
||||
const row = page.getByTestId(`user-row-${victimId}`);
|
||||
|
||||
const boxes = [];
|
||||
for (const label of ROW_ACTION_LABELS) {
|
||||
const box = await row.getByRole('button', { name: label, exact: true }).boundingBox();
|
||||
expect(box, `${label} at ${width}px`).not.toBeNull();
|
||||
boxes.push(box!);
|
||||
}
|
||||
|
||||
// Then every action is a square that clears the 44px minimum. One of them
|
||||
// deletes an account irreversibly, so an action that shrank to fit its row
|
||||
// would put a data-losing target under a thumb.
|
||||
for (const [i, box] of boxes.entries()) {
|
||||
const at = `${ROW_ACTION_LABELS[i]} at ${width}px`;
|
||||
expect(Math.min(box.width, box.height), at).toBeGreaterThanOrEqual(44);
|
||||
expect(Math.abs(box.width - box.height), at).toBeLessThan(1);
|
||||
}
|
||||
|
||||
// And they sit on one line wherever there is room for one
|
||||
const lines = new Set(boxes.map((box) => Math.round(box.y)));
|
||||
if (fitsOneLine) expect(lines.size, `lines at ${width}px`).toBe(1);
|
||||
|
||||
// And where there is not, they wrap rather than spill out of their row or
|
||||
// push the page sideways
|
||||
const rowBox = await row.boundingBox();
|
||||
expect(rowBox, `row at ${width}px`).not.toBeNull();
|
||||
for (const [i, box] of boxes.entries()) {
|
||||
expect(box.x + box.width, `${ROW_ACTION_LABELS[i]} at ${width}px`).toBeLessThanOrEqual(
|
||||
rowBox!.x + rowBox!.width
|
||||
);
|
||||
}
|
||||
const overflows = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
||||
);
|
||||
expect(overflows, `horizontal overflow at ${width}px`).toBe(false);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A refused reset reports the server reason and reveals no password', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user the server refuses to reset — an account whose sign-in method
|
||||
// changed after the list was loaded, so the row still looks eligible
|
||||
const victimId = await createVictim(page, 'ui-rejected@test.com', 'UI Rejected');
|
||||
const serverReason = 'Managed by SSO — password login is disabled for this account.';
|
||||
await openUsersPage(page);
|
||||
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue();
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: serverReason })
|
||||
});
|
||||
});
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the refusal is shown in that row, and announced
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(serverReason);
|
||||
await expect(page.getByTestId('reset-password-live')).toContainText(serverReason);
|
||||
|
||||
// And no password is revealed
|
||||
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
|
||||
|
||||
// And the control no longer invites a retry that would fail the same way
|
||||
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
|
||||
await expect(btn).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toContainText(serverReason);
|
||||
|
||||
// And dismissing the message keeps that explanation in place
|
||||
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toHaveCount(0);
|
||||
await expect(btn).toBeFocused();
|
||||
await expect(btn).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// Clean up
|
||||
await page.unroute(`**/api/admin/users/${victimId}`);
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A reset whose outcome is unknown keeps the generated password', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user whose reset response never arrives, so the server may or may
|
||||
// not have stored the new password
|
||||
const victimId = await createVictim(page, 'ui-unknown@test.com', 'UI Unknown');
|
||||
await openUsersPage(page);
|
||||
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue();
|
||||
await route.abort('connectionreset');
|
||||
});
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the uncertainty is stated
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
|
||||
/may or may not/i
|
||||
);
|
||||
|
||||
// And the generated password is kept, because it may be the account's only
|
||||
// working credential and nothing else holds a copy of it
|
||||
const reveal = page.getByTestId(`generated-password-${victimId}`);
|
||||
await expect(reveal).toBeVisible();
|
||||
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
|
||||
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
await page.unroute(`**/api/admin/users/${victimId}`);
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A server-side failure keeps the generated password even when it gives a reason', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user whose reset fails inside the server, with a reason attached —
|
||||
// a reason says why the server is unhappy, never whether it already stored
|
||||
// the password, so the outcome is unknown however readable the body is
|
||||
const victimId = await createVictim(page, 'ui-5xx-reason@test.com', 'UI 5xx Reason');
|
||||
await openUsersPage(page);
|
||||
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue();
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Database is temporarily read-only.' })
|
||||
});
|
||||
});
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the uncertainty is stated rather than the server's reason being taken
|
||||
// as a refusal
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
|
||||
/may or may not/i
|
||||
);
|
||||
|
||||
// And the generated password is kept, because the server may already hold it
|
||||
const reveal = page.getByTestId(`generated-password-${victimId}`);
|
||||
await expect(reveal).toBeVisible();
|
||||
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
// Clean up
|
||||
await page.unroute(`**/api/admin/users/${victimId}`);
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A rejection nobody can read keeps the generated password', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user whose reset comes back as an unreadable error page — what a
|
||||
// reverse proxy returns when it answers instead of the app, so the request may
|
||||
// have reached the server or may not have
|
||||
const victimId = await createVictim(page, 'ui-html-error@test.com', 'UI Html Error');
|
||||
await openUsersPage(page);
|
||||
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue();
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'text/html',
|
||||
body: '<html><body><h1>400 Bad Request</h1></body></html>'
|
||||
});
|
||||
});
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the uncertainty is stated rather than the status alone being read as a
|
||||
// refusal
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
|
||||
/may or may not/i
|
||||
);
|
||||
|
||||
// And the generated password is kept, because nothing said it was not stored
|
||||
const reveal = page.getByTestId(`generated-password-${victimId}`);
|
||||
await expect(reveal).toBeVisible();
|
||||
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
// And the control is not left explaining a reason it never received
|
||||
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toHaveCount(0);
|
||||
|
||||
// Clean up
|
||||
await page.unroute(`**/api/admin/users/${victimId}`);
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: A reset attempted offline reports that nothing was changed', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user exists and the browser has no connection
|
||||
const victimId = await createVictim(page, 'ui-offline@test.com', 'UI Offline');
|
||||
await openUsersPage(page);
|
||||
const patchRequests: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.method() === 'PATCH') patchRequests.push(req.url());
|
||||
});
|
||||
await page.context().setOffline(true);
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the outcome is stated as definite: nothing left the device
|
||||
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
|
||||
/offline.*nothing was changed/i
|
||||
);
|
||||
expect(patchRequests).toEqual([]);
|
||||
|
||||
// And no password is revealed, since none was set
|
||||
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
|
||||
|
||||
// Restore connectivity — Playwright does not reliably emit the online event
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: One row\'s reset never touches another row', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given two password-auth users exist
|
||||
const firstId = await createVictim(page, 'ui-isolation-a@test.com', 'UI Isolation A');
|
||||
const secondId = await createVictim(page, 'ui-isolation-b@test.com', 'UI Isolation B');
|
||||
await openUsersPage(page);
|
||||
|
||||
// When the first account's password is reset
|
||||
const revealed = await resetViaUi(page, firstId);
|
||||
|
||||
// Then the second row shows neither a password nor a message
|
||||
await expect(page.getByTestId(`generated-password-${secondId}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`reset-password-btn-${secondId}`)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false'
|
||||
);
|
||||
|
||||
// And when the second account's reset is refused
|
||||
await page.route(`**/api/admin/users/${secondId}`, async (route) => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue();
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Password login is not available for this account.' })
|
||||
});
|
||||
});
|
||||
await page.getByTestId(`reset-password-btn-${secondId}`).click();
|
||||
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toBeVisible();
|
||||
|
||||
// Then the first row keeps its password and stays free of that message
|
||||
expect((await page.getByTestId(`generated-password-${firstId}`).textContent())?.trim()).toBe(
|
||||
revealed
|
||||
);
|
||||
await expect(page.getByTestId(`reset-password-error-${firstId}`)).toHaveCount(0);
|
||||
|
||||
// Clean up
|
||||
await page.unroute(`**/api/admin/users/${secondId}`);
|
||||
await page.request.delete(`/api/admin/users/${firstId}`);
|
||||
await page.request.delete(`/api/admin/users/${secondId}`);
|
||||
});
|
||||
|
||||
test('Scenario: Resetting an account that no longer exists drops it from the list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user who is deleted after the list was loaded
|
||||
const victimId = await createVictim(page, 'ui-missing@test.com', 'UI Missing');
|
||||
await openUsersPage(page);
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
|
||||
expect((await page.request.delete(`/api/admin/users/${victimId}`)).status()).toBe(200);
|
||||
|
||||
// When the admin resets that account's password
|
||||
await page.getByTestId(`reset-password-btn-${victimId}`).click();
|
||||
|
||||
// Then the row disappears and no password is left behind
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: A reset in flight cannot be issued twice', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a password-auth user whose reset response is slow to arrive
|
||||
const victimId = await createVictim(page, 'ui-inflight@test.com', 'UI In Flight');
|
||||
await openUsersPage(page);
|
||||
let patchCount = 0;
|
||||
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue();
|
||||
patchCount++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// When the admin activates the control twice in a row
|
||||
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
|
||||
await btn.click();
|
||||
await expect(btn).toHaveAccessibleName(/Resetting/);
|
||||
await expect(btn).toHaveAttribute('aria-disabled', 'true');
|
||||
await btn.click({ force: true });
|
||||
|
||||
// Then only one reset is issued
|
||||
await expect(page.getByTestId(`generated-password-${victimId}`)).toBeVisible();
|
||||
expect(patchCount).toBe(1);
|
||||
|
||||
// Clean up
|
||||
await page.unroute(`**/api/admin/users/${victimId}`);
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const DEFAULT_PREFS = {
|
||||
@@ -81,6 +81,30 @@ test.describe('Settings — Preferences', () => {
|
||||
await expect(page.getByTestId('note-content-input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Default note mode applies when reopening an existing note', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
await resetPreferences(page);
|
||||
|
||||
// Given a note titled "Existing Markdown Note" exists
|
||||
const title = `Existing Markdown Note ${Date.now()}`;
|
||||
await page.request.post('/api/notes', { data: { title, content: 'Some **bold** text' } });
|
||||
|
||||
// And the user's default note mode is Markdown
|
||||
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
||||
await page.getByTestId('pref-mode-markdown').click();
|
||||
await putResponse;
|
||||
|
||||
// When the user opens the note
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await noteCard(page, title).click();
|
||||
|
||||
// Then the note editor opens in markdown mode showing the raw source
|
||||
await expect(page.getByTestId('note-content-input')).toBeVisible();
|
||||
await expect(page.getByTestId('note-content-input')).toHaveValue('Some **bold** text');
|
||||
});
|
||||
|
||||
test('Scenario: Footer toggle hides and shows footer', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
|
||||
+24
-1
@@ -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
@@ -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.32.0</span>
|
||||
<span class="footer-version">v0.35.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user