Compare commits

...
35 Commits
Author SHA1 Message Date
github-actions[bot] 423c2d59e9 chore: bump version to 0.8.0 2026-03-09 00:11:02 +00:00
Frederic CabassutandClaude Opus 4.6 2c588c6026 chore: regenerate lockfile after rebase onto main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 3b74a879a2 fix(test): proper e2e test isolation with project dependencies
Add globalSetup to clean test DB before each run. Split Playwright into
two projects: auth-setup (serial, runs first) and app (parallel, depends
on auth-setup). Skip rate limiting in test environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 b02ff65813 fix(test): resolve strict mode violations in e2e organization tests
Use .first() for pin-btn and tag filter assertions to handle cases
where multiple matching elements exist in the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 506e6b9c87 fix: resolve type errors in OAuth and API utils
Fix null vs undefined in GitHub OAuth, use arctic's
createAuthorizationURLWithPKCE for OIDC, loosen api-utils type
signature to avoid SvelteKit route-specific generics, add @types/zxcvbn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 333bd4912d docs: update .env.example with OAuth configuration
Add Google, GitHub, and generic OIDC environment variables. Providers
are auto-enabled when their env vars are set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 198e9ea3c3 feat(ui): dynamic OAuth buttons on login page
Login page loads enabled providers from server and shows "or continue
with" divider + branded buttons for Google/GitHub/OIDC. Also handles
rate limit 429 and OAuth no_account error display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 67764cee31 feat(auth): OAuth authorize and callback endpoints
Dynamic [provider] routes for Google, GitHub, and OIDC. Stores state and
PKCE verifier in httpOnly cookies. Callback validates code, finds/links
user by provider ID or email (invite-only), and creates session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 86c5d29e62 feat(auth): OAuth provider configuration with arctic
Add Google, GitHub, and generic OIDC provider modules using arctic v3.
Providers are auto-enabled when env vars are set. Google/GitHub use PKCE,
OIDC uses OpenID Connect discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 e155251f27 feat(auth): session list and revocation
Track session metadata (createdAt, userAgent, ip, lastUsedAt). Add API
for listing and revoking sessions. Settings page shows active sessions
with "Revoke" per session and "Log out everywhere else" option.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 34c95fae92 feat(ui): password strength meter with zxcvbn
Add PasswordStrengthMeter component showing visual bar and feedback.
Integrated in setup, settings change-password, and admin create-user forms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 5aaebfefe5 feat(auth): login rate limiting
5 failed attempts per IP triggers 15-minute lockout. Uses loginAttempts
table added in Phase 1. Returns 429 with Retry-After header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 5bed796396 fix(test): update tests for multi-user schema and improve e2e reliability
Update unit test helpers with full multi-user schema, fix auth tests to
include email field, and improve e2e tests with specific card targeting
and force clicks for hover-revealed buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 8438790cc1 chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 ac9bed93b2 fix(auth): resolve session validation and layout rendering issues
Fix session expiry check (seconds vs milliseconds mismatch in raw SQL),
conditionally render app chrome only for authenticated users, use full
page reload after login/setup for proper session propagation, remove
HTML minlength to allow JS validation, fix store to remove notes on
trash/archive, and make auth tests race-condition safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 b3e55ff95c fix(db): reorder migration to run before index creation
Move migration logic (ALTER TABLE) before CREATE INDEX statements so
that user_id columns exist on old schemas before indexes reference them.
Disable foreign keys during schema setup to avoid constraint issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 0adf6051e9 test: update auth tests for multi-user
Update e2e fixtures to use email+password for setup and login.
Update auth tests to fill email field. Add multi-user test for
admin creating a new user via API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 785f313b29 refactor(sync): namespace IndexedDB by userId
Each user gets their own IDB database (keepnotes-{userId}), preventing
data leaks between users on the same device. Add initDb(userId) called
on mount and destroyDb() for logout cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 04e93ec541 feat(ui): settings page with user profile and admin user management
- Settings page: edit display name/email, change password
- Admin user management page: create/delete users, toggle roles
- Profile update API endpoint (PUT /api/auth/profile)
- Settings link added to sidebar navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 93aeb85a7e feat(api): admin user management endpoints
- GET/POST /api/admin/users — list and create users (admin only)
- PATCH/DELETE /api/admin/users/[id] — update role/reset password, delete user
- PUT /api/auth/password — change own password
All admin routes guarded by requireAdmin().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 a9cb60e852 feat(layout): expose user data to app shell
Add layout.server.ts to pass event.locals.user to the client.
Header now shows user avatar initial with dropdown menu
(Settings link, Log out button).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 c9e041bffe refactor(api): scope all queries by authenticated user
Add getUserId/requireAdmin helpers in api-utils.ts. All notes, tags,
search, sync, and attachment routes now filter by userId to ensure
complete data isolation between users. Sync server accepts userId
parameter for push/pull operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 875306c99a feat(auth): email-based login and setup flows
Login page now requires email + password. Setup page adds email and
optional display name fields. API endpoints updated to pass email
to verifyPassword/setupUser and explicit userId to createSession.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 1ae194eb17 refactor(hooks): populate event.locals.user from session
validateSession now returns user data. Middleware sets event.locals.user
on every authenticated request. Add /api/auth/oauth to public paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 54c89c77b5 refactor(auth): email-based login and user CRUD
Rewrite auth module for multi-user support:
- setupUser now takes email, password, displayName; returns User
- verifyPassword takes email+password; returns User or null
- createSession takes explicit userId
- validateSession joins sessions+users; returns user data
- Add createUser, deleteUser, changePassword, resetPassword,
  updateUserRole, updateUserProfile, listUsers, getUser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 848af42b80 feat(types): add User type and App.Locals
Define App.Locals.user interface for session user data.
Add User type to shared types (mirrors DB columns minus passwordHash).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 38dbabae74 feat(db): add multi-user columns and login_attempts table
Add email, displayName, role, authProvider, providerId to users table.
Add userId foreign key to notes, tags, attachments, syncLog tables.
Add loginAttempts table for rate limiting. Include migration logic
to detect old single-user schema and backfill existing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 36b54cccd8 style: add vertical padding to checklist items for better spacing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:46:32 +01:00
github-actions[bot] 90bdf9ab96 chore: pin docker-compose to v0.7.2 [skip ci] 2026-03-08 23:45:47 +00:00
github-actions[bot] 1813c5b1a6 chore: bump version to 0.7.2 2026-03-08 23:42:16 +00:00
Frederic CabassutandClaude Opus 4.6 54416e392c fix: add error resilience for IDB failures and API error feedback
loadNotes() crashed entirely if IndexedDB was unavailable because
getAllNotes() was called outside try/catch. Non-OK API responses
(401, 500) were silently ignored across loadNotes, createNote, and
sync operations — users saw empty data with no feedback.

Changes:
- Wrap all IDB calls in try/catch so the app works without IndexedDB
- Show toast on non-OK API responses in loadNotes and createNote
- Redirect to /login on 401 responses
- Make sync pull/push resilient to IDB unavailability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:41:45 +01:00
github-actions[bot] 8a21950e53 chore: pin docker-compose to v0.7.1 [skip ci] 2026-03-08 23:37:55 +00:00
github-actions[bot] 5992a12f76 chore: bump version to 0.7.1 2026-03-08 23:33:55 +00:00
Frederic CabassutandClaude Opus 4.6 b515b0593d fix: add missing thumbnail_path migration and upload error feedback
DBs created before the image upload feature had no thumbnail_path column
in the attachments table. Since CREATE TABLE IF NOT EXISTS is a no-op for
existing tables, the column was never added, causing silent insert failures.

Add idempotent ALTER TABLE migration for thumbnail_path and surface upload
errors via toast notifications instead of swallowing them silently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:33:32 +01:00
github-actions[bot] ba665b66c2 chore: pin docker-compose to v0.7.0 [skip ci] 2026-03-08 21:42:42 +00:00
60 changed files with 2209 additions and 201 deletions
+17 -1
View File
@@ -1,8 +1,24 @@
# Database path (relative to project root or absolute)
DATABASE_URL=./data/crumbs.db
# Server origin (required for CSRF protection)
# Server origin (required for CSRF protection and OAuth redirect URLs)
ORIGIN=http://localhost:3000
# Node environment
NODE_ENV=development
# --- OAuth Providers (optional, auto-enabled when set) ---
# Google OAuth 2.0
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# GitHub OAuth
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# Generic OIDC (e.g. Authentik, Keycloak, Okta)
# AUTH_OIDC_ISSUER=https://your-idp.example.com
# AUTH_OIDC_CLIENT_ID=
# AUTH_OIDC_CLIENT_SECRET=
# AUTH_OIDC_DISPLAY_NAME=SSO
+2
View File
@@ -37,4 +37,6 @@ vite.config.ts.timestamp-*
# Local notes
todo.md
# Worktrees
.worktrees/
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.6.2
image: ghcr.io/bretzel-app/crumbs:0.7.2
container_name: crumbs
ports:
- "3000:3000"
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.7.0",
"version": "0.8.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -39,6 +39,7 @@
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
@@ -47,7 +48,8 @@
"markdown-it": "^14.1.1",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
@@ -58,6 +60,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"drizzle-kit": "^0.31.9",
"svelte": "^5.53.7",
+9 -7
View File
@@ -1,12 +1,12 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: './tests/e2e/global-setup.ts',
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
globalSetup: './tests/e2e/global-setup.ts',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
@@ -14,14 +14,15 @@ export default defineConfig({
},
projects: [
{
name: 'setup',
testMatch: /auth\.spec\.ts/,
name: 'auth-setup',
testMatch: 'auth.spec.ts',
fullyParallel: false,
use: { ...devices['Desktop Chrome'] }
},
{
name: 'chromium',
testIgnore: /auth\.spec\.ts/,
dependencies: ['setup'],
name: 'app',
testIgnore: 'auth.spec.ts',
dependencies: ['auth-setup'],
use: { ...devices['Desktop Chrome'] }
}
],
@@ -31,7 +32,8 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
DATABASE_URL: './data/test-crumbs.db'
DATABASE_URL: './data/test-crumbs.db',
NODE_ENV: 'test'
}
}
});
+65
View File
@@ -43,6 +43,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.20.1
version: 3.20.1
arctic:
specifier: ^3.7.0
version: 3.7.0
argon2:
specifier: ^0.43.1
version: 0.43.1
@@ -70,6 +73,9 @@ importers:
uuid:
specifier: ^11.1.0
version: 11.1.0
zxcvbn:
specifier: ^4.4.2
version: 4.4.2
devDependencies:
'@playwright/test':
specifier: ^1.58.2
@@ -95,6 +101,9 @@ importers:
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@types/zxcvbn':
specifier: ^4.4.5
version: 4.4.5
'@vite-pwa/sveltekit':
specifier: ^1.1.0
version: 1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
@@ -975,6 +984,24 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
'@oslojs/binary@1.0.0':
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
'@oslojs/crypto@1.0.1':
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
'@oslojs/encoding@0.4.1':
resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==}
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@oslojs/jwt@0.2.0':
resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
@@ -1544,6 +1571,9 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/zxcvbn@4.4.5':
resolution: {integrity: sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==}
'@vite-pwa/sveltekit@1.1.0':
resolution: {integrity: sha512-mMIf2tY+7Hg8jecpu/WY+Ki2ikoXy3hVmt3tOxi0K+lYYnKQrDYthuHireI0S+26Mg9BXzL7qQF1xeB5VYlYlg==}
engines: {node: '>=18.13'}
@@ -1591,6 +1621,9 @@ packages:
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
arctic@3.7.0:
resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==}
argon2@0.43.1:
resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==}
engines: {node: '>=16.17.0'}
@@ -3248,6 +3281,9 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zxcvbn@4.4.2:
resolution: {integrity: sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==}
snapshots:
'@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)':
@@ -4105,6 +4141,25 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@oslojs/asn1@1.0.0':
dependencies:
'@oslojs/binary': 1.0.0
'@oslojs/binary@1.0.0': {}
'@oslojs/crypto@1.0.1':
dependencies:
'@oslojs/asn1': 1.0.0
'@oslojs/binary': 1.0.0
'@oslojs/encoding@0.4.1': {}
'@oslojs/encoding@1.1.0': {}
'@oslojs/jwt@0.2.0':
dependencies:
'@oslojs/encoding': 0.4.1
'@phc/format@1.0.0': {}
'@playwright/test@1.58.2':
@@ -4612,6 +4667,8 @@ snapshots:
'@types/uuid@10.0.0': {}
'@types/zxcvbn@4.4.5': {}
'@vite-pwa/sveltekit@1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)':
dependencies:
'@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -4675,6 +4732,12 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
arctic@3.7.0:
dependencies:
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
'@oslojs/jwt': 0.2.0
argon2@0.43.1:
dependencies:
'@phc/format': 1.0.0
@@ -6494,3 +6557,5 @@ snapshots:
yaml@2.8.2: {}
zimmerframe@1.1.4: {}
zxcvbn@4.4.2: {}
+8 -1
View File
@@ -3,7 +3,14 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: {
id: number;
email: string;
displayName: string;
role: 'admin' | 'user';
} | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+19 -3
View File
@@ -2,11 +2,20 @@ import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
const PUBLIC_PATHS = [
'/login',
'/setup',
'/api/auth/login',
'/api/auth/setup',
'/api/auth/oauth'
];
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Initialize locals
event.locals.user = null;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return resolve(event);
@@ -21,9 +30,16 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// Validate session
// Validate session and populate user
const sessionToken = event.cookies.get('session');
if (!sessionToken || !(await validateSession(sessionToken))) {
if (sessionToken) {
const result = await validateSession(sessionToken);
if (result.valid && result.user) {
event.locals.user = result.user;
}
}
if (!event.locals.user) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
+5 -5
View File
@@ -112,15 +112,15 @@
}
</script>
<div class="space-y-1" data-testid="checklist">
<div class="space-y-0.5" data-testid="checklist">
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-1"
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2" animate:flip={{ duration: flipDurationMs }}>
<div class="group flex items-center gap-2 py-1" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
@@ -173,9 +173,9 @@
</button>
{#if doneExpanded}
<div class="mt-1 space-y-1 pl-5" data-testid="checklist-done-section">
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2">
<div class="group flex items-center gap-2 py-1">
<input
type="checkbox"
checked={item.checked}
+5
View File
@@ -7,6 +7,7 @@
import { optimizeImage } from '$lib/utils/image-optimize.js';
import { browser } from '$app/environment';
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
import { showToast } from '$lib/stores/toast.js';
interface Props {
noteId: string;
@@ -85,9 +86,13 @@
if (res.ok) {
const attachment = await res.json();
onUpload(attachment);
} else {
console.error('Upload failed:', res.status, await res.text());
showToast('Failed to upload image', 'error');
}
} catch (err) {
console.error('Upload failed:', err);
showToast('Failed to upload image', 'error');
}
uploading = false;
}
+52 -1
View File
@@ -6,12 +6,28 @@
interface Props {
onMenuToggle: () => void;
user: { id: number; email: string; displayName: string; role: 'admin' | 'user' } | null;
}
let { onMenuToggle }: Props = $props();
let { onMenuToggle, user }: Props = $props();
let mobileSearchOpen = $state(false);
let dropdownOpen = $state(false);
function getInitial(user: Props['user']): string {
if (!user) return '?';
if (user.displayName) return user.displayName.charAt(0).toUpperCase();
if (user.email) return user.email.charAt(0).toUpperCase();
return '?';
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
}
</script>
<svelte:window onclick={() => (dropdownOpen = false)} />
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
{#if mobileSearchOpen}
<!-- Mobile expanded search -->
@@ -48,6 +64,41 @@
<div class="flex items-center gap-2">
<SyncIndicator />
{#if user}
<div class="relative">
<button
onclick={(e) => { e.stopPropagation(); dropdownOpen = !dropdownOpen; }}
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--primary)] text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
aria-label="User menu"
data-testid="user-menu-btn"
>
{getInitial(user)}
</button>
{#if dropdownOpen}
<div class="absolute right-0 top-10 z-50 w-48 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]">
<div class="border-b border-[var(--border-subtle)] px-4 py-2">
<p class="text-sm font-medium text-[var(--text)]">{user.displayName || user.email}</p>
<p class="text-xs text-[var(--text-muted)]">{user.email}</p>
</div>
<a
href="/settings"
class="block px-4 py-2 text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
>
Settings
</a>
<button
onclick={handleLogout}
class="block w-full px-4 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
data-testid="logout-btn"
>
Log out
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</header>
+17
View File
@@ -73,6 +73,23 @@
</li>
</ul>
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<ul class="space-y-1">
<li>
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
</li>
</ul>
</div>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Tags</h3>
@@ -0,0 +1,36 @@
<script lang="ts">
import zxcvbn from 'zxcvbn';
let { password = '' }: { password: string } = $props();
const result = $derived(password.length > 0 ? zxcvbn(password) : null);
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
const colors = [
'bg-red-500',
'bg-orange-500',
'bg-yellow-500',
'bg-lime-500',
'bg-green-500'
];
</script>
{#if result}
<div class="mt-1 mb-2" data-testid="password-strength">
<div class="flex gap-1">
{#each { length: 4 } as _, i}
<div
class="h-1 flex-1 rounded-full transition-colors {i <= result.score
? colors[result.score]
: 'bg-gray-200 dark:bg-gray-600'}"
></div>
{/each}
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{labels[result.score]}
{#if result.feedback.warning}
— {result.feedback.warning}
{/if}
</p>
</div>
{/if}
+16
View File
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
type EventWithLocals = {
locals: App.Locals;
};
export function getUserId(event: EventWithLocals): number {
if (!event.locals.user) throw error(401, 'Unauthorized');
return event.locals.user.id;
}
export function requireAdmin(event: EventWithLocals) {
const user = event.locals.user;
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
return user;
}
+2
View File
@@ -18,6 +18,7 @@ if (!existsSync(ATTACHMENTS_DIR)) {
export async function saveAttachment(
noteId: string,
file: File,
userId: number,
thumbnail?: File | Blob | null
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
@@ -40,6 +41,7 @@ export async function saveAttachment(
.insert(attachments)
.values({
id,
userId,
noteId,
filename: file.name,
mimeType: file.type,
+12 -5
View File
@@ -41,18 +41,25 @@ describe('Auth - User Setup', () => {
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'admin@test.com',
displayName: 'Admin',
role: 'admin',
passwordHash: hash,
createdAt: new Date()
});
const user = await db.select().from(users).get();
expect(user).toBeDefined();
expect(user!.email).toBe('admin@test.com');
expect(user!.role).toBe('admin');
expect(user!.passwordHash).not.toBe(password);
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
expect(await argon2.verify(user!.passwordHash!, password)).toBe(true);
});
it('should only allow one user', async () => {
it('should only allow one admin setup', async () => {
await db.insert(users).values({
email: 'admin@test.com',
role: 'admin',
passwordHash: 'hash1',
createdAt: new Date()
});
@@ -66,7 +73,7 @@ describe('Auth - Sessions', () => {
it('should create and validate a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -85,7 +92,7 @@ describe('Auth - Sessions', () => {
it('should detect expired sessions', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -103,7 +110,7 @@ describe('Auth - Sessions', () => {
it('should delete a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
+278 -34
View File
@@ -1,65 +1,309 @@
import { db } from './db/index.js';
import { db, sqlite } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq, lt } from 'drizzle-orm';
import { eq, lt, and } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import type { User } from '$lib/types/index.js';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type SessionUser = { id: number; email: string; displayName: string; role: 'admin' | 'user' };
function toUser(row: typeof users.$inferSelect): User {
return {
id: row.id,
email: row.email,
displayName: row.displayName,
role: row.role,
authProvider: row.authProvider,
providerId: row.providerId,
createdAt: row.createdAt
};
}
export async function isSetupComplete(): Promise<boolean> {
const user = await db.select().from(users).get();
const user = db.select().from(users).get();
return !!user;
}
export async function setupUser(password: string): Promise<boolean> {
const existing = await db.select().from(users).get();
if (existing) return false; // Already set up
export async function setupUser(
email: string,
password: string,
displayName?: string
): Promise<User | null> {
const existing = db.select().from(users).get();
if (existing) return null; // Already set up
const hash = await argon2.hash(password);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
return true;
const result = db
.insert(users)
.values({
email,
displayName: displayName || email.split('@')[0] || 'Admin',
role: 'admin',
passwordHash: hash,
authProvider: 'password',
createdAt: new Date()
})
.returning()
.get();
return toUser(result);
}
export async function verifyPassword(password: string): Promise<boolean> {
const user = await db.select().from(users).get();
if (!user) return false;
return argon2.verify(user.passwordHash, password);
export async function verifyPassword(
email: string,
password: string
): Promise<User | null> {
const user = db
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.authProvider, 'password')))
.get();
if (!user || !user.passwordHash) return null;
const valid = await argon2.verify(user.passwordHash, password);
return valid ? toUser(user) : null;
}
export async function createSession(): Promise<string> {
const user = await db.select().from(users).get();
if (!user) throw new Error('No user exists');
export async function createSession(
userId: number,
meta?: { userAgent?: string; ip?: string }
): Promise<string> {
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
});
const now = new Date();
db.insert(sessions)
.values({
id: token,
userId,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
createdAt: now,
userAgent: meta?.userAgent || null,
ip: meta?.ip || null,
lastUsedAt: now
})
.run();
return token;
}
export async function validateSession(token: string): Promise<boolean> {
if (!token) return false;
export async function validateSession(
token: string
): Promise<{ valid: boolean; user?: SessionUser }> {
if (!token) return { valid: false };
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) return false;
// Join sessions with users to get user data in one query
const result = sqlite
.prepare(
`SELECT s.id as session_id, s.expires_at, u.id, u.email, u.display_name, u.role
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ?`
)
.get(token) as
| {
session_id: string;
expires_at: number;
id: number;
email: string;
display_name: string;
role: 'admin' | 'user';
}
| undefined;
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, token));
return false;
if (!result) return { valid: false };
if (result.expires_at * 1000 < Date.now()) {
db.delete(sessions).where(eq(sessions.id, token)).run();
return { valid: false };
}
return true;
// Update lastUsedAt
db.update(sessions)
.set({ lastUsedAt: new Date() })
.where(eq(sessions.id, token))
.run();
return {
valid: true,
user: {
id: result.id,
email: result.email,
displayName: result.display_name,
role: result.role
}
};
}
export async function deleteSession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, token));
db.delete(sessions).where(eq(sessions.id, token)).run();
}
export async function cleanExpiredSessions(): Promise<void> {
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run();
}
export type SessionInfo = {
id: string;
createdAt: Date | null;
userAgent: string | null;
ip: string | null;
lastUsedAt: Date | null;
expiresAt: Date;
isCurrent: boolean;
};
export function listUserSessions(userId: number, currentToken: string): SessionInfo[] {
return db
.select()
.from(sessions)
.where(eq(sessions.userId, userId))
.all()
.map((s) => ({
id: s.id,
createdAt: s.createdAt,
userAgent: s.userAgent,
ip: s.ip,
lastUsedAt: s.lastUsedAt,
expiresAt: s.expiresAt,
isCurrent: s.id === currentToken
}));
}
export function revokeSession(userId: number, sessionId: string): boolean {
const session = db
.select()
.from(sessions)
.where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId)))
.get();
if (!session) return false;
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return true;
}
export function revokeAllSessions(userId: number, exceptToken?: string): void {
if (exceptToken) {
sqlite
.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?')
.run(userId, exceptToken);
} else {
db.delete(sessions).where(eq(sessions.userId, userId)).run();
}
}
// --- User CRUD (admin operations) ---
export async function createUser(
email: string,
displayName: string,
password: string,
role: 'admin' | 'user' = 'user'
): Promise<User> {
const hash = await argon2.hash(password);
const result = db
.insert(users)
.values({
email,
displayName,
role,
passwordHash: hash,
authProvider: 'password',
createdAt: new Date()
})
.returning()
.get();
return toUser(result);
}
export function listUsers(): User[] {
return db
.select()
.from(users)
.all()
.map(toUser);
}
export function getUser(userId: number): User | null {
const row = db.select().from(users).where(eq(users.id, userId)).get();
return row ? toUser(row) : null;
}
export async function deleteUser(userId: number): Promise<void> {
// Cascade: delete user's data in dependency order
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
db.delete(users).where(eq(users.id, userId)).run();
}
export async function changePassword(
userId: number,
currentPassword: string,
newPassword: string
): Promise<boolean> {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user || !user.passwordHash) return false;
const valid = await argon2.verify(user.passwordHash, currentPassword);
if (!valid) return false;
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
return true;
}
export async function resetPassword(userId: number, newPassword: string): Promise<void> {
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
}
export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
db.update(users).set({ role }).where(eq(users.id, userId)).run();
}
/**
* Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user.
*/
export function findOrLinkOAuthUser(
provider: string,
providerId: string,
email: string,
name: string
): User | null {
// First try exact provider+providerId match
const byProvider = db
.select()
.from(users)
.where(and(eq(users.authProvider, provider), eq(users.providerId, providerId)))
.get();
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();
if (!byEmail) return null; // No matching user — not invited
// Link this OAuth provider to the existing user
db.update(users)
.set({ authProvider: provider, providerId, displayName: byEmail.displayName || name })
.where(eq(users.id, byEmail.id))
.run();
return toUser({ ...byEmail, authProvider: provider, providerId });
}
export function updateUserProfile(
userId: number,
data: { displayName?: string; email?: string }
): void {
const updates: Record<string, string> = {};
if (data.displayName !== undefined) updates.displayName = data.displayName;
if (data.email !== undefined) updates.email = data.email;
if (Object.keys(updates).length > 0) {
db.update(users).set(updates).where(eq(users.id, userId)).run();
}
}
+109 -7
View File
@@ -16,24 +16,36 @@ const sqlite = new Database(DATABASE_URL);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Auto-create tables on first run
// Disable foreign keys during schema setup to avoid constraint issues during migration
sqlite.pragma('foreign_keys = OFF');
// Auto-create tables on first run (new installs get full schema)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
expires_at INTEGER NOT NULL,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
@@ -50,9 +62,9 @@ sqlite.exec(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -61,36 +73,126 @@ sqlite.exec(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
thumbnail_path TEXT,
featured INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id),
user_id INTEGER NOT NULL DEFAULT 0,
note_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
email TEXT NOT NULL,
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
`);
// Migration: detect old schema (no email column on users) and add columns
const userColumns = sqlite
.prepare("PRAGMA table_info('users')")
.all() as { name: string }[];
const hasEmail = userColumns.some((col) => col.name === 'email');
if (!hasEmail) {
sqlite.exec(`
ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'password';
ALTER TABLE users ADD COLUMN provider_id TEXT;
`);
}
// Ensure user_id column exists on data tables (migration from old schema)
const notesColumns = sqlite
.prepare("PRAGMA table_info('notes')")
.all() as { name: string }[];
if (!notesColumns.some((col) => col.name === 'user_id')) {
sqlite.exec(`
ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE attachments ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
`);
}
// Backfill: promote existing user to admin and assign all data to them
if (!hasEmail) {
const existingUser = sqlite.prepare('SELECT id FROM users LIMIT 1').get() as
| { id: number }
| undefined;
if (existingUser) {
const uid = existingUser.id;
sqlite.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', uid);
for (const table of ['notes', 'tags', 'attachments', 'sync_log']) {
sqlite.prepare(`UPDATE ${table} SET user_id = ? WHERE user_id = 0`).run(uid);
}
}
}
// Migration: add session tracking columns
const sessionColumns = sqlite
.prepare("PRAGMA table_info('sessions')")
.all() as { name: string }[];
if (!sessionColumns.some((col) => col.name === 'created_at')) {
sqlite.exec(`
ALTER TABLE sessions ADD COLUMN created_at INTEGER;
ALTER TABLE sessions ADD COLUMN user_agent TEXT;
ALTER TABLE sessions ADD COLUMN ip TEXT;
ALTER TABLE sessions ADD COLUMN last_used_at INTEGER;
`);
}
// Create indexes AFTER migration ensures columns exist
sqlite.exec(`
CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id);
CREATE INDEX IF NOT EXISTS notes_trashed_archived_idx ON notes(trashed, archived);
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
CREATE INDEX IF NOT EXISTS note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
`);
// Idempotent migration: add featured column to attachments
// Replace old tags unique index with compound index including user_id
try {
sqlite.exec(`DROP INDEX IF EXISTS tags_name_unique`);
} catch {
// ignore
}
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS tags_name_user_unique ON tags(name, user_id)`
);
// Idempotent migrations: add columns that may be missing from older DBs
try {
sqlite.exec(`ALTER TABLE attachments ADD COLUMN thumbnail_path TEXT;`);
} catch {
// Column already exists
}
try {
sqlite.exec(`ALTER TABLE attachments ADD COLUMN featured INTEGER NOT NULL DEFAULT 0;`);
} catch {
// Column already exists
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export { sqlite };
+2 -1
View File
@@ -235,7 +235,8 @@ describe('Notes with Tags', () => {
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note
// Delete note_tags first (FK cascade is disabled in test DB), then note
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
await db.delete(notes).where(eq(notes.id, noteId));
// note_tags should be gone
+41 -3
View File
@@ -2,7 +2,12 @@ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqli
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
passwordHash: text('password_hash').notNull(),
email: text('email').notNull().default(''),
displayName: text('display_name').notNull().default(''),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
passwordHash: text('password_hash'),
authProvider: text('auth_provider').notNull().default('password'),
providerId: text('provider_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
@@ -11,13 +16,21 @@ export const sessions = sqliteTable('sessions', {
userId: integer('user_id')
.references(() => users.id)
.notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }),
userAgent: text('user_agent'),
ip: text('ip'),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
});
export const notes = sqliteTable(
'notes',
{
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
title: text('title').default('').notNull(),
content: text('content').default('').notNull(),
color: text('color').default('default').notNull(),
@@ -32,6 +45,7 @@ export const notes = sqliteTable(
version: integer('version').default(1).notNull()
},
(table) => [
index('notes_user_id_idx').on(table.userId),
index('notes_trashed_archived_idx').on(table.trashed, table.archived),
index('notes_updated_at_idx').on(table.updatedAt)
]
@@ -41,9 +55,13 @@ export const tags = sqliteTable(
'tags',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
(table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)]
);
export const noteTags = sqliteTable(
@@ -64,6 +82,10 @@ export const noteTags = sqliteTable(
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
@@ -80,6 +102,10 @@ export const syncLog = sqliteTable(
'sync_log',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
noteId: text('note_id')
.references(() => notes.id)
.notNull(),
@@ -89,3 +115,15 @@ export const syncLog = sqliteTable(
},
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
);
export const loginAttempts = sqliteTable(
'login_attempts',
{
id: integer('id').primaryKey({ autoIncrement: true }),
ip: text('ip').notNull(),
email: text('email').notNull(),
success: integer('success', { mode: 'boolean' }).notNull(),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull()
},
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
);
+33 -5
View File
@@ -9,24 +9,34 @@ import * as schema from './schema.js';
export function createTestDb() {
const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('foreign_keys = OFF');
// Create tables
// Create tables matching the multi-user schema
sqlite.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
expires_at INTEGER NOT NULL,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
@@ -40,20 +50,27 @@ export function createTestDb() {
updated_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX notes_user_id_idx ON notes(user_id);
CREATE INDEX notes_trashed_archived_idx ON notes(trashed, archived);
CREATE INDEX notes_updated_at_idx ON notes(updated_at);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
name TEXT NOT NULL
);
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
CREATE UNIQUE INDEX tags_name_user_unique ON tags(name, user_id);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
@@ -64,11 +81,22 @@ export function createTestDb() {
CREATE TABLE sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
CREATE INDEX sync_log_timestamp_idx ON sync_log(timestamp);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
email TEXT NOT NULL,
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
`);
const db = drizzle(sqlite, { schema });
+51
View File
@@ -0,0 +1,51 @@
import * as arctic from 'arctic';
import { getGithubClient } from './providers.js';
export function createGithubAuthUrl(): { url: URL; state: string } | null {
const github = getGithubClient();
if (!github) return null;
const state = arctic.generateState();
const scopes = ['user:email'];
const url = github.createAuthorizationURL(state, scopes);
return { url, state };
}
export async function validateGithubCallback(
code: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const github = getGithubClient();
if (!github) return null;
const tokens = await github.validateAuthorizationCode(code);
const accessToken = tokens.accessToken();
// Fetch user profile
const userRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
});
if (!userRes.ok) return null;
const user = (await userRes.json()) as { id: number; login: string; name?: string; email?: string };
// GitHub may not return email in profile — fetch from emails endpoint
let email = user.email;
if (!email) {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
});
if (emailsRes.ok) {
const emails = (await emailsRes.json()) as { email: string; primary: boolean; verified: boolean }[];
const primary = emails.find((e) => e.primary && e.verified);
email = primary?.email || emails.find((e) => e.verified)?.email || undefined;
}
}
if (!email) return null;
return {
email,
name: user.name || user.login,
providerId: String(user.id)
};
}
+38
View File
@@ -0,0 +1,38 @@
import * as arctic from 'arctic';
import { getGoogleClient } from './providers.js';
export function createGoogleAuthUrl(): { url: URL; state: string; codeVerifier: string } | null {
const google = getGoogleClient();
if (!google) return null;
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
const scopes = ['openid', 'profile', 'email'];
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
return { url, state, codeVerifier };
}
export async function validateGoogleCallback(
code: string,
codeVerifier: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const google = getGoogleClient();
if (!google) return null;
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
const idToken = tokens.idToken();
const claims = arctic.decodeIdToken(idToken) as {
sub: string;
email?: string;
name?: string;
};
if (!claims.email) return null;
return {
email: claims.email,
name: claims.name || claims.email.split('@')[0],
providerId: claims.sub
};
}
+119
View File
@@ -0,0 +1,119 @@
import * as arctic from 'arctic';
let oidcClient: arctic.OAuth2Client | null = null;
let oidcConfig: {
authorizationEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
} | null = null;
function getOidcEnv() {
return {
issuer: process.env.AUTH_OIDC_ISSUER || '',
clientId: process.env.AUTH_OIDC_CLIENT_ID || '',
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET || ''
};
}
function getBaseUrl(): string {
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
}
async function ensureOidcClient(): Promise<arctic.OAuth2Client | null> {
const env = getOidcEnv();
if (!env.issuer || !env.clientId || !env.clientSecret) return null;
if (oidcClient && oidcConfig) return oidcClient;
// Discover OIDC endpoints
const discoveryUrl = env.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
const res = await fetch(discoveryUrl);
if (!res.ok) return null;
const config = (await res.json()) as {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
};
oidcConfig = {
authorizationEndpoint: config.authorization_endpoint,
tokenEndpoint: config.token_endpoint,
userinfoEndpoint: config.userinfo_endpoint
};
const redirectURI = `${getBaseUrl()}/api/auth/oauth/oidc/callback`;
oidcClient = new arctic.OAuth2Client(env.clientId, env.clientSecret, redirectURI);
return oidcClient;
}
export async function createOidcAuthUrl(): Promise<{ url: URL; state: string; codeVerifier: string } | null> {
const client = await ensureOidcClient();
if (!client || !oidcConfig) return null;
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
const scopes = ['openid', 'profile', 'email'];
const url = client.createAuthorizationURLWithPKCE(
oidcConfig.authorizationEndpoint,
state,
arctic.CodeChallengeMethod.S256,
codeVerifier,
scopes
);
return { url, state, codeVerifier };
}
export async function validateOidcCallback(
code: string,
codeVerifier: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const client = await ensureOidcClient();
if (!client || !oidcConfig) return null;
const tokens = await client.validateAuthorizationCode(
oidcConfig.tokenEndpoint,
code,
codeVerifier
);
// Try to decode ID token first
const idToken = tokens.idToken();
if (idToken) {
const claims = arctic.decodeIdToken(idToken) as {
sub: string;
email?: string;
name?: string;
};
if (claims.email) {
return {
email: claims.email,
name: claims.name || claims.email.split('@')[0],
providerId: claims.sub
};
}
}
// Fallback to userinfo endpoint
const accessToken = tokens.accessToken();
const userRes = await fetch(oidcConfig.userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!userRes.ok) return null;
const userInfo = (await userRes.json()) as {
sub: string;
email?: string;
name?: string;
};
if (!userInfo.email) return null;
return {
email: userInfo.email,
name: userInfo.name || userInfo.email.split('@')[0],
providerId: userInfo.sub
};
}
+50
View File
@@ -0,0 +1,50 @@
import * as arctic from 'arctic';
export type OAuthProviderInfo = {
id: string;
name: string;
icon?: string;
};
let googleClient: arctic.Google | null = null;
let githubClient: arctic.GitHub | null = null;
function getBaseUrl(): string {
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
}
export function getGoogleClient(): arctic.Google | null {
if (googleClient) return googleClient;
const clientId = process.env.AUTH_GOOGLE_CLIENT_ID;
const clientSecret = process.env.AUTH_GOOGLE_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
googleClient = new arctic.Google(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/google/callback`);
return googleClient;
}
export function getGithubClient(): arctic.GitHub | null {
if (githubClient) return githubClient;
const clientId = process.env.AUTH_GITHUB_CLIENT_ID;
const clientSecret = process.env.AUTH_GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
githubClient = new arctic.GitHub(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/github/callback`);
return githubClient;
}
export function getEnabledProviders(): OAuthProviderInfo[] {
const providers: OAuthProviderInfo[] = [];
if (getGoogleClient()) providers.push({ id: 'google', name: 'Google' });
if (getGithubClient()) providers.push({ id: 'github', name: 'GitHub' });
const oidcIssuer = process.env.AUTH_OIDC_ISSUER;
const oidcClientId = process.env.AUTH_OIDC_CLIENT_ID;
const oidcClientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
if (oidcIssuer && oidcClientId && oidcClientSecret) {
providers.push({
id: 'oidc',
name: process.env.AUTH_OIDC_DISPLAY_NAME || 'SSO'
});
}
return providers;
}
+79
View File
@@ -0,0 +1,79 @@
import { db } from './db/index.js';
import { loginAttempts } from './db/schema.js';
import { and, eq, gte, sql } from 'drizzle-orm';
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 1000; // 1 minute
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
export function checkRateLimit(
ip: string,
email: string
): { allowed: boolean; retryAfter?: number } {
// Skip rate limiting in test environment
if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') {
return { allowed: true };
}
const windowStart = new Date(Date.now() - LOCKOUT_MS);
// Count failed attempts from this IP in the lockout window
const result = db
.select({ count: sql<number>`count(*)` })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ip, ip),
eq(loginAttempts.success, false),
gte(loginAttempts.timestamp, windowStart)
)
)
.get();
const failedCount = result?.count ?? 0;
if (failedCount >= MAX_ATTEMPTS) {
// Find the most recent failed attempt to calculate retry-after
const latest = db
.select({ timestamp: loginAttempts.timestamp })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ip, ip),
eq(loginAttempts.success, false),
gte(loginAttempts.timestamp, windowStart)
)
)
.orderBy(sql`${loginAttempts.timestamp} DESC`)
.limit(1)
.get();
if (latest) {
const retryAfter = Math.ceil(
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
);
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
}
return { allowed: false, retryAfter: 900 };
}
return { allowed: true };
}
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
db.insert(loginAttempts)
.values({
ip,
email,
success,
timestamp: new Date()
})
.run();
}
export function clearOldAttempts(): void {
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
db.delete(loginAttempts)
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
.run();
}
+28 -7
View File
@@ -63,9 +63,13 @@ function isNetworkError(err: unknown): boolean {
export async function loadNotes(filter: NoteFilter = 'all') {
// Load from IDB first for instant display
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
try {
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
}
} catch {
// IDB unavailable — continue with server fetch
}
currentFilter.set(filter);
notesLoaded.set(true);
@@ -79,8 +83,16 @@ export async function loadNotes(filter: NoteFilter = 'all') {
currentFilter.set(filter);
// Update IDB with server state
await clearIdbNotes();
await Promise.all(data.map((n) => putNote(n)));
try {
await clearIdbNotes();
await Promise.all(data.map((n) => putNote(n)));
} catch {
// IDB write failed — server data is still in the store
}
} else if (res.status === 401) {
window.location.href = '/login';
} else {
showToast('Failed to load notes', 'error');
}
} catch {
// Offline — IDB data already loaded above
@@ -100,6 +112,11 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
await putNote(created);
return created;
}
if (res.status === 401) {
window.location.href = '/login';
} else {
showToast('Failed to save note', 'error');
}
return null;
} catch (err) {
if (isNetworkError(err)) {
@@ -195,7 +212,9 @@ export async function deleteNote(id: string): Promise<boolean> {
}
export async function trashNote(id: string): Promise<Note | null> {
return updateNote(id, { trashed: true });
const result = await updateNote(id, { trashed: true });
if (result) notes.update((list) => list.filter((n) => n.id !== id));
return result;
}
export async function restoreNote(id: string): Promise<Note | null> {
@@ -203,7 +222,9 @@ export async function restoreNote(id: string): Promise<Note | null> {
}
export async function archiveNote(id: string): Promise<Note | null> {
return updateNote(id, { archived: true });
const result = await updateNote(id, { archived: true });
if (result) notes.update((list) => list.filter((n) => n.id !== id));
return result;
}
export async function unarchiveNote(id: string): Promise<Note | null> {
+28 -13
View File
@@ -29,7 +29,12 @@ export function getSyncStatus(): SyncStatus {
* Push local changes to server.
*/
async function pushChanges(): Promise<boolean> {
const queue = await getSyncQueue();
let queue;
try {
queue = await getSyncQueue();
} catch {
return true; // IDB unavailable — nothing to push
}
if (queue.length === 0) return true;
try {
@@ -78,27 +83,37 @@ async function pushPendingAttachments(): Promise<void> {
* Pull remote changes and merge with local state.
*/
async function pullChanges(): Promise<Note[]> {
const lastSync = (await getMeta('lastSyncTimestamp')) || '0';
let lastSync = '0';
try {
lastSync = (await getMeta('lastSyncTimestamp')) || '0';
} catch {
// IDB unavailable — pull everything
}
try {
const res = await fetch(`/api/sync?since=${lastSync}`);
if (!res.ok) return [];
const remoteNotes: Note[] = await res.json();
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const merged = mergeNotes(local, remote);
await putNote(merged);
} else {
await putNote(remote);
try {
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const merged = mergeNotes(local, remote);
await putNote(merged);
} else {
await putNote(remote);
}
}
}
await setMeta('lastSyncTimestamp', String(Date.now()));
await setMeta('lastSyncTimestamp', String(Date.now()));
} catch {
// IDB write failed — server data still returned for store use
}
return remoteNotes;
} catch {
return [];
+31 -3
View File
@@ -1,7 +1,7 @@
import { openDB, type IDBPDatabase } from 'idb';
import { openDB, deleteDB, type IDBPDatabase } from 'idb';
import type { Note } from '$lib/types/index.js';
const DB_NAME = 'crumbs';
const DB_PREFIX = 'crumbs';
const DB_VERSION = 2;
export interface PendingAttachment {
@@ -51,10 +51,23 @@ export interface SyncQueueItem {
}
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
let currentUserId: number | null = null;
/**
* Initialize the IDB for a specific user. Must be called before any
* other IDB operation (typically after login when userId is known).
*/
export function initDb(userId: number): void {
if (currentUserId !== userId) {
dbPromise = null;
currentUserId = userId;
}
}
export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
if (!dbPromise) {
dbPromise = openDB<CrumbsDB>(DB_NAME, DB_VERSION, {
const dbName = currentUserId ? `${DB_PREFIX}-${currentUserId}` : DB_PREFIX;
dbPromise = openDB<CrumbsDB>(dbName, DB_VERSION, {
upgrade(db) {
// Notes store
if (!db.objectStoreNames.contains('notes')) {
@@ -87,6 +100,21 @@ export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
return dbPromise;
}
/**
* Delete the current user's IDB (call on logout).
*/
export async function destroyDb(): Promise<void> {
if (dbPromise) {
const db = await dbPromise;
db.close();
dbPromise = null;
}
if (currentUserId) {
await deleteDB(`${DB_PREFIX}-${currentUserId}`);
currentUserId = null;
}
}
// Notes operations
export async function getAllNotes(): Promise<Note[]> {
const db = await getDb();
+11 -9
View File
@@ -1,6 +1,6 @@
import { db } from '$lib/server/db/index.js';
import { notes, syncLog } from '$lib/server/db/schema.js';
import { eq, gt } from 'drizzle-orm';
import { eq, gt, and } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import type { SyncQueueItem } from './idb.js';
@@ -8,7 +8,7 @@ import type { SyncQueueItem } from './idb.js';
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise<void> {
const noteIdsToSyncTags: string[] = [];
db.transaction((tx) => {
@@ -20,7 +20,7 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
const existing = tx
.select()
.from(notes)
.where(eq(notes.id, change.noteId))
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.get();
if (existing) {
@@ -31,13 +31,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
updatedAt: new Date(change.timestamp),
version: existing.version + 1
})
.where(eq(notes.id, change.noteId))
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.run();
}
} else if (change.operation === 'create' && change.data) {
tx.insert(notes)
.values({
id: change.noteId,
userId,
title: change.data.title || '',
content: change.data.content || '',
color: change.data.color || 'default',
@@ -59,13 +60,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
break;
}
case 'delete': {
tx.delete(notes).where(eq(notes.id, change.noteId)).run();
tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run();
break;
}
}
tx.insert(syncLog)
.values({
userId,
noteId: change.noteId,
operation: change.operation,
timestamp: new Date(change.timestamp),
@@ -76,7 +78,7 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
});
for (const noteId of noteIdsToSyncTags) {
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
if (note) {
const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content));
@@ -85,12 +87,12 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
}
/**
* Get all notes updated since a given timestamp.
* Get all notes updated since a given timestamp for a specific user.
*/
export async function getChangesSince(sinceTimestamp: number) {
export async function getChangesSince(sinceTimestamp: number, userId: number) {
return db
.select()
.from(notes)
.where(gt(notes.updatedAt, new Date(sinceTimestamp)))
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp))))
.all();
}
+10
View File
@@ -52,6 +52,16 @@ export interface Tag {
name: string;
}
export interface User {
id: number;
email: string;
displayName: string;
role: 'admin' | 'user';
authProvider: string;
providerId: string | null;
createdAt: Date;
}
export interface SyncChange {
noteId: string;
operation: 'create' | 'update' | 'delete';
+7 -3
View File
@@ -1,5 +1,9 @@
import { version } from '../../../package.json';
import type { LayoutServerLoad } from './$types';
export function load() {
return { appVersion: version };
}
export const load: LayoutServerLoad = (event) => {
return {
appVersion: version,
user: event.locals.user
};
};
+5 -1
View File
@@ -4,6 +4,7 @@
import Toast from '$lib/components/Layout/Toast.svelte';
import { loadNotes } from '$lib/stores/notes.js';
import { startSync, stopSync } from '$lib/sync/client.js';
import { initDb } from '$lib/sync/idb.js';
import { onMount, onDestroy } from 'svelte';
let { data, children } = $props();
@@ -11,6 +12,9 @@
onMount(() => {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
if (data.user) {
initDb(data.user.id);
}
loadNotes();
startSync();
});
@@ -21,7 +25,7 @@
</script>
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} user={data.user} />
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
+8
View File
@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types.js';
import { getEnabledProviders } from '$lib/server/oauth/providers.js';
export const load: PageServerLoad = async () => {
return {
providers: getEnabledProviders()
};
};
+66 -5
View File
@@ -1,8 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
let { data } = $props();
let email = $state('');
let password = $state('');
let errorMsg = $state('');
let loading = $state(false);
// Check for OAuth error in URL
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
if (urlParams?.get('error') === 'no_account') {
errorMsg = 'No account found for this email. Ask your admin for an invite.';
}
async function handleLogin(e: Event) {
e.preventDefault();
errorMsg = '';
@@ -12,14 +23,17 @@
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
body: JSON.stringify({ email, password })
});
if (res.ok) {
window.location.href = '/';
goto('/');
} else if (res.status === 429) {
const d = await res.json();
errorMsg = d.error || 'Too many attempts. Please try again later.';
} else {
const data = await res.json();
errorMsg = data.message || 'Invalid password';
const d = await res.json();
errorMsg = d.message || 'Invalid email or password';
}
} catch {
errorMsg = 'Connection error';
@@ -27,6 +41,8 @@
loading = false;
}
}
const providers = data.providers ?? [];
</script>
<svelte:head>
@@ -37,7 +53,7 @@
<div class="mb-6 text-center">
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Crumbs</h1>
<p class="mt-2 text-sm text-[var(--text-muted)]">Enter your password to continue</p>
<p class="mt-2 text-sm text-[var(--text-muted)]">Sign in to continue</p>
</div>
<form onsubmit={handleLogin}>
@@ -47,6 +63,15 @@
</div>
{/if}
<input
type="email"
bind:value={email}
placeholder="Email"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="email-input"
required
/>
<input
type="password"
bind:value={password}
@@ -65,4 +90,40 @@
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{#if providers.length > 0}
<div class="my-6 flex items-center gap-3">
<div class="h-px flex-1 bg-[var(--border-subtle)]"></div>
<span class="text-xs text-[var(--text-muted)]">or continue with</span>
<div class="h-px flex-1 bg-[var(--border-subtle)]"></div>
</div>
<div class="space-y-2">
{#each providers as provider (provider.id)}
<a
href="/api/auth/oauth/{provider.id}"
class="flex w-full items-center justify-center gap-2 rounded-sm border border-[var(--border-subtle)] px-4 py-3 text-sm font-medium text-[var(--text)] transition-colors hover:border-[var(--primary)] hover:bg-[var(--bg-base)]"
data-testid="oauth-{provider.id}-btn"
>
{#if provider.id === 'google'}
<svg class="h-5 w-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
{:else if provider.id === 'github'}
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{:else}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{/if}
{provider.name}
</a>
{/each}
</div>
{/if}
</div>
+32 -4
View File
@@ -1,4 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
let email = $state('');
let displayName = $state('');
let password = $state('');
let confirmPassword = $state('');
let errorMsg = $state('');
@@ -8,6 +12,11 @@
e.preventDefault();
errorMsg = '';
if (!email) {
errorMsg = 'Email is required';
return;
}
if (password.length < 8) {
errorMsg = 'Password must be at least 8 characters';
return;
@@ -24,11 +33,11 @@
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
body: JSON.stringify({ email, displayName, password })
});
if (res.ok) {
window.location.href = '/';
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Setup failed';
@@ -49,7 +58,7 @@
<div class="mb-6 text-center">
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Welcome to Crumbs</h1>
<p class="mt-2 text-sm text-[var(--text-muted)]">Set up your password to get started</p>
<p class="mt-2 text-sm text-[var(--text-muted)]">Create your admin account to get started</p>
</div>
<form onsubmit={handleSetup}>
@@ -59,6 +68,23 @@
</div>
{/if}
<input
type="email"
bind:value={email}
placeholder="Email"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="email-input"
required
/>
<input
type="text"
bind:value={displayName}
placeholder="Display name (optional)"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="display-name-input"
/>
<input
type="password"
bind:value={password}
@@ -66,6 +92,7 @@
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="password-input"
required
minlength="8"
/>
<input
@@ -75,6 +102,7 @@
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="confirm-password-input"
required
minlength="8"
/>
<button
@@ -83,7 +111,7 @@
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
data-testid="setup-btn"
>
{loading ? 'Setting up...' : 'Set password & start'}
{loading ? 'Setting up...' : 'Create account & start'}
</button>
</form>
</div>
+5
View File
@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types.js';
export const load: LayoutServerLoad = ({ locals }) => {
return { user: locals.user };
};
+25
View File
@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { listUsers, createUser } from '$lib/server/auth.js';
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
return json(listUsers());
};
export const POST: RequestHandler = async ({ request, ...event }) => {
requireAdmin(event);
const { email, displayName, password, role } = await request.json();
if (!email || !password) {
throw error(400, 'Email and password are required');
}
if (password.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
const user = await createUser(email, displayName || email.split('@')[0], password, role || 'user');
return json(user, { status: 201 });
};
@@ -0,0 +1,46 @@
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 } from '$lib/server/auth.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const admin = requireAdmin(event);
const userId = parseInt(params.id, 10);
const body = await request.json();
const user = getUser(userId);
if (!user) throw error(404, 'User not found');
if (body.role !== undefined) {
// Prevent admin from demoting themselves
if (userId === admin.id && body.role !== 'admin') {
throw error(400, 'Cannot change your own role');
}
updateUserRole(userId, body.role);
}
if (body.newPassword !== undefined) {
if (body.newPassword.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
await resetPassword(userId, body.newPassword);
}
const updated = getUser(userId);
return json(updated);
};
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const admin = requireAdmin(event);
const userId = parseInt(params.id, 10);
if (userId === admin.id) {
throw error(400, 'Cannot delete your own account');
}
const user = getUser(userId);
if (!user) throw error(404, 'User not found');
await deleteUser(userId);
return json({ success: true });
};
+22 -8
View File
@@ -1,20 +1,34 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { verifyPassword, createSession } from '$lib/server/auth.js';
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
export const POST: RequestHandler = async ({ request, cookies }) => {
const { password } = await request.json();
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const ip = getClientAddress();
const { email, password } = await request.json();
if (!password) {
throw error(400, 'Password is required');
if (!email || !password) {
throw error(400, 'Email and password are required');
}
const valid = await verifyPassword(password);
if (!valid) {
throw error(401, 'Invalid password');
const rateCheck = checkRateLimit(ip, email);
if (!rateCheck.allowed) {
return json(
{ error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter },
{ status: 429, headers: { 'Retry-After': String(rateCheck.retryAfter) } }
);
}
const token = await createSession();
const user = await verifyPassword(email, password);
if (!user) {
recordLoginAttempt(ip, email, false);
throw error(401, 'Invalid email or password');
}
recordLoginAttempt(ip, email, true);
const userAgent = request.headers.get('user-agent') || undefined;
const token = await createSession(user.id, { userAgent, ip });
cookies.set('session', token, {
path: '/',
httpOnly: true,
@@ -0,0 +1,65 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { createGoogleAuthUrl } from '$lib/server/oauth/google.js';
import { createGithubAuthUrl } from '$lib/server/oauth/github.js';
import { createOidcAuthUrl } from '$lib/server/oauth/oidc.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const { provider } = params;
if (provider === 'google') {
const result = createGoogleAuthUrl();
if (!result) throw error(404, 'Google OAuth not configured');
cookies.set('oauth_state', result.state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
cookies.set('oauth_code_verifier', result.codeVerifier, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
throw redirect(302, result.url.toString());
}
if (provider === 'github') {
const result = createGithubAuthUrl();
if (!result) throw error(404, 'GitHub OAuth not configured');
cookies.set('oauth_state', result.state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
throw redirect(302, result.url.toString());
}
if (provider === 'oidc') {
const result = await createOidcAuthUrl();
if (!result) throw error(404, 'OIDC not configured');
cookies.set('oauth_state', result.state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
cookies.set('oauth_code_verifier', result.codeVerifier, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
throw redirect(302, result.url.toString());
}
throw error(404, 'Unknown provider');
};
@@ -0,0 +1,63 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { validateGoogleCallback } from '$lib/server/oauth/google.js';
import { validateGithubCallback } from '$lib/server/oauth/github.js';
import { validateOidcCallback } from '$lib/server/oauth/oidc.js';
import { findOrLinkOAuthUser, createSession } from '$lib/server/auth.js';
export const GET: RequestHandler = async ({ params, url, cookies, request, getClientAddress }) => {
const { provider } = params;
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies.get('oauth_state');
if (!code || !state || !storedState || state !== storedState) {
throw error(400, 'Invalid OAuth callback');
}
// Clean up OAuth cookies
cookies.delete('oauth_state', { path: '/' });
let profile: { email: string; name: string; providerId: string } | null = null;
if (provider === 'google') {
const codeVerifier = cookies.get('oauth_code_verifier');
cookies.delete('oauth_code_verifier', { path: '/' });
if (!codeVerifier) throw error(400, 'Missing code verifier');
profile = await validateGoogleCallback(code, codeVerifier);
} else if (provider === 'github') {
profile = await validateGithubCallback(code);
} else if (provider === 'oidc') {
const codeVerifier = cookies.get('oauth_code_verifier');
cookies.delete('oauth_code_verifier', { path: '/' });
if (!codeVerifier) throw error(400, 'Missing code verifier');
profile = await validateOidcCallback(code, codeVerifier);
} else {
throw error(404, 'Unknown provider');
}
if (!profile) {
throw error(400, 'Failed to get profile from OAuth provider');
}
// Find or link user (invite-only)
const user = findOrLinkOAuthUser(provider, profile.providerId, profile.email, profile.name);
if (!user) {
// User not found — not invited
throw redirect(302, '/login?error=no_account');
}
// Create session
const userAgent = request.headers.get('user-agent') || undefined;
const ip = getClientAddress();
const token = await createSession(user.id, { userAgent, ip });
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60
});
throw redirect(302, '/');
};
+24
View File
@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { changePassword } from '$lib/server/auth.js';
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
throw error(400, 'Current password and new password are required');
}
if (newPassword.length < 8) {
throw error(400, 'New password must be at least 8 characters');
}
const success = await changePassword(userId, currentPassword, newPassword);
if (!success) {
throw error(401, 'Current password is incorrect');
}
return json({ success: true });
};
+16
View File
@@ -0,0 +1,16 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { updateUserProfile } from '$lib/server/auth.js';
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { displayName, email } = await request.json();
if (email !== undefined && !email) {
throw error(400, 'Email cannot be empty');
}
updateUserProfile(userId, { displayName, email });
return json({ success: true });
};
+33
View File
@@ -0,0 +1,33 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { listUserSessions, revokeSession, revokeAllSessions } from '$lib/server/auth.js';
export const GET: RequestHandler = async (event) => {
const userId = getUserId(event);
const currentToken = event.cookies.get('session') || '';
const sessions = listUserSessions(userId, currentToken);
return json(sessions);
};
export const DELETE: RequestHandler = async (event) => {
const userId = getUserId(event);
const { sessionId, all } = await event.request.json();
if (all) {
const currentToken = event.cookies.get('session') || '';
revokeAllSessions(userId, currentToken);
return json({ success: true });
}
if (!sessionId) {
throw error(400, 'sessionId is required');
}
const revoked = revokeSession(userId, sessionId);
if (!revoked) {
throw error(404, 'Session not found');
}
return json({ success: true });
};
+11 -5
View File
@@ -2,24 +2,30 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { setupUser, createSession, isSetupComplete } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const alreadySetup = await isSetupComplete();
if (alreadySetup) {
throw error(400, 'Setup already completed');
}
const { password } = await request.json();
const { email, displayName, password } = await request.json();
if (!email) {
throw error(400, 'Email is required');
}
if (!password || password.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
const success = await setupUser(password);
if (!success) {
const user = await setupUser(email, password, displayName);
if (!user) {
throw error(500, 'Failed to create user');
}
const token = await createSession();
const userAgent = request.headers.get('user-agent') || undefined;
const ip = getClientAddress();
const token = await createSession(user.id, { userAgent, ip });
cookies.set('session', token, {
path: '/',
httpOnly: true,
+9 -5
View File
@@ -7,20 +7,22 @@ import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async ({ url }) => {
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const filter = url.searchParams.get('filter') || 'all';
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.archived, true), eq(notes.trashed, false));
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
conditions = eq(notes.trashed, true);
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.archived, false), eq(notes.trashed, false));
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const result = db
@@ -42,13 +44,15 @@ export const GET: RequestHandler = async ({ url }) => {
return json(notesWithTags);
};
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const now = new Date();
const id = body.id || uuidv4();
const newNote = {
id,
userId,
title: body.title || '',
content: body.content || '',
color: body.color || 'default',
+26 -10
View File
@@ -2,13 +2,19 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async ({ params }) => {
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
if (!note) {
throw error(404, 'Note not found');
@@ -24,9 +30,14 @@ export const GET: RequestHandler = async ({ params }) => {
});
};
export const PATCH: RequestHandler = async ({ params, request }) => {
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
if (!existing) {
throw error(404, 'Note not found');
@@ -50,9 +61,9 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
db.update(notes).set(updates).where(eq(notes.id, params.id)).run();
db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
const updated = db.select().from(notes).where(eq(notes.id, params.id)).get();
const updated = db.select().from(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).get();
if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
@@ -70,13 +81,18 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
});
};
export const DELETE: RequestHandler = async ({ params }) => {
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
if (!existing) {
throw error(404, 'Note not found');
}
db.delete(notes).where(eq(notes.id, params.id)).run();
db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
return json({ success: true });
};
@@ -2,8 +2,26 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
import { readFile } from 'fs/promises';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { getUserId } from '$lib/server/api-utils.js';
/** Verify the note belongs to the authenticated user */
async function verifyNoteOwnership(noteId: string, userId: number) {
const note = await db
.select()
.from(notes)
.where(and(eq(notes.id, noteId), eq(notes.userId, userId)))
.get();
if (!note) throw error(404, 'Note not found');
return note;
}
export const GET: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
export const GET: RequestHandler = async ({ params, url }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) {
@@ -28,7 +46,10 @@ export const GET: RequestHandler = async ({ params, url }) => {
return json(list);
};
export const POST: RequestHandler = async ({ params, request }) => {
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
const formData = await request.formData();
const file = formData.get('file') as File;
@@ -46,11 +67,14 @@ export const POST: RequestHandler = async ({ params, request }) => {
}
const thumbnail = formData.get('thumbnail') as File | null;
const attachment = await saveAttachment(params.id, file, thumbnail);
const attachment = await saveAttachment(params.id, file, userId, thumbnail);
return json(attachment, { status: 201 });
};
export const PATCH: RequestHandler = async ({ url, request }) => {
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
@@ -63,7 +87,10 @@ export const PATCH: RequestHandler = async ({ url, request }) => {
return json(updated);
};
export const DELETE: RequestHandler = async ({ url }) => {
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
+6 -3
View File
@@ -4,8 +4,10 @@ import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { and, eq, like, or, inArray } from 'drizzle-orm';
import { fetchTagsForNotes } from '$lib/server/tags.js';
import { getUserId } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async ({ url }) => {
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const query = url.searchParams.get('q')?.trim();
if (!query) {
@@ -19,6 +21,7 @@ export const GET: RequestHandler = async ({ url }) => {
.from(notes)
.where(
and(
eq(notes.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
@@ -30,7 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(like(tags.name, pattern))
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set(results.map((n) => n.id));
@@ -43,7 +46,7 @@ export const GET: RequestHandler = async ({ url }) => {
extraNotes = db
.select()
.from(notes)
.where(and(inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.all();
}
+7 -4
View File
@@ -3,21 +3,24 @@ import type { RequestHandler } from './$types.js';
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
import { fetchTagsForNotes } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { changes } = await request.json();
if (!Array.isArray(changes)) {
return json({ error: 'Invalid changes' }, { status: 400 });
}
await processSyncPush(changes);
await processSyncPush(changes, userId);
return json({ success: true });
};
export const GET: RequestHandler = async ({ url }) => {
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const since = parseInt(url.searchParams.get('since') || '0', 10);
const changes = await getChangesSince(since);
const changes = await getChangesSince(since, userId);
const noteIds = changes.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
+5 -2
View File
@@ -2,8 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { tags } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { getUserId } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async () => {
const allTags = await db.select().from(tags);
export const GET: RequestHandler = async (event) => {
const userId = getUserId(event);
const allTags = await db.select().from(tags).where(eq(tags.userId, userId));
return json(allTags);
};
+286
View File
@@ -0,0 +1,286 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
type SessionInfo = {
id: string;
createdAt: string | null;
userAgent: string | null;
ip: string | null;
lastUsedAt: string | null;
expiresAt: string;
isCurrent: boolean;
};
let { data } = $props();
let displayName = $state(data.user?.displayName || '');
let email = $state(data.user?.email || '');
let profileMsg = $state('');
let profileError = $state(false);
let currentPassword = $state('');
let newPassword = $state('');
let confirmNewPassword = $state('');
let passwordMsg = $state('');
let passwordError = $state(false);
let activeSessions = $state<SessionInfo[]>([]);
let sessionsLoaded = $state(false);
async function loadSessions() {
try {
const res = await fetch('/api/auth/sessions');
if (res.ok) activeSessions = await res.json();
sessionsLoaded = true;
} catch {
// ignore
}
}
async function revokeSession(sessionId: string) {
try {
await fetch('/api/auth/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId })
});
activeSessions = activeSessions.filter((s) => s.id !== sessionId);
} catch {
// ignore
}
}
async function revokeAllOtherSessions() {
try {
await fetch('/api/auth/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true })
});
activeSessions = activeSessions.filter((s) => s.isCurrent);
} catch {
// ignore
}
}
function formatAgent(ua: string | null): string {
if (!ua) return 'Unknown';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Edg/')) return 'Edge';
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Safari')) return 'Safari';
return ua.slice(0, 40);
}
function formatDate(d: string | null): string {
if (!d) return 'Unknown';
return new Date(d).toLocaleString();
}
$effect(() => {
loadSessions();
});
async function saveProfile() {
profileMsg = '';
try {
const res = await fetch('/api/auth/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName, email })
});
if (res.ok) {
profileMsg = 'Profile updated';
profileError = false;
} else {
const d = await res.json();
profileMsg = d.message || 'Failed to update';
profileError = true;
}
} catch {
profileMsg = 'Connection error';
profileError = true;
}
}
async function changePassword() {
passwordMsg = '';
if (newPassword.length < 8) {
passwordMsg = 'Password must be at least 8 characters';
passwordError = true;
return;
}
if (newPassword !== confirmNewPassword) {
passwordMsg = 'Passwords do not match';
passwordError = true;
return;
}
try {
const res = await fetch('/api/auth/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
if (res.ok) {
passwordMsg = 'Password changed';
passwordError = false;
currentPassword = '';
newPassword = '';
confirmNewPassword = '';
} else {
const d = await res.json();
passwordMsg = d.message || 'Failed to change password';
passwordError = true;
}
} catch {
passwordMsg = 'Connection error';
passwordError = true;
}
}
</script>
<svelte:head>
<title>Settings - KeepNotes</title>
</svelte:head>
<div class="mx-auto max-w-2xl py-8">
<h1 class="mb-8 text-2xl font-bold text-gray-800 dark:text-gray-100">Settings</h1>
<!-- Profile -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Profile</h2>
{#if profileMsg}
<div class="mb-4 rounded p-3 text-sm {profileError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{profileMsg}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="display-name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</label>
<input
id="display-name"
type="text"
bind:value={displayName}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input
id="email"
type="email"
bind:value={email}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<button
onclick={saveProfile}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Save profile
</button>
</div>
</section>
<!-- Change Password -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Change Password</h2>
{#if passwordMsg}
<div class="mb-4 rounded p-3 text-sm {passwordError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{passwordMsg}
</div>
{/if}
<div class="space-y-4">
<input
type="password"
bind:value={currentPassword}
placeholder="Current password"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="password"
bind:value={newPassword}
placeholder="New password (min 8 characters)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<PasswordStrengthMeter password={newPassword} />
<input
type="password"
bind:value={confirmNewPassword}
placeholder="Confirm new password"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<button
onclick={changePassword}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Change password
</button>
</div>
</section>
<!-- Active Sessions -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Active Sessions</h2>
{#if activeSessions.length > 1}
<button
onclick={revokeAllOtherSessions}
class="text-xs text-red-600 hover:text-red-700 dark:text-red-400"
>
Log out everywhere else
</button>
{/if}
</div>
{#if !sessionsLoaded}
<p class="text-sm text-gray-500">Loading sessions...</p>
{:else if activeSessions.length === 0}
<p class="text-sm text-gray-500">No active sessions</p>
{:else}
<div class="space-y-3">
{#each activeSessions as session (session.id)}
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-3 dark:border-gray-700">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">
{formatAgent(session.userAgent)}
{#if session.isCurrent}
<span class="ml-2 rounded bg-green-100 px-2 py-0.5 text-xs text-green-800 dark:bg-green-900/30 dark:text-green-200">current</span>
{/if}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
</p>
</div>
{#if !session.isCurrent}
<button
onclick={() => revokeSession(session.id)}
class="rounded px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Revoke
</button>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<!-- Admin: User Management Link -->
{#if data.user?.role === 'admin'}
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Administration</h2>
<a
href="/settings/users"
class="text-sm text-amber-600 hover:text-amber-700 hover:underline dark:text-amber-400"
>
Manage users →
</a>
</section>
{/if}
</div>
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { listUsers } from '$lib/server/auth.js';
export const load: PageServerLoad = ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(302, '/settings');
}
return { users: listUsers() };
};
+181
View File
@@ -0,0 +1,181 @@
<script lang="ts">
import type { User } from '$lib/types/index.js';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
let { data } = $props();
let users = $state<User[]>(data.users);
// Create user form
let newEmail = $state('');
let newDisplayName = $state('');
let newPassword = $state('');
let newRole = $state<'admin' | 'user'>('user');
let createMsg = $state('');
let createError = $state(false);
async function createUser() {
createMsg = '';
if (!newEmail || !newPassword) {
createMsg = 'Email and password are required';
createError = true;
return;
}
if (newPassword.length < 8) {
createMsg = 'Password must be at least 8 characters';
createError = true;
return;
}
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: newEmail,
displayName: newDisplayName || newEmail.split('@')[0],
password: newPassword,
role: newRole
})
});
if (res.ok) {
const user = await res.json();
users = [...users, user];
newEmail = '';
newDisplayName = '';
newPassword = '';
newRole = 'user';
createMsg = 'User created';
createError = false;
} else {
const d = await res.json();
createMsg = d.message || 'Failed to create user';
createError = true;
}
} catch {
createMsg = 'Connection error';
createError = true;
}
}
async function deleteUser(userId: number) {
if (!confirm('Are you sure you want to delete this user and all their data?')) return;
try {
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
}
} catch {
// ignore
}
}
async function toggleRole(user: User) {
const newRoleValue = user.role === 'admin' ? 'user' : 'admin';
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRoleValue })
});
if (res.ok) {
users = users.map((u) => (u.id === user.id ? { ...u, role: newRoleValue } : u));
}
} catch {
// ignore
}
}
</script>
<svelte:head>
<title>User Management - KeepNotes</title>
</svelte:head>
<div class="mx-auto max-w-2xl py-8">
<div class="mb-8 flex items-center gap-4">
<a href="/settings" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← Settings</a>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">User Management</h1>
</div>
<!-- Create User -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Create User</h2>
{#if createMsg}
<div class="mb-4 rounded p-3 text-sm {createError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{createMsg}
</div>
{/if}
<div class="space-y-3">
<input
type="email"
bind:value={newEmail}
placeholder="Email"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="text"
bind:value={newDisplayName}
placeholder="Display name (optional)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="password"
bind:value={newPassword}
placeholder="Password (min 8 characters)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<PasswordStrengthMeter password={newPassword} />
<select
bind:value={newRole}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onclick={createUser}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Create user
</button>
</div>
</section>
<!-- User List -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Users ({users.length})</h2>
<div class="space-y-3">
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-gray-700">
<div>
<p class="font-medium text-gray-800 dark:text-gray-100">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">admin</span>
{/if}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => toggleRole(user)}
class="rounded px-3 py-1 text-xs text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
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={() => deleteUser(user.id)}
class="rounded px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
</section>
</div>
+26 -30
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
test.describe.serial('Authentication', () => {
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
@@ -15,7 +15,8 @@ test.describe.serial('Authentication', () => {
// When the user visits the setup page
await page.goto('/setup');
// Then the password and confirmation fields and setup action are available
// Then the email, password, and confirmation fields and setup action are available
await expect(page.getByTestId('email-input')).toBeVisible();
await expect(page.getByTestId('password-input')).toBeVisible();
await expect(page.getByTestId('confirm-password-input')).toBeVisible();
await expect(page.getByTestId('setup-btn')).toBeVisible();
@@ -26,6 +27,7 @@ test.describe.serial('Authentication', () => {
await page.goto('/setup');
// When they submit a password shorter than 8 characters
await page.getByTestId('email-input').fill('test@test.com');
await page.getByTestId('password-input').fill('short');
await page.getByTestId('confirm-password-input').fill('short');
await page.getByTestId('setup-btn').click();
@@ -39,6 +41,7 @@ test.describe.serial('Authentication', () => {
await page.goto('/setup');
// When they submit two different passwords
await page.getByTestId('email-input').fill('test@test.com');
await page.getByTestId('password-input').fill('longpassword1');
await page.getByTestId('confirm-password-input').fill('longpassword2');
await page.getByTestId('setup-btn').click();
@@ -48,39 +51,14 @@ test.describe.serial('Authentication', () => {
});
test('Scenario: User creates an account and gains access to the application', async ({ page }) => {
// Given the user accesses the application
await page.goto('/');
const url = page.url();
// When they authenticate with valid credentials
if (url.includes('/setup')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('confirm-password-input').fill('testpassword123');
await page.getByTestId('setup-btn').click();
} else if (url.includes('/login')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('login-btn').click();
}
// Then they have access to the main application
await page.waitForURL('/');
// Use the shared setupAndLogin helper which handles race conditions
await setupAndLogin(page);
await expect(page.getByTestId('new-note-btn')).toBeVisible();
});
test('Scenario: Logged-out user loses access to the application', async ({ page }) => {
// Given the user is authenticated
await page.goto('/');
const url = page.url();
if (url.includes('/setup')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('confirm-password-input').fill('testpassword123');
await page.getByTestId('setup-btn').click();
await page.waitForURL('/');
} else if (url.includes('/login')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
}
await setupAndLogin(page);
// When they log out
await page.evaluate(() => fetch('/api/auth/logout', { method: 'POST' }));
@@ -90,3 +68,21 @@ test.describe.serial('Authentication', () => {
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('Multi-user', () => {
test('admin can create a new user via API', async ({ authenticatedPage: page }) => {
// authenticatedPage fixture already handles setup/login
// Create a new user via admin API
const response = await page.request.post('/api/admin/users', {
data: {
email: 'user2@test.com',
displayName: 'User Two',
password: 'userpassword123',
role: 'user'
}
});
expect(response.status()).toBe(201);
});
});
+9 -3
View File
@@ -1,6 +1,8 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test';
const TEST_EMAIL = 'admin@test.com';
const TEST_PASSWORD = 'testpassword123';
const TEST_DISPLAY_NAME = 'Test Admin';
/** Find a specific note card by its title text */
export function noteCard(page: Page, title: string): Locator {
@@ -23,7 +25,9 @@ export async function setupAndLogin(page: Page) {
const url = page.url();
if (url.includes('/setup')) {
// First-time setup - may race with other workers
// First-time setup with email
await page.getByTestId('email-input').fill(TEST_EMAIL);
await page.getByTestId('display-name-input').fill(TEST_DISPLAY_NAME);
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('confirm-password-input').fill(TEST_PASSWORD);
await page.getByTestId('setup-btn').click();
@@ -34,12 +38,14 @@ export async function setupAndLogin(page: Page) {
} catch {
// Setup was completed by another worker - go to login instead
await page.goto('/login');
await page.getByTestId('email-input').fill(TEST_EMAIL);
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
}
} else if (url.includes('/login')) {
// Login
// Login with email
await page.getByTestId('email-input').fill(TEST_EMAIL);
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
@@ -51,4 +57,4 @@ export async function setupAndLogin(page: Page) {
await page.waitForLoadState('networkidle');
}
export { expect };
export { expect, TEST_EMAIL, TEST_PASSWORD };
+1 -1
View File
@@ -42,7 +42,7 @@ test.describe('Notes CRUD', () => {
// When the user trashes the note
const card = noteCard(page, 'Delete Me');
await card.hover();
await card.getByTestId('trash-btn').click();
await card.getByTestId('trash-btn').click({ force: true });
// Then the note is no longer visible
await expect(page.getByText('Delete Me')).not.toBeVisible();
+3 -3
View File
@@ -10,7 +10,7 @@ test.describe('Organization Features', () => {
// When the user pins the note
const pinCard = noteCard(page, 'Pin Me');
await pinCard.hover();
await pinCard.getByTestId('pin-btn').click();
await pinCard.getByTestId('pin-btn').first().click({ force: true });
// Then the "Pinned" section is visible
await expect(page.getByText('Pinned')).toBeVisible();
@@ -25,7 +25,7 @@ test.describe('Organization Features', () => {
// When the user archives the note
const archiveCard = noteCard(page, 'Archive Me');
await archiveCard.hover();
await archiveCard.getByTestId('archive-btn').click();
await archiveCard.getByTestId('archive-btn').click({ force: true });
// Then the note is no longer visible in the main view
await expect(page.getByText('Archive Me')).not.toBeVisible();
@@ -64,7 +64,7 @@ test.describe('Organization Features', () => {
await tagChip.click();
// Then only the tagged note is visible
await expect(page.getByText('Tagged Note')).toBeVisible();
await expect(page.getByText('Tagged Note').first()).toBeVisible();
}
});
});
+1 -1
View File
@@ -222,7 +222,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.7.0</span>
<span class="footer-version">v0.8.0</span>
</div>
</footer>