forked from mirror/crumbs-original
Compare commits
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
|
||||
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -10,7 +10,7 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
|
||||
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
|
||||
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
|
||||
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at `src/lib/server/db/schema.ts`)
|
||||
- **Auth**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
|
||||
- **Auth**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
|
||||
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
|
||||
- **Package manager**: pnpm
|
||||
|
||||
@@ -55,6 +55,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
||||
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
|
||||
|
||||
### Layout groups
|
||||
|
||||
@@ -74,7 +75,12 @@ src/
|
||||
(app)/ # Layout group: main app (header, sidebar, footer)
|
||||
+layout.svelte # App shell with Header, Sidebar, Toast
|
||||
+page.svelte # Main notes view
|
||||
api/auth/ # Login, logout, setup endpoints
|
||||
archive/ # Archived notes view
|
||||
trash/ # Trashed notes view
|
||||
tag/[name]/ # Notes filtered by tag
|
||||
settings/ # Settings with tabbed layout (profile, MCP, users)
|
||||
api/auth/ # Login, logout, setup, OAuth endpoints
|
||||
api/admin/users/ # Admin user management (CRUD, role, sessions)
|
||||
api/notes/ # CRUD + attachments
|
||||
api/search/ # Full-text search
|
||||
api/sync/ # Offline sync
|
||||
@@ -128,7 +134,9 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
|
||||
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
||||
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
||||
- **Main branch**: `main` — use as base for all PRs and feature branches
|
||||
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
||||
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -207,5 +215,6 @@ Keep docs in `docs/` up to date when making changes. If a feature, API endpoint,
|
||||
|-----|--------|
|
||||
| `docs/FEATURES.md` | User-facing feature list |
|
||||
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
|
||||
| `docs/AUTH.md` | Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.) |
|
||||
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
|
||||
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
# Crumbs by Bretzel
|
||||
|
||||
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
|
||||
## Quick Start
|
||||
## Install
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
@@ -19,7 +19,41 @@ A self-hostable, offline-first note-taking app inspired by Google Keep. Part of
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open http://localhost:3000 and set your password on first visit.
|
||||
Open http://localhost:3000 and create your admin account on first visit.
|
||||
|
||||
#### With SSO (optional)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- crumbs-data:/data
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||
- AUTH_OIDC_CLIENT_ID=your-client-id
|
||||
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
|
||||
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
crumbs-data:
|
||||
```
|
||||
|
||||
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
```bash
|
||||
git clone <repo-url> crumbs && cd crumbs
|
||||
pnpm install
|
||||
pnpm build
|
||||
DATABASE_URL=./data/crumbs.db ORIGIN=http://localhost:3000 node build
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
@@ -42,7 +76,8 @@ pnpm build # Production build
|
||||
- Organize with #tags, pinning, archive, and trash
|
||||
- Full-text search across titles, content, and tags
|
||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||
- Single-user password auth (Argon2)
|
||||
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
|
||||
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
|
||||
- Docker deployment with a single command
|
||||
|
||||
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
@@ -68,6 +103,7 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
|----------|-------------|
|
||||
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
|
||||
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
|
||||
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
|
||||
| [API](docs/API.md) | REST API reference (auto-generated) |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.7.2
|
||||
image: ghcr.io/bretzel-app/crumbs:0.11.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+275
@@ -111,6 +111,17 @@ List notes
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -157,6 +168,17 @@ Create a note
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -192,6 +214,17 @@ Get a note
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -247,6 +280,17 @@ Partial update — only include changed fields.
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -277,6 +321,123 @@ Permanently delete a note
|
||||
|
||||
---
|
||||
|
||||
## Collaboration
|
||||
|
||||
### `GET /api/notes/{id}/collaborators`
|
||||
|
||||
List collaborators
|
||||
|
||||
Returns all collaborators for a note. Requires access (owner or collaborator).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"userId": 1,
|
||||
"displayName": "displayName",
|
||||
"email": "email",
|
||||
"addedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/notes/{id}/collaborators`
|
||||
|
||||
Add a collaborator
|
||||
|
||||
Owner-only. Adds a user as collaborator on the note.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"userId": 1,
|
||||
"displayName": "displayName",
|
||||
"email": "email",
|
||||
"addedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Errors:** `400` Invalid request (missing userId, owner as collaborator), `409` User is already a collaborator
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/notes/{id}/collaborators`
|
||||
|
||||
Remove a collaborator
|
||||
|
||||
Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
| `userId` | query | | yes | |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
### `GET /api/users/search`
|
||||
|
||||
Search users
|
||||
|
||||
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `q` | query | string | yes | Search query (matches email or displayName) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"displayName": "displayName",
|
||||
"email": "email"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Attachments
|
||||
|
||||
### `GET /api/notes/{id}/attachments`
|
||||
@@ -291,6 +452,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
| `attachmentId` | query | string | no | |
|
||||
| `thumb` | query | string | no | Set to "1" to return the thumbnail instead of the full image (requires attachmentId) (1) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
@@ -302,6 +464,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -330,6 +493,7 @@ Upload an image attachment
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
@@ -393,6 +557,17 @@ Searches across note titles, content, and tags. Excludes trashed notes.
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -451,6 +626,17 @@ Pull changes since timestamp
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -498,3 +684,92 @@ Push local changes
|
||||
**Errors:** `400` Invalid changes payload
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### `GET /api/settings/api-keys`
|
||||
|
||||
List API keys
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "name",
|
||||
"keyPrefix": "keyPrefix",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"lastUsedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/settings/api-keys`
|
||||
|
||||
Create API key
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "name"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "id",
|
||||
"key": "key",
|
||||
"name": "name",
|
||||
"keyPrefix": "keyPrefix"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/settings/api-keys/{id}`
|
||||
|
||||
Revoke API key
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` Key not found
|
||||
|
||||
---
|
||||
|
||||
## MCP
|
||||
|
||||
### `POST /api/mcp`
|
||||
|
||||
MCP Streamable HTTP endpoint
|
||||
|
||||
Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
**Errors:** `400` Invalid session, `401` Invalid or missing API key
|
||||
|
||||
---
|
||||
|
||||
+42
-6
@@ -83,32 +83,68 @@ For a single-user app with multiple devices:
|
||||
- New v4 uses Vite plugin (faster builds)
|
||||
|
||||
### Argon2
|
||||
- Current best practice for password hashing
|
||||
- Memory-hard (resists GPU attacks)
|
||||
- Winner of the Password Hashing Competition
|
||||
- Memory-hard password hashing (resists GPU attacks)
|
||||
- See [AUTH.md](AUTH.md) for authentication details
|
||||
|
||||
## Database Schema
|
||||
|
||||
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
|
||||
|
||||
Key tables:
|
||||
- `users` - Single user (password hash)
|
||||
- `users` - User accounts (password hash, OAuth provider link, role)
|
||||
- `sessions` - Auth sessions (30-day TTL)
|
||||
- `notes` - Core note data with version tracking
|
||||
- `tags` - Unique tag names
|
||||
- `note_tags` - Many-to-many note ↔ tag
|
||||
- `attachments` - Image file metadata (path + thumbnailPath)
|
||||
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
|
||||
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
|
||||
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
|
||||
- `sync_log` - Sync operation history
|
||||
|
||||
### Collaboration Access Control
|
||||
|
||||
Notes support sharing with other users on the same instance:
|
||||
|
||||
- **Owner**: Full control (edit, trash, delete, share/unshare)
|
||||
- **Collaborator**: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via `note_user_state`
|
||||
- Access checks use `canAccessNote()` / `requireNoteAccess()` / `requireNoteOwnership()` helpers
|
||||
- `note_user_state` stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
|
||||
- When owner trashes a note, it vanishes for all collaborators
|
||||
- Collaborators can leave (remove themselves) from shared notes
|
||||
|
||||
## MCP Server
|
||||
|
||||
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
|
||||
|
||||
### Architecture
|
||||
- **Endpoint**: `POST /api/mcp` (Streamable HTTP transport)
|
||||
- **Auth**: Bearer token via API keys (generated in Settings)
|
||||
- **Sessions**: Stateful per MCP spec — session ID in `mcp-session-id` header
|
||||
- **Transport**: `WebStandardStreamableHTTPServerTransport` (works with SvelteKit's Web Standard Request/Response)
|
||||
|
||||
### Shared Logic
|
||||
MCP tool handlers and REST API routes both call the same service layer (`src/lib/server/notes-service.ts`). This avoids duplicating business logic:
|
||||
|
||||
```
|
||||
REST API routes ──┐
|
||||
├──► notes-service.ts ──► Drizzle DB
|
||||
MCP tool handlers ┘
|
||||
```
|
||||
|
||||
### Tools (14)
|
||||
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/lib/server/ # Server-only code (DB, auth, attachments)
|
||||
src/lib/server/ # Server-only code (DB, auth, attachments, notes-service)
|
||||
src/lib/server/mcp/ # MCP server + tool definitions
|
||||
src/lib/sync/ # Sync engine (shared types, server + client implementations)
|
||||
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
|
||||
src/lib/components/ # Svelte UI components
|
||||
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
|
||||
src/lib/types/ # TypeScript type definitions
|
||||
src/routes/api/ # REST API endpoints
|
||||
src/routes/api/ # REST API + MCP endpoints
|
||||
src/routes/ # Page routes (SvelteKit file-based routing)
|
||||
```
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Authentication
|
||||
|
||||
Crumbs supports password-based authentication and optional OAuth/SSO via Google, GitHub, or any OpenID Connect (OIDC) provider (e.g. Authentik, Keycloak, Okta, Auth0).
|
||||
|
||||
## First-run setup
|
||||
|
||||
On first launch, Crumbs presents a setup screen to create the initial admin account with email and password (minimum 8 characters). This account becomes the instance administrator.
|
||||
|
||||
## Password authentication
|
||||
|
||||
- Passwords are hashed with **Argon2** (argon2id variant)
|
||||
- Sessions use httpOnly cookies with 30-day expiry
|
||||
- Admins can reset passwords and revoke sessions for any user via Settings
|
||||
|
||||
## OAuth / SSO
|
||||
|
||||
OAuth providers are **auto-enabled** when their environment variables are set — no code changes needed. The login page dynamically shows buttons for each configured provider.
|
||||
|
||||
### How it works
|
||||
|
||||
1. User clicks an OAuth button on the login page
|
||||
2. Crumbs redirects to the provider's authorization endpoint (with PKCE)
|
||||
3. Provider authenticates the user and redirects back to Crumbs
|
||||
4. Crumbs validates the response, extracts the user's email and name
|
||||
5. If a matching user exists (by provider ID or email), a session is created
|
||||
6. If no matching user exists, the login is rejected (invite-only model)
|
||||
|
||||
### Invite-only model
|
||||
|
||||
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
|
||||
|
||||
1. Go to **Settings > Users > Invite User**
|
||||
2. Create the user with the **same email** they use on the OAuth provider
|
||||
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
|
||||
|
||||
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
|
||||
|
||||
### Environment variables
|
||||
|
||||
#### Google OAuth
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
|
||||
| `AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret |
|
||||
|
||||
**Callback URL:** `https://<your-domain>/api/auth/oauth/google/callback`
|
||||
|
||||
Setup:
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
|
||||
2. Create an OAuth 2.0 Client ID (Web application)
|
||||
3. Add the callback URL above as an authorized redirect URI
|
||||
4. Copy the client ID and secret into your environment
|
||||
|
||||
#### GitHub OAuth
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
|
||||
| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
|
||||
|
||||
**Callback URL:** `https://<your-domain>/api/auth/oauth/github/callback`
|
||||
|
||||
Setup:
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Create a new OAuth App
|
||||
3. Set the authorization callback URL to the callback URL above
|
||||
4. Copy the client ID and secret into your environment
|
||||
|
||||
#### Generic OIDC
|
||||
|
||||
Any OpenID Connect-compliant provider works via the generic OIDC integration. Crumbs automatically discovers endpoints via the provider's `/.well-known/openid-configuration` document.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_OIDC_ISSUER` | OIDC issuer URL (must serve `/.well-known/openid-configuration`) |
|
||||
| `AUTH_OIDC_CLIENT_ID` | OIDC client ID |
|
||||
| `AUTH_OIDC_CLIENT_SECRET` | OIDC client secret |
|
||||
| `AUTH_OIDC_DISPLAY_NAME` | Button label on login page (default: `SSO`) |
|
||||
|
||||
**Callback URL:** `https://<your-domain>/api/auth/oauth/oidc/callback`
|
||||
|
||||
**Required scopes:** `openid`, `profile`, `email`
|
||||
|
||||
### Provider guides
|
||||
|
||||
#### Authentik
|
||||
|
||||
[Authentik](https://goauthentik.io/) is a self-hosted identity provider that works with Crumbs via the generic OIDC integration.
|
||||
|
||||
**1. Create a provider in Authentik:**
|
||||
|
||||
1. Open the Authentik admin panel
|
||||
2. Go to **Applications > Providers > Create**
|
||||
3. Select **OAuth2/OpenID Connect**
|
||||
4. Configure:
|
||||
- **Name:** `Crumbs`
|
||||
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or `explicit-consent` to prompt users)
|
||||
- **Client type:** `Confidential`
|
||||
- **Redirect URIs:** `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
|
||||
- **Scopes:** `openid`, `profile`, `email`
|
||||
5. Save and note the **Client ID** and **Client Secret**
|
||||
|
||||
**2. Create an application in Authentik:**
|
||||
|
||||
1. Go to **Applications > Applications > Create**
|
||||
2. Configure:
|
||||
- **Name:** `Crumbs`
|
||||
- **Slug:** `crumbs`
|
||||
- **Provider:** Select the `Crumbs` provider you just created
|
||||
- **Launch URL:** `https://<your-crumbs-domain>` (optional, for Authentik's app dashboard)
|
||||
3. Save
|
||||
|
||||
**3. Configure Crumbs:**
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||
- AUTH_OIDC_CLIENT_ID=<client-id>
|
||||
- AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||
```
|
||||
|
||||
> **Note:** The issuer URL for Authentik follows the pattern `https://<authentik-domain>/application/o/<app-slug>/`. This URL must serve a valid `/.well-known/openid-configuration` document — you can verify by visiting `https://<authentik-domain>/application/o/crumbs/.well-known/openid-configuration` in your browser.
|
||||
|
||||
**4. Pre-create users:**
|
||||
|
||||
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
|
||||
|
||||
#### Keycloak
|
||||
|
||||
1. Create a new client in your Keycloak realm
|
||||
2. Set **Client type** to `OpenID Connect`, **Client authentication** to `On`
|
||||
3. Add the callback URL: `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
|
||||
4. Set the issuer URL to `https://<keycloak-domain>/realms/<realm-name>`
|
||||
|
||||
```env
|
||||
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
|
||||
AUTH_OIDC_CLIENT_ID=crumbs
|
||||
AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||
AUTH_OIDC_DISPLAY_NAME=Keycloak
|
||||
```
|
||||
|
||||
#### Okta / Auth0
|
||||
|
||||
Use the issuer URL from your Okta org or Auth0 tenant:
|
||||
|
||||
```env
|
||||
# Okta
|
||||
AUTH_OIDC_ISSUER=https://your-org.okta.com
|
||||
|
||||
# Auth0
|
||||
AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
|
||||
```
|
||||
|
||||
### Docker example with multiple providers
|
||||
|
||||
```yaml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- crumbs-data:/data
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
# Google
|
||||
- AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
|
||||
- AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||
# GitHub
|
||||
- AUTH_GITHUB_CLIENT_ID=Iv1.abc123
|
||||
- AUTH_GITHUB_CLIENT_SECRET=abc123secret
|
||||
# Authentik (OIDC)
|
||||
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||
- AUTH_OIDC_CLIENT_ID=crumbs-client-id
|
||||
- AUTH_OIDC_CLIENT_SECRET=crumbs-client-secret
|
||||
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
crumbs-data:
|
||||
```
|
||||
|
||||
All configured providers will appear as buttons on the login page.
|
||||
|
||||
## Security notes
|
||||
|
||||
- All OAuth flows use **PKCE** (Proof Key for Code Exchange) with S256 challenge method
|
||||
- OAuth state and code verifier are stored as httpOnly cookies (10-minute expiry)
|
||||
- Passwords are hashed with **Argon2** (never stored in plain text)
|
||||
- Sessions expire after **30 days**
|
||||
- The `ORIGIN` environment variable must match your actual domain for CSRF protection
|
||||
- Always use **HTTPS** in production (via reverse proxy)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| OAuth button doesn't appear | Provider env vars not set or missing a required variable | Check all required env vars are set and restart |
|
||||
| "No account found" after OAuth | User doesn't exist in Crumbs | Create the user via Settings with matching email |
|
||||
| OIDC discovery fails | Issuer URL is wrong or unreachable | Verify `AUTH_OIDC_ISSUER` serves `/.well-known/openid-configuration` |
|
||||
| Redirect URI mismatch | Callback URL in provider doesn't match `ORIGIN` | Ensure `ORIGIN` matches your domain and the callback URL uses the same origin |
|
||||
| PKCE error | Provider doesn't support S256 PKCE | Most modern providers support it; check provider docs |
|
||||
+33
-3
@@ -100,6 +100,8 @@ node build
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
|
||||
@@ -108,6 +110,10 @@ node build
|
||||
| `PORT` | `3000` | HTTP port |
|
||||
| `NODE_ENV` | `development` | Set to `production` for deployment |
|
||||
|
||||
### OAuth / SSO (optional)
|
||||
|
||||
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
|
||||
|
||||
## Health Check
|
||||
|
||||
The Docker image includes a health check that pings `/login`:
|
||||
@@ -116,10 +122,34 @@ The Docker image includes a health check that pings `/login`:
|
||||
docker inspect --format='{{.State.Health.Status}}' crumbs
|
||||
```
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. See [ARCHITECTURE.md](ARCHITECTURE.md#mcp-server) for technical details and the full tool list.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Open **Settings** in the Crumbs UI
|
||||
2. Create an API key (give it a name like "Claude Code")
|
||||
3. Copy the key (shown only once)
|
||||
4. Configure your MCP client:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"crumbs": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://your-crumbs-instance/api/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always use HTTPS in production (via reverse proxy)
|
||||
- Set a strong password (min 8 characters recommended)
|
||||
- The `ORIGIN` variable must match your actual domain for CSRF protection
|
||||
- Sessions expire after 30 days
|
||||
- Passwords are hashed with Argon2
|
||||
- API keys are SHA-256 hashed (never stored in plain text)
|
||||
- See [AUTH.md](AUTH.md#security-notes) for authentication security details (password hashing, session expiry, PKCE)
|
||||
|
||||
+40
-7
@@ -59,7 +59,7 @@
|
||||
- Tags inside code blocks and URLs are ignored
|
||||
|
||||
### Color-Coded Notes
|
||||
12 colors matching Google Keep's palette:
|
||||
12 note colors:
|
||||
- Default, Coral, Peach, Sand, Mint, Sage
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to note theme
|
||||
@@ -80,18 +80,36 @@
|
||||
- Restore notes from trash
|
||||
- Permanently delete from trash
|
||||
|
||||
### Note Sharing / Collaboration
|
||||
- Share notes with other users on the same instance
|
||||
- User search autocomplete for adding collaborators
|
||||
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
|
||||
- Per-user state: pin, archive, and sort order are independent per user
|
||||
- Owner-only actions: trash, delete, share/unshare
|
||||
- Collaborator indicator (Users icon) on shared note cards
|
||||
- Hover popover showing collaborator names
|
||||
- Share dialog accessible from editor toolbar
|
||||
- Collaborators can leave shared notes
|
||||
- Shared notes appear mixed in main list alongside owned notes
|
||||
|
||||
### Full-Text Search
|
||||
- Search across note titles, content, and tags
|
||||
- Real-time search results as you type
|
||||
- Clear search to restore original view
|
||||
- Case-insensitive matching
|
||||
|
||||
### Single-User Auth
|
||||
- First-run setup: create password (min 8 chars)
|
||||
- Argon2 password hashing (industry standard)
|
||||
- Session-based auth with 30-day expiry
|
||||
- All routes protected except /login and /setup
|
||||
- Logout clears session
|
||||
### Authentication & OAuth / SSO
|
||||
- Multi-user with role-based access (admin / user)
|
||||
- Password auth (Argon2) + optional OAuth/SSO (Google, GitHub, any OIDC provider)
|
||||
- Session-based auth with active session management
|
||||
- Invite-only OAuth model with PKCE-secured flows
|
||||
- See [AUTH.md](AUTH.md) for full details and provider setup guides
|
||||
|
||||
### User Management (admin)
|
||||
- Admin dashboard to create, list, and delete users
|
||||
- Assign roles (admin / user)
|
||||
- Reset passwords for any user
|
||||
- Revoke all sessions (force logout) for any user
|
||||
|
||||
### PWA / Offline-First
|
||||
- Installable as standalone app
|
||||
@@ -102,6 +120,21 @@
|
||||
- LWW conflict resolution for multi-device use
|
||||
- Sync status indicator (synced/syncing/offline/error)
|
||||
|
||||
### MCP Server (Model Context Protocol)
|
||||
- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport)
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; pin, reorder notes; upload images
|
||||
- API key authentication (Bearer token)
|
||||
- Compatible with Claude Code, Claude Desktop, and other MCP clients
|
||||
- Stateful sessions with automatic cleanup
|
||||
|
||||
### API Key Management
|
||||
- Generate API keys from the Settings page
|
||||
- Keys use `crumbs_` prefix for easy identification
|
||||
- SHA-256 hashed storage (keys shown once at creation)
|
||||
- Track last-used timestamps
|
||||
- Revoke keys with confirmation dialog
|
||||
- MCP client config example shown in Settings
|
||||
|
||||
### Docker Deployment
|
||||
- Multi-stage Dockerfile (build + slim runtime)
|
||||
- docker-compose.yml with persistent volume
|
||||
|
||||
@@ -168,6 +168,104 @@ paths:
|
||||
'404':
|
||||
description: Note not found
|
||||
|
||||
/api/notes/{id}/collaborators:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
summary: List collaborators
|
||||
description: Returns all collaborators for a note. Requires access (owner or collaborator).
|
||||
tags: [Collaboration]
|
||||
responses:
|
||||
'200':
|
||||
description: Collaborator list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Collaborator'
|
||||
post:
|
||||
summary: Add a collaborator
|
||||
description: Owner-only. Adds a user as collaborator on the note.
|
||||
tags: [Collaboration]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [userId]
|
||||
properties:
|
||||
userId:
|
||||
type: integer
|
||||
responses:
|
||||
'201':
|
||||
description: Collaborator added, returns updated list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Collaborator'
|
||||
'400':
|
||||
description: Invalid request (missing userId, owner as collaborator)
|
||||
'409':
|
||||
description: User is already a collaborator
|
||||
delete:
|
||||
summary: Remove a collaborator
|
||||
description: Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
|
||||
tags: [Collaboration]
|
||||
parameters:
|
||||
- name: userId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
enum: [self]
|
||||
responses:
|
||||
'200':
|
||||
description: Collaborator removed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
|
||||
/api/users/search:
|
||||
get:
|
||||
summary: Search users
|
||||
description: Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
|
||||
tags: [Users]
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Search query (matches email or displayName)
|
||||
responses:
|
||||
'200':
|
||||
description: Matching users (max 10)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
|
||||
/api/notes/{id}/attachments:
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -325,12 +423,101 @@ paths:
|
||||
'400':
|
||||
description: Invalid changes payload
|
||||
|
||||
/api/settings/api-keys:
|
||||
get:
|
||||
summary: List API keys
|
||||
tags: [Settings]
|
||||
responses:
|
||||
'200':
|
||||
description: List of API keys (without hashes)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ApiKey'
|
||||
post:
|
||||
summary: Create API key
|
||||
tags: [Settings]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: API key created (key shown only once)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
keyPrefix:
|
||||
type: string
|
||||
|
||||
/api/settings/api-keys/{id}:
|
||||
delete:
|
||||
summary: Revoke API key
|
||||
tags: [Settings]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Key revoked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
'404':
|
||||
description: Key not found
|
||||
|
||||
/api/mcp:
|
||||
post:
|
||||
summary: MCP Streamable HTTP endpoint
|
||||
description: Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
|
||||
tags: [MCP]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: JSON-RPC 2.0 message
|
||||
responses:
|
||||
'200':
|
||||
description: JSON-RPC response (may be SSE stream)
|
||||
'400':
|
||||
description: Invalid session
|
||||
'401':
|
||||
description: Invalid or missing API key
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
sessionCookie:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: session
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: API key (crumbs_...)
|
||||
|
||||
schemas:
|
||||
Success:
|
||||
@@ -452,3 +639,35 @@ components:
|
||||
description: Unix timestamp in milliseconds
|
||||
data:
|
||||
$ref: '#/components/schemas/NoteInput'
|
||||
|
||||
Collaborator:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: integer
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
addedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
ApiKey:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
keyPrefix:
|
||||
type: string
|
||||
description: First 12 characters of the key for display
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
lastUsedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE `login_attempts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`ip` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`success` integer NOT NULL,
|
||||
`timestamp` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint
|
||||
DROP INDEX `tags_name_unique`;--> statement-breakpoint
|
||||
ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`email` text DEFAULT '' NOT NULL,
|
||||
`display_name` text DEFAULT '' NOT NULL,
|
||||
`role` text DEFAULT 'user' NOT NULL,
|
||||
`password_hash` text,
|
||||
`auth_provider` text DEFAULT 'password' NOT NULL,
|
||||
`provider_id` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint
|
||||
DROP TABLE `users`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||
ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id);
|
||||
@@ -0,0 +1,684 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3",
|
||||
"prevId": "682b02aa-a28d-4904-8996-cbc4d740f626",
|
||||
"tables": {
|
||||
"attachments": {
|
||||
"name": "attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"featured": {
|
||||
"name": "featured",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"attachments_user_id_users_id_fk": {
|
||||
"name": "attachments_user_id_users_id_fk",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"attachments_note_id_notes_id_fk": {
|
||||
"name": "attachments_note_id_notes_id_fk",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"login_attempts": {
|
||||
"name": "login_attempts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"success": {
|
||||
"name": "success",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"login_attempts_ip_timestamp_idx": {
|
||||
"name": "login_attempts_ip_timestamp_idx",
|
||||
"columns": [
|
||||
"ip",
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_tags": {
|
||||
"name": "note_tags",
|
||||
"columns": {
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"note_tags_note_id_idx": {
|
||||
"name": "note_tags_note_id_idx",
|
||||
"columns": [
|
||||
"note_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"note_tags_tag_id_idx": {
|
||||
"name": "note_tags_tag_id_idx",
|
||||
"columns": [
|
||||
"tag_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"note_tags_note_id_notes_id_fk": {
|
||||
"name": "note_tags_note_id_notes_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"note_tags_tag_id_tags_id_fk": {
|
||||
"name": "note_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"pinned": {
|
||||
"name": "pinned",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed": {
|
||||
"name": "trashed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed_at": {
|
||||
"name": "trashed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checklist_mode": {
|
||||
"name": "checklist_mode",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notes_user_id_idx": {
|
||||
"name": "notes_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"notes_trashed_archived_idx": {
|
||||
"name": "notes_trashed_archived_idx",
|
||||
"columns": [
|
||||
"trashed",
|
||||
"archived"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"notes_updated_at_idx": {
|
||||
"name": "notes_updated_at_idx",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"notes_user_id_users_id_fk": {
|
||||
"name": "notes_user_id_users_id_fk",
|
||||
"tableFrom": "notes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_used_at": {
|
||||
"name": "last_used_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sync_log": {
|
||||
"name": "sync_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation": {
|
||||
"name": "operation",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sync_log_timestamp_idx": {
|
||||
"name": "sync_log_timestamp_idx",
|
||||
"columns": [
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sync_log_user_id_users_id_fk": {
|
||||
"name": "sync_log_user_id_users_id_fk",
|
||||
"tableFrom": "sync_log",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"sync_log_note_id_notes_id_fk": {
|
||||
"name": "sync_log_note_id_notes_id_fk",
|
||||
"tableFrom": "sync_log",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tags_name_user_unique": {
|
||||
"name": "tags_name_user_unique",
|
||||
"columns": [
|
||||
"name",
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tags_user_id_users_id_fk": {
|
||||
"name": "tags_user_id_users_id_fk",
|
||||
"tableFrom": "tags",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_provider": {
|
||||
"name": "auth_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'password'"
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1773000424830,
|
||||
"tag": "0000_wooden_vargas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1773016788899,
|
||||
"tag": "0001_strange_lord_hawal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.11.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
@@ -29,6 +29,7 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
@@ -49,6 +50,7 @@
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+492
@@ -13,6 +13,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.27.1
|
||||
version: 1.27.1(zod@4.3.6)
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.4(@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)))
|
||||
@@ -73,6 +76,9 @@ importers:
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
zxcvbn:
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
@@ -961,6 +967,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@hono/node-server@1.19.11':
|
||||
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@isaacs/cliui@9.0.0':
|
||||
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -984,6 +996,16 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.27.1':
|
||||
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@oslojs/asn1@1.0.0':
|
||||
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
|
||||
|
||||
@@ -1613,11 +1635,23 @@ packages:
|
||||
'@vitest/utils@3.2.4':
|
||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
@@ -1705,6 +1739,10 @@ packages:
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
@@ -1723,6 +1761,10 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1771,9 +1813,21 @@ packages:
|
||||
commondir@1.0.1:
|
||||
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
|
||||
|
||||
content-disposition@1.0.1:
|
||||
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
content-type@1.0.5:
|
||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1781,6 +1835,10 @@ packages:
|
||||
core-js-compat@3.48.0:
|
||||
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
@@ -1842,6 +1900,10 @@ packages:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1949,6 +2011,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
ejs@3.1.10:
|
||||
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1957,6 +2022,10 @@ packages:
|
||||
electron-to-chromium@1.5.307:
|
||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||
|
||||
encodeurl@2.0.0:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
@@ -2014,6 +2083,9 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
escape-string-regexp@4.0.0:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2037,6 +2109,18 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventsource-parser@3.0.6:
|
||||
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
eventsource@3.0.7:
|
||||
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2045,6 +2129,16 @@ packages:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express-rate-limit@8.3.0:
|
||||
resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -2069,6 +2163,10 @@ packages:
|
||||
filelist@1.0.6:
|
||||
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
|
||||
|
||||
finalhandler@2.1.1:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
for-each@0.3.5:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2077,6 +2175,14 @@ packages:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
@@ -2173,6 +2279,18 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hono@4.12.5:
|
||||
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb@7.1.1:
|
||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||
|
||||
@@ -2192,6 +2310,14 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2251,6 +2377,9 @@ packages:
|
||||
resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
is-reference@1.2.1:
|
||||
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
|
||||
|
||||
@@ -2320,6 +2449,9 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.0:
|
||||
resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2334,6 +2466,9 @@ packages:
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-schema-typed@8.0.2:
|
||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||
|
||||
json-schema@0.4.0:
|
||||
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
||||
|
||||
@@ -2487,6 +2622,22 @@ packages:
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
merge-descriptors@2.0.0:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mimic-response@3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2528,6 +2679,10 @@ packages:
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-abi@3.87.0:
|
||||
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2543,6 +2698,10 @@ packages:
|
||||
node-releases@2.0.36:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2558,6 +2717,10 @@ packages:
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -2571,6 +2734,10 @@ packages:
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2582,6 +2749,9 @@ packages:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
path-to-regexp@8.3.0:
|
||||
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
@@ -2600,6 +2770,10 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
playwright-core@1.58.2:
|
||||
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2694,6 +2868,10 @@ packages:
|
||||
prosemirror-view@1.41.6:
|
||||
resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
@@ -2705,6 +2883,18 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
@@ -2768,6 +2958,10 @@ packages:
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
sade@1.8.1:
|
||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2787,6 +2981,9 @@ packages:
|
||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -2796,10 +2993,18 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serialize-javascript@7.0.4:
|
||||
resolution: {integrity: sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-cookie-parser@3.0.1:
|
||||
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
|
||||
|
||||
@@ -2815,6 +3020,9 @@ packages:
|
||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2883,6 +3091,10 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -2999,6 +3211,10 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.0.1
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
totalist@3.0.1:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3018,6 +3234,10 @@ packages:
|
||||
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
type-is@2.0.1:
|
||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3073,6 +3293,10 @@ packages:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
upath@1.2.0:
|
||||
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3090,6 +3314,10 @@ packages:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite-node@3.2.4:
|
||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -3281,6 +3509,14 @@ packages:
|
||||
zimmerframe@1.1.4:
|
||||
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
|
||||
|
||||
zod-to-json-schema@3.25.1:
|
||||
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zxcvbn@4.4.2:
|
||||
resolution: {integrity: sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==}
|
||||
|
||||
@@ -4115,6 +4351,10 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@hono/node-server@1.19.11(hono@4.12.5)':
|
||||
dependencies:
|
||||
hono: 4.12.5
|
||||
|
||||
'@isaacs/cliui@9.0.0': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
@@ -4141,6 +4381,28 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.11(hono@4.12.5)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.6
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.3.0(express@5.2.1)
|
||||
hono: 4.12.5
|
||||
jose: 6.2.0
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.3.6
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@oslojs/asn1@1.0.0':
|
||||
dependencies:
|
||||
'@oslojs/binary': 1.0.0
|
||||
@@ -4723,8 +4985,17 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
negotiator: 1.0.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -4824,6 +5095,20 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.0
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -4847,6 +5132,8 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
@@ -4892,14 +5179,25 @@ snapshots:
|
||||
|
||||
commondir@1.0.1: {}
|
||||
|
||||
content-disposition@1.0.1: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-js-compat@3.48.0:
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
@@ -4956,6 +5254,8 @@ snapshots:
|
||||
has-property-descriptors: 1.0.2
|
||||
object-keys: 1.1.1
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
devalue@5.6.3: {}
|
||||
@@ -4980,12 +5280,16 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
ejs@3.1.10:
|
||||
dependencies:
|
||||
jake: 10.9.4
|
||||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
@@ -5144,6 +5448,8 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
esm-env@1.2.2: {}
|
||||
@@ -5162,10 +5468,56 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventsource-parser@3.0.6: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
dependencies:
|
||||
eventsource-parser: 3.0.6
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
express-rate-limit@8.3.0(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.1.0
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.2.2
|
||||
content-disposition: 1.0.1
|
||||
content-type: 1.0.5
|
||||
cookie: 1.1.1
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 2.0.0
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.0
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
serve-static: 2.2.1
|
||||
statuses: 2.0.2
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
@@ -5182,6 +5534,17 @@ snapshots:
|
||||
dependencies:
|
||||
minimatch: 5.1.9
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
@@ -5191,6 +5554,10 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fs-extra@9.1.0:
|
||||
@@ -5293,6 +5660,20 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hono@4.12.5: {}
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb@7.1.1: {}
|
||||
|
||||
idb@8.0.3: {}
|
||||
@@ -5309,6 +5690,10 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -5374,6 +5759,8 @@ snapshots:
|
||||
|
||||
is-obj@1.0.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-reference@1.2.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -5441,6 +5828,8 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.2.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
@@ -5449,6 +5838,8 @@ snapshots:
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
@@ -5565,6 +5956,16 @@ snapshots:
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
minimatch@10.2.4:
|
||||
@@ -5591,6 +5992,8 @@ snapshots:
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-abi@3.87.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
@@ -5601,6 +6004,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.36: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
@@ -5616,6 +6021,10 @@ snapshots:
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
@@ -5630,6 +6039,8 @@ snapshots:
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
@@ -5639,6 +6050,8 @@ snapshots:
|
||||
lru-cache: 11.2.6
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@8.3.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
@@ -5649,6 +6062,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
playwright-core@1.58.2: {}
|
||||
|
||||
playwright@1.58.2:
|
||||
@@ -5792,6 +6207,11 @@ snapshots:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
@@ -5801,6 +6221,19 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
@@ -5904,6 +6337,16 @@ snapshots:
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 8.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
sade@1.8.1:
|
||||
dependencies:
|
||||
mri: 1.2.0
|
||||
@@ -5929,12 +6372,39 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-regex: 1.2.1
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
mime-types: 3.0.2
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serialize-javascript@7.0.4: {}
|
||||
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-cookie-parser@3.0.1: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
@@ -5959,6 +6429,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -6030,6 +6502,8 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -6189,6 +6663,8 @@ snapshots:
|
||||
markdown-it-task-lists: 2.1.1
|
||||
prosemirror-markdown: 1.13.4
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tr46@1.0.1:
|
||||
@@ -6208,6 +6684,12 @@ snapshots:
|
||||
|
||||
type-fest@0.16.0: {}
|
||||
|
||||
type-is@2.0.1:
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.2
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -6271,6 +6753,8 @@ snapshots:
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
upath@1.2.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||
@@ -6283,6 +6767,8 @@ snapshots:
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite-node@3.2.4(@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):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -6558,4 +7044,10 @@ snapshots:
|
||||
|
||||
zimmerframe@1.1.4: {}
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zxcvbn@4.4.2: {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
|
||||
import { validateApiKey, getUserForApiKey } from '$lib/server/api-keys.js';
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
@@ -21,6 +22,38 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// MCP endpoint: authenticate via Bearer token (API key)
|
||||
if (pathname.startsWith('/api/mcp')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Check if setup is complete
|
||||
const setupDone = await isSetupComplete();
|
||||
if (!setupDone) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
let { content, onChange }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
interface Props {
|
||||
collaborators: Collaborator[];
|
||||
ownerName?: string;
|
||||
}
|
||||
|
||||
const { collaborators, ownerName }: Props = $props();
|
||||
|
||||
let showPopover = $state(false);
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleMouseEnter() {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
showPopover = true;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
clearTimeout(hoverTimeout);
|
||||
showPopover = false;
|
||||
}
|
||||
|
||||
function handleClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
showPopover = !showPopover;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative inline-flex"
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
title="Shared note"
|
||||
onclick={handleClick}
|
||||
data-testid="collaborator-indicator"
|
||||
>
|
||||
<Users class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if showPopover}
|
||||
<div
|
||||
class="absolute left-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||
data-testid="collaborator-popover"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
|
||||
<ul class="space-y-0.5">
|
||||
{#if ownerName}
|
||||
<li class="text-xs text-[var(--text)]">
|
||||
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
|
||||
</li>
|
||||
{/if}
|
||||
{#each collaborators as collab}
|
||||
<li class="text-xs text-[var(--text)]">
|
||||
{collab.displayName || collab.email}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
const { src, alt, onClose }: Props = $props();
|
||||
|
||||
let dialogEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
@@ -21,14 +23,21 @@
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
dialogEl?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={dialogEl}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={handleBackgroundClick}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image lightbox"
|
||||
tabindex="-1"
|
||||
data-testid="image-lightbox"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
onUpload: (attachment: Attachment) => void;
|
||||
onRemove: (attachmentId: string) => void;
|
||||
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
|
||||
showDropZone?: boolean;
|
||||
}
|
||||
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured }: Props = $props();
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: Props = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let optimizing = $state(false);
|
||||
@@ -118,14 +119,18 @@
|
||||
{@const isPending = 'pending' in attachment && attachment.pending}
|
||||
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
||||
<div class="group relative">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
|
||||
<button
|
||||
type="button"
|
||||
class="h-20 w-20 cursor-pointer rounded-sm p-0 border-0 bg-transparent overflow-hidden"
|
||||
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 object-cover {isPending ? 'opacity-70' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
@@ -133,7 +138,7 @@
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
|
||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 {attachment.featured ? 'block' : 'hidden group-hover:block'}"
|
||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 transition-opacity {attachment.featured ? 'opacity-100' : 'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100'}"
|
||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||
data-testid="toggle-featured"
|
||||
>
|
||||
@@ -142,7 +147,7 @@
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
|
||||
class="absolute -right-1 -top-1 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
@@ -154,33 +159,56 @@
|
||||
{/if}
|
||||
|
||||
<!-- Upload area -->
|
||||
<div
|
||||
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDropZone}
|
||||
<!-- Drop zone for desktop, browse-only label for mobile -->
|
||||
<div
|
||||
class="hidden rounded-sm border-2 border-dashed p-4 text-center transition-colors md:block {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Mobile: compact browse button -->
|
||||
<div class="md:hidden">
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-sm text-[var(--text-muted)] hover:border-[var(--primary)]">
|
||||
<span>Add images</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input-mobile"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lightboxSrc}
|
||||
|
||||
@@ -6,28 +6,12 @@
|
||||
|
||||
interface Props {
|
||||
onMenuToggle: () => void;
|
||||
user: { id: number; email: string; displayName: string; role: 'admin' | 'user' } | null;
|
||||
}
|
||||
|
||||
let { onMenuToggle, user }: Props = $props();
|
||||
let { onMenuToggle }: 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 -->
|
||||
@@ -64,41 +48,6 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import { StickyNote, Archive, Trash2, Tag } from 'lucide-svelte';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import { page } from '$app/stores';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -17,17 +17,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
loadNotes(filter);
|
||||
closeMobile();
|
||||
}
|
||||
|
||||
function selectTag(tag: string | null) {
|
||||
selectedTag.set(tag);
|
||||
loadNotes('all');
|
||||
closeMobile();
|
||||
}
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
@@ -44,51 +38,19 @@
|
||||
>
|
||||
<nav class="p-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('all')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
Crumbs
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('archived')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Archive size={20} />
|
||||
Archive
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('trashed')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
Trash
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<ul class="space-y-1">
|
||||
{#each navItems as item}
|
||||
<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"
|
||||
href={item.href}
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<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
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
|
||||
@@ -96,22 +58,34 @@
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each $allTags as tag}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectTag($selectedTag === tag ? null : tag)}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
<a
|
||||
href="/tag/{tag}"
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Tag size={16} />
|
||||
#{tag}
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
{#if appVersion}
|
||||
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)] px-6 py-3 text-right text-xs text-[var(--text-muted)]">
|
||||
v{appVersion}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex w-full items-center gap-3 px-6 py-3 text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--primary)]"
|
||||
onclick={closeMobile}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Settings
|
||||
</a>
|
||||
{#if appVersion}
|
||||
<div class="border-t border-[var(--border-subtle)] px-6 py-2 text-right text-xs text-[var(--text-muted)]">
|
||||
v{appVersion}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import CollaboratorPopover from './CollaboratorPopover.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
@@ -73,6 +75,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<article
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
||||
style={cardStyle}
|
||||
@@ -88,15 +91,19 @@
|
||||
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
|
||||
{#each featuredAttachments.slice(0, 3) as attachment}
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||
alt={attachment.filename}
|
||||
class="h-24 w-full cursor-pointer object-cover"
|
||||
loading="lazy"
|
||||
<button
|
||||
type="button"
|
||||
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
|
||||
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="card-thumbnail"
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||
alt={attachment.filename}
|
||||
class="h-24 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if featuredAttachments.length > 3}
|
||||
@@ -107,16 +114,25 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Status indicators (top-right) -->
|
||||
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
|
||||
{#if note.isShared && note.collaborators}
|
||||
<CollaboratorPopover
|
||||
collaborators={note.collaborators}
|
||||
ownerName={note.isOwner ? 'You' : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if note.title}
|
||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
||||
@@ -203,14 +219,25 @@
|
||||
<Archive class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{#if note.isShared && !note.isOwner}
|
||||
<button
|
||||
onclick={stop(() => leaveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Leave note"
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
@@ -34,12 +37,11 @@
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
// Mutable note identity — allows transitioning from new → saved without closing
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -50,6 +52,36 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||
const isOwner = $derived(note?.isOwner !== false);
|
||||
const isShared = $derived(collaboratorsList.length > 0);
|
||||
// Use displayName from first user or 'You' for owner
|
||||
const ownerName = $derived(isOwner ? 'You' : 'Owner');
|
||||
|
||||
function handleCollaboratorsUpdate(updated: Collaborator[]) {
|
||||
collaboratorsList = updated;
|
||||
// Update the note in the store
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) =>
|
||||
n.id === noteId
|
||||
? { ...n, collaborators: updated, isShared: updated.length > 0 }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleShareDialog() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
if (!id) return;
|
||||
}
|
||||
showShareDialog = !showShareDialog;
|
||||
}
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
@@ -211,7 +243,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#if noteId && showImageUpload}
|
||||
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
@@ -219,27 +251,9 @@
|
||||
onUpload={handleAttachmentUpload}
|
||||
onRemove={handleAttachmentRemove}
|
||||
onToggleFeatured={handleToggleFeatured}
|
||||
showDropZone={showImageUpload}
|
||||
/>
|
||||
</div>
|
||||
{:else if noteId && attachmentsList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each attachmentsList as attachment}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover"
|
||||
onclick={() => { lightboxSrc = `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
@@ -305,6 +319,26 @@
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Share note"
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if isShared}
|
||||
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<UserPlus class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||
<Users class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -317,3 +351,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
<ShareDialog
|
||||
noteId={noteId}
|
||||
collaborators={collaboratorsList}
|
||||
{ownerName}
|
||||
onClose={() => (showShareDialog = false)}
|
||||
onUpdate={handleCollaboratorsUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<div
|
||||
use:dragHandle
|
||||
aria-label="drag handle for {note.title || 'note'}"
|
||||
class="absolute top-2 right-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
class="absolute top-2 left-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
data-testid="note-drag-handle"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
tag?: string | null;
|
||||
}
|
||||
|
||||
let { filter, tag = null }: Props = $props();
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
|
||||
// Sync store with route props
|
||||
$effect(() => {
|
||||
currentFilter.set(filter);
|
||||
selectedTag.set(tag);
|
||||
});
|
||||
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
|
||||
const unsubscribe = notes.subscribe(($notes) => {
|
||||
if ($notes.length === 0) return;
|
||||
const note = $notes.find((n) => n.id === hash);
|
||||
if (note) editingNote = note;
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<div class="mx-auto mb-6 max-w-xl">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
{:else if filter === 'trashed'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
|
||||
{:else if tag}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
|
||||
{/if}
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
{#if filter === 'trashed'}
|
||||
No crumbs in trash
|
||||
{:else if filter === 'archived'}
|
||||
No archived crumbs
|
||||
{:else if tag}
|
||||
No crumbs with tag #{tag}
|
||||
{:else}
|
||||
No crumbs yet
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
{/if}
|
||||
@@ -1,17 +1,27 @@
|
||||
<script lang="ts">
|
||||
import zxcvbn from 'zxcvbn';
|
||||
import type { ZXCVBNResult } from 'zxcvbn';
|
||||
|
||||
let { password = '' }: { password: string } = $props();
|
||||
|
||||
const result = $derived(password.length > 0 ? zxcvbn(password) : null);
|
||||
let result = $state<ZXCVBNResult | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (password.length > 0) {
|
||||
import('zxcvbn').then((mod) => {
|
||||
result = mod.default(password);
|
||||
});
|
||||
} else {
|
||||
result = 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'
|
||||
'bg-[var(--destructive)]',
|
||||
'bg-[var(--destructive)]',
|
||||
'bg-[var(--primary)]',
|
||||
'bg-[var(--success-text)]',
|
||||
'bg-[var(--success-text)]'
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -20,13 +30,13 @@
|
||||
<div class="flex gap-1">
|
||||
{#each { length: 4 } as _, i}
|
||||
<div
|
||||
class="h-1 flex-1 rounded-full transition-colors {i <= result.score
|
||||
class="h-1 flex-1 rounded-sm transition-colors {i <= result.score
|
||||
? colors[result.score]
|
||||
: 'bg-gray-200 dark:bg-gray-600'}"
|
||||
: 'bg-[var(--border-subtle)]'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{labels[result.score]}
|
||||
{#if result.feedback.warning}
|
||||
— {result.feedback.warning}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface UserResult {
|
||||
id: number;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
collaborators: Collaborator[];
|
||||
ownerName: string;
|
||||
onClose: () => void;
|
||||
onUpdate: (collaborators: Collaborator[]) => void;
|
||||
}
|
||||
|
||||
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<UserResult[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleSearch(query: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
if (!query.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
searchTimeout = setTimeout(async () => {
|
||||
isSearching = true;
|
||||
try {
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const users: UserResult[] = await res.json();
|
||||
// Filter out existing collaborators
|
||||
const collabIds = new Set(collaborators.map((c) => c.userId));
|
||||
searchResults = users.filter((u) => !collabIds.has(u.id));
|
||||
}
|
||||
} catch {
|
||||
// Search failed silently
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
handleSearch(searchQuery);
|
||||
});
|
||||
|
||||
async function addCollaborator(user: UserResult) {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id })
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: Collaborator[] = await res.json();
|
||||
onUpdate(updated);
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
showToast(`Shared with ${user.displayName || user.email}`, 'success');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
|
||||
showToast(err.message || 'Failed to share', 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to share note', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCollaborator(userId: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = collaborators.filter((c) => c.userId !== userId);
|
||||
onUpdate(updated);
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to remove collaborator', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="share-dialog-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
data-testid="share-dialog"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
title="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="relative px-4 pt-3">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="share-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search results dropdown -->
|
||||
{#if searchResults.length > 0}
|
||||
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
|
||||
{#each searchResults as user}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
|
||||
onclick={() => addCollaborator(user)}
|
||||
data-testid="share-user-result"
|
||||
>
|
||||
<span class="font-medium">{user.displayName || 'User'}</span>
|
||||
{#if user.email}
|
||||
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Current collaborators -->
|
||||
<div class="px-4 py-3">
|
||||
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
|
||||
<ul class="space-y-1">
|
||||
<!-- Owner -->
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
|
||||
<span class="text-sm text-[var(--text)]">
|
||||
{ownerName}
|
||||
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
|
||||
</span>
|
||||
</li>
|
||||
<!-- Collaborators -->
|
||||
{#each collaborators as collab}
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
|
||||
<span class="text-sm text-[var(--text)]">
|
||||
{collab.displayName || collab.email}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => removeCollaborator(collab.userId)}
|
||||
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
|
||||
title="Remove"
|
||||
data-testid="remove-collaborator-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
selectedTag.update((current) => (current === tag ? null : tag));
|
||||
const isActive = $page.url.pathname === `/tag/${tag}`;
|
||||
goto(isActive ? '/' : `/tag/${tag}`);
|
||||
}
|
||||
|
||||
function isTagActive(tag: string, pathname: string): boolean {
|
||||
return pathname === `/tag/${tag}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
|
||||
{#each $allTags as tag}
|
||||
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
|
||||
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -57,16 +57,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
|
||||
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
tabindex="0"
|
||||
onclick={() => editor?.commands.focus()}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.tiptap-wrapper :global(.tiptap) {
|
||||
outline: none;
|
||||
min-height: 80px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { generateApiKey, validateApiKey, listApiKeys, deleteApiKey } from './api-keys.js';
|
||||
import { users } from './db/schema.js';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
describe('API Keys', () => {
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
// Create a test user
|
||||
db.insert(users)
|
||||
.values({ email: 'test@example.com', displayName: 'Test', createdAt: new Date() })
|
||||
.run();
|
||||
userId = 1;
|
||||
});
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
it('returns key with crumbs_ prefix', () => {
|
||||
const result = generateApiKey('Test Key', userId, db);
|
||||
expect(result.key).toMatch(/^crumbs_[a-f0-9]{64}$/);
|
||||
expect(result.name).toBe('Test Key');
|
||||
expect(result.id).toBeTruthy();
|
||||
expect(result.keyPrefix).toBe(result.key.slice(0, 12));
|
||||
});
|
||||
|
||||
it('generates unique keys each time', () => {
|
||||
const key1 = generateApiKey('Key 1', userId, db);
|
||||
const key2 = generateApiKey('Key 2', userId, db);
|
||||
expect(key1.key).not.toBe(key2.key);
|
||||
expect(key1.id).not.toBe(key2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey', () => {
|
||||
it('returns valid with userId for a valid key', () => {
|
||||
const { key } = generateApiKey('Test Key', userId, db);
|
||||
const result = validateApiKey(key, db);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.userId).toBe(userId);
|
||||
});
|
||||
|
||||
it('returns invalid for an invalid key', () => {
|
||||
const result = validateApiKey('crumbs_invalid', db);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.userId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns invalid for empty string', () => {
|
||||
expect(validateApiKey('', db).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns invalid for key without prefix', () => {
|
||||
expect(validateApiKey('no_prefix_here', db).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('updates lastUsedAt on validation', () => {
|
||||
const { key, id } = generateApiKey('Test Key', userId, db);
|
||||
const before = listApiKeys(userId, db).find((k) => k.id === id);
|
||||
expect(before?.lastUsedAt).toBeNull();
|
||||
|
||||
validateApiKey(key, db);
|
||||
|
||||
const after = listApiKeys(userId, db).find((k) => k.id === id);
|
||||
expect(after?.lastUsedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listApiKeys', () => {
|
||||
it('returns empty array when no keys exist', () => {
|
||||
expect(listApiKeys(userId, db)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns keys without hash', () => {
|
||||
generateApiKey('Key 1', userId, db);
|
||||
generateApiKey('Key 2', userId, db);
|
||||
|
||||
const keys = listApiKeys(userId, db);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0]).toHaveProperty('name');
|
||||
expect(keys[0]).toHaveProperty('keyPrefix');
|
||||
expect(keys[0]).toHaveProperty('createdAt');
|
||||
expect(keys[0]).not.toHaveProperty('keyHash');
|
||||
});
|
||||
|
||||
it('only returns keys for the specified user', () => {
|
||||
db.insert(users)
|
||||
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
|
||||
.run();
|
||||
const otherUserId = 2;
|
||||
|
||||
generateApiKey('My Key', userId, db);
|
||||
generateApiKey('Other Key', otherUserId, db);
|
||||
|
||||
expect(listApiKeys(userId, db)).toHaveLength(1);
|
||||
expect(listApiKeys(otherUserId, db)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteApiKey', () => {
|
||||
it('deletes an existing key and returns true', () => {
|
||||
const { id, key } = generateApiKey('Test Key', userId, db);
|
||||
expect(deleteApiKey(id, userId, db)).toBe(true);
|
||||
expect(validateApiKey(key, db).valid).toBe(false);
|
||||
expect(listApiKeys(userId, db)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns false for non-existent key', () => {
|
||||
expect(deleteApiKey('nonexistent', userId, db)).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot delete another user\'s key', () => {
|
||||
db.insert(users)
|
||||
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
|
||||
.run();
|
||||
const otherUserId = 2;
|
||||
|
||||
const { id } = generateApiKey('Other Key', otherUserId, db);
|
||||
expect(deleteApiKey(id, userId, db)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { db } from './db/index.js';
|
||||
import { apiKeys, users } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
type Db = BetterSQLite3Database<typeof schema>;
|
||||
|
||||
const PREFIX = 'crumbs_';
|
||||
|
||||
function hashKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
export function generateApiKey(
|
||||
name: string,
|
||||
userId: number,
|
||||
database: Db = db
|
||||
): { id: string; key: string; name: string; keyPrefix: string } {
|
||||
const id = uuidv4();
|
||||
const raw = randomBytes(32).toString('hex');
|
||||
const key = `${PREFIX}${raw}`;
|
||||
const keyHash = hashKey(key);
|
||||
const keyPrefix = key.slice(0, 12);
|
||||
|
||||
database
|
||||
.insert(apiKeys)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.run();
|
||||
|
||||
return { id, key, name, keyPrefix };
|
||||
}
|
||||
|
||||
export function validateApiKey(
|
||||
key: string,
|
||||
database: Db = db
|
||||
): { valid: boolean; userId: number | null } {
|
||||
if (!key || !key.startsWith(PREFIX)) return { valid: false, userId: null };
|
||||
|
||||
const keyHash = hashKey(key);
|
||||
const row = database.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get();
|
||||
if (!row) return { valid: false, userId: null };
|
||||
|
||||
database
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiKeys.id, row.id))
|
||||
.run();
|
||||
|
||||
return { valid: true, userId: row.userId };
|
||||
}
|
||||
|
||||
export function getUserForApiKey(
|
||||
userId: number,
|
||||
database: Db = db
|
||||
): App.Locals['user'] | null {
|
||||
const user = database
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
role: users.role
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.get();
|
||||
|
||||
return user ? (user as App.Locals['user']) : null;
|
||||
}
|
||||
|
||||
export function listApiKeys(userId: number, database: Db = db) {
|
||||
return database
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function deleteApiKey(id: string, userId: number, database: Db = db): boolean {
|
||||
const existing = database
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing || existing.userId !== userId) return false;
|
||||
|
||||
const result = database.delete(apiKeys).where(eq(apiKeys.id, id)).run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from './db/index.js';
|
||||
import { notes, noteCollaborators } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
type EventWithLocals = {
|
||||
locals: App.Locals;
|
||||
@@ -14,3 +17,36 @@ export function requireAdmin(event: EventWithLocals) {
|
||||
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can access a note (as owner or collaborator).
|
||||
*/
|
||||
export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note) return { canAccess: false, isOwner: false };
|
||||
if (note.userId === userId) return { canAccess: true, isOwner: true };
|
||||
|
||||
const collab = db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||
.get();
|
||||
return { canAccess: !!collab, isOwner: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that a user can access a note. Throws 404 if no access.
|
||||
*/
|
||||
export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } {
|
||||
const { canAccess, isOwner } = canAccessNote(noteId, userId);
|
||||
if (!canAccess) throw error(404, 'Not found');
|
||||
return { isOwner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that a user is the owner of a note. Throws 404 if not owner.
|
||||
*/
|
||||
export function requireNoteOwnership(noteId: string, userId: number): void {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note || note.userId !== userId) throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from './db/index.js';
|
||||
import { attachments } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
@@ -80,25 +80,29 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
||||
export async function getAttachment(id: string, noteId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByNote(noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function updateAttachment(id: string, data: { featured: boolean }) {
|
||||
export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
|
||||
const [updated] = await db
|
||||
.update(attachments)
|
||||
.set({ featured: data.featured })
|
||||
.where(eq(attachments.id, id))
|
||||
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
const attachment = await getAttachment(id);
|
||||
export async function deleteAttachment(id: string, noteId: string) {
|
||||
const attachment = await getAttachment(id, noteId);
|
||||
if (attachment) {
|
||||
try {
|
||||
await unlink(attachment.path);
|
||||
@@ -112,6 +116,6 @@ export async function deleteAttachment(id: string) {
|
||||
// Thumbnail may already be deleted
|
||||
}
|
||||
}
|
||||
await db.delete(attachments).where(eq(attachments.id, id));
|
||||
await db.delete(attachments).where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +194,10 @@ export function revokeAllSessions(userId: number, exceptToken?: string): void {
|
||||
export async function createUser(
|
||||
email: string,
|
||||
displayName: string,
|
||||
password: string,
|
||||
password: string | null,
|
||||
role: 'admin' | 'user' = 'user'
|
||||
): Promise<User> {
|
||||
const hash = await argon2.hash(password);
|
||||
const hash = password ? await argon2.hash(password) : null;
|
||||
const result = db
|
||||
.insert(users)
|
||||
.values({
|
||||
@@ -205,7 +205,7 @@ export async function createUser(
|
||||
displayName,
|
||||
role,
|
||||
passwordHash: hash,
|
||||
authProvider: 'password',
|
||||
authProvider: password ? 'password' : 'none',
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { db } from './db/index.js';
|
||||
import { noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
|
||||
/**
|
||||
* Batch fetch collaborators for multiple notes.
|
||||
* Returns a Map of noteId → Collaborator[].
|
||||
*/
|
||||
export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Collaborator[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
noteId: noteCollaborators.noteId,
|
||||
userId: noteCollaborators.userId,
|
||||
displayName: users.displayName,
|
||||
email: users.email,
|
||||
addedAt: noteCollaborators.addedAt
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(users, eq(noteCollaborators.userId, users.id))
|
||||
.where(inArray(noteCollaborators.noteId, noteIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, Collaborator[]>();
|
||||
for (const row of rows) {
|
||||
const collab: Collaborator = {
|
||||
userId: row.userId,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
addedAt: row.addedAt
|
||||
};
|
||||
const existing = map.get(row.noteId);
|
||||
if (existing) {
|
||||
existing.push(collab);
|
||||
} else {
|
||||
map.set(row.noteId, [collab]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a collaborator to a note.
|
||||
*/
|
||||
export function addCollaborator(noteId: string, userId: number, addedBy: number): void {
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId, userId, addedBy, addedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a collaborator from a note. Also cleans up their per-user state.
|
||||
*/
|
||||
export function removeCollaborator(noteId: string, userId: number): void {
|
||||
db.delete(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||
.run();
|
||||
db.delete(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, noteId), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collaborator user IDs for a note.
|
||||
*/
|
||||
export function getCollaboratorIds(noteId: string): number[] {
|
||||
return db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(eq(noteCollaborators.noteId, noteId))
|
||||
.all()
|
||||
.map((r) => r.userId);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
function seedTwoUsers(db: BetterSQLite3Database<typeof schema>) {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(db: BetterSQLite3Database<typeof schema>, id: string, userId: number) {
|
||||
const now = new Date();
|
||||
db.insert(notes).values({ id, userId, title: 'Test', content: '', createdAt: now, updatedAt: now }).run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedTwoUsers(db);
|
||||
});
|
||||
|
||||
describe('noteCollaborators table', () => {
|
||||
it('should add a collaborator to a note', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].userId).toBe(2);
|
||||
expect(result[0].addedBy).toBe(1);
|
||||
});
|
||||
|
||||
it('should enforce unique constraint on (noteId, userId)', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
expect(() =>
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should allow multiple collaborators per note', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
// Add a third user
|
||||
db.insert(users)
|
||||
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(noteCollaborators).values([
|
||||
{ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() },
|
||||
{ noteId: 'n1', userId: 3, addedBy: 1, addedAt: new Date() }
|
||||
]).run();
|
||||
|
||||
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove a collaborator', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.delete(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, 'n1'), eq(noteCollaborators.userId, 2)))
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteUserState table', () => {
|
||||
it('should store per-user pin/archive/sortOrder state', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
|
||||
.get();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.pinned).toBe(true);
|
||||
expect(result!.archived).toBe(false);
|
||||
expect(result!.sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('should enforce unique constraint on (noteId, userId)', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2, pinned: false, archived: false, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
expect(() =>
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 0 })
|
||||
.run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should default to false/false/0 for new state', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2 })
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
|
||||
.get();
|
||||
|
||||
expect(result!.pinned).toBe(false);
|
||||
expect(result!.archived).toBe(false);
|
||||
expect(result!.sortOrder).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow independent state per user per note', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(users)
|
||||
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(noteUserState).values([
|
||||
{ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 1 },
|
||||
{ noteId: 'n1', userId: 3, pinned: false, archived: true, sortOrder: 2 }
|
||||
]).run();
|
||||
|
||||
const user2State = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2))).get();
|
||||
const user3State = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 3))).get();
|
||||
|
||||
expect(user2State!.pinned).toBe(true);
|
||||
expect(user2State!.archived).toBe(false);
|
||||
expect(user3State!.pinned).toBe(false);
|
||||
expect(user3State!.archived).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,21 @@ sqlite.exec(`
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
added_by INTEGER NOT NULL REFERENCES users(id),
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_user_state (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
@@ -167,6 +182,20 @@ sqlite.exec(`
|
||||
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);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Replace old tags unique index with compound index including user_id
|
||||
@@ -191,6 +220,29 @@ try {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: make password_hash nullable (for OAuth-only users)
|
||||
const pwCol = (
|
||||
sqlite.prepare("PRAGMA table_info('users')").all() as { name: string; notnull: number }[]
|
||||
).find((col) => col.name === 'password_hash');
|
||||
if (pwCol && pwCol.notnull) {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE __new_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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
|
||||
);
|
||||
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
|
||||
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
|
||||
DROP TABLE users;
|
||||
ALTER TABLE __new_users RENAME TO users;
|
||||
`);
|
||||
}
|
||||
|
||||
// Re-enable foreign keys
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type * as schema from './schema.js';
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
const testDb = createTestDb({ seedUser: true });
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Test Note',
|
||||
content: 'Hello world',
|
||||
createdAt: now,
|
||||
@@ -45,6 +46,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Colored Note',
|
||||
content: '',
|
||||
color: 'coral',
|
||||
@@ -62,16 +64,17 @@ describe('Notes CRUD', () => {
|
||||
it('should read all active notes (not archived, not trashed)', async () => {
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
|
||||
{ id: 'n1', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now },
|
||||
{
|
||||
id: 'n2',
|
||||
userId: 1,
|
||||
title: 'Archived',
|
||||
content: '',
|
||||
archived: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
||||
{ id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const active = await db
|
||||
@@ -89,6 +92,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Original',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -113,6 +117,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'To Trash',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -132,6 +137,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'To Delete',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -149,6 +155,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'To Archive',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -170,6 +177,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Pin Me',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -193,6 +201,7 @@ describe('Notes with Tags', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
userId: 1,
|
||||
title: 'Tagged Note',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -200,8 +209,8 @@ describe('Notes with Tags', () => {
|
||||
});
|
||||
|
||||
// Create tags
|
||||
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
|
||||
const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
|
||||
const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning();
|
||||
|
||||
// Associate
|
||||
await db.insert(noteTags).values([
|
||||
@@ -226,13 +235,14 @@ describe('Notes with Tags', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
userId: 1,
|
||||
title: '',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
|
||||
const [tag] = await db.insert(tags).values({ userId: 1, name: 'temp' }).returning();
|
||||
await db.insert(noteTags).values({ noteId, tagId: tag.id });
|
||||
|
||||
// Delete note_tags first (FK cascade is disabled in test DB), then note
|
||||
@@ -258,6 +268,7 @@ describe('Note ordering', () => {
|
||||
await db.insert(notes).values([
|
||||
{
|
||||
id: 'unpinned',
|
||||
userId: 1,
|
||||
title: 'Unpinned',
|
||||
content: '',
|
||||
pinned: false,
|
||||
@@ -266,6 +277,7 @@ describe('Note ordering', () => {
|
||||
},
|
||||
{
|
||||
id: 'pinned',
|
||||
userId: 1,
|
||||
title: 'Pinned',
|
||||
content: '',
|
||||
pinned: true,
|
||||
|
||||
@@ -98,6 +98,18 @@ export const attachments = sqliteTable('attachments', {
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable('api_keys', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
name: text('name').notNull(),
|
||||
keyHash: text('key_hash').notNull(),
|
||||
keyPrefix: text('key_prefix').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
|
||||
});
|
||||
|
||||
export const syncLog = sqliteTable(
|
||||
'sync_log',
|
||||
{
|
||||
@@ -116,6 +128,42 @@ export const syncLog = sqliteTable(
|
||||
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
|
||||
);
|
||||
|
||||
export const noteCollaborators = sqliteTable(
|
||||
'note_collaborators',
|
||||
{
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
addedBy: integer('added_by')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
addedAt: integer('added_at', { mode: 'timestamp' }).notNull()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('note_collaborators_unique').on(table.noteId, table.userId),
|
||||
index('note_collaborators_user_id_idx').on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const noteUserState = sqliteTable(
|
||||
'note_user_state',
|
||||
{
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
|
||||
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('note_user_state_unique').on(table.noteId, table.userId)]
|
||||
);
|
||||
|
||||
export const loginAttempts = sqliteTable(
|
||||
'login_attempts',
|
||||
{
|
||||
|
||||
@@ -8,18 +8,18 @@ import type * as schema from './schema.js';
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const testDb = createTestDb();
|
||||
const testDb = createTestDb({ seedUser: true });
|
||||
db = testDb.db;
|
||||
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
|
||||
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
|
||||
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
|
||||
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
|
||||
{ id: 'n1', userId: 1, title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
|
||||
{ id: 'n2', userId: 1, title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
|
||||
{ id: 'n3', userId: 1, title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
|
||||
{ id: 'n4', userId: 1, title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
|
||||
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as schema from './schema.js';
|
||||
* Create an in-memory SQLite database for testing.
|
||||
* Each test gets a fresh database.
|
||||
*/
|
||||
export function createTestDb() {
|
||||
export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = OFF');
|
||||
@@ -68,6 +68,24 @@ export function createTestDb() {
|
||||
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
|
||||
CREATE TABLE note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
added_by INTEGER NOT NULL REFERENCES users(id),
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||
CREATE INDEX note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||
|
||||
CREATE TABLE note_user_state (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX note_user_state_unique ON note_user_state(note_id, user_id);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
@@ -97,8 +115,29 @@ export function createTestDb() {
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
if (options?.seedUser) {
|
||||
db.insert(schema.users).values({
|
||||
email: 'test@test.com',
|
||||
displayName: 'Test User',
|
||||
role: 'admin',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
}).run();
|
||||
}
|
||||
|
||||
return { db, sqlite };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
getNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
searchNotes,
|
||||
listAllTags,
|
||||
reorderNotes
|
||||
} from '../notes-service.js';
|
||||
import { saveAttachment } from '../attachments.js';
|
||||
|
||||
const NOTE_COLORS = [
|
||||
'default',
|
||||
'coral',
|
||||
'peach',
|
||||
'sand',
|
||||
'mint',
|
||||
'sage',
|
||||
'fog',
|
||||
'storm',
|
||||
'dusk',
|
||||
'blossom',
|
||||
'clay',
|
||||
'chalk'
|
||||
] as const;
|
||||
|
||||
export function createMcpServer(userId: number): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'crumbs',
|
||||
version: '0.7.2'
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'list_notes',
|
||||
'List notes with optional filter (all, archived, trashed) and tag filter',
|
||||
{
|
||||
filter: z
|
||||
.enum(['all', 'archived', 'trashed'])
|
||||
.optional()
|
||||
.describe('Filter notes by status'),
|
||||
tag: z.string().optional().describe('Filter by tag name')
|
||||
},
|
||||
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
|
||||
let result = listNotes(userId, filter as NoteFilter);
|
||||
|
||||
if (tag) {
|
||||
result = result.filter((n) =>
|
||||
(n.tags ?? []).some((t: string) => t.toLowerCase() === tag.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_note',
|
||||
'Get a single note by ID with its tags and attachments',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const note = getNote(userId, id);
|
||||
if (!note) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'create_note',
|
||||
'Create a new note',
|
||||
{
|
||||
title: z.string().optional().describe('Note title'),
|
||||
content: z.string().optional().describe('Note content (supports markdown)'),
|
||||
color: z.enum(NOTE_COLORS).optional().describe('Note color'),
|
||||
checklistMode: z.boolean().optional().describe('Enable checklist mode')
|
||||
},
|
||||
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
|
||||
const note = createNote(userId, { title, content, color, checklistMode });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_note',
|
||||
'Update an existing note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
title: z.string().optional().describe('New title'),
|
||||
content: z.string().optional().describe('New content'),
|
||||
color: z.enum(NOTE_COLORS).optional().describe('New color'),
|
||||
pinned: z.boolean().optional().describe('Pin/unpin'),
|
||||
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
|
||||
},
|
||||
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
|
||||
const result = updateNote(userId, id, updates);
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'trash_note',
|
||||
'Move a note to trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" trashed` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'restore_note',
|
||||
'Restore a note from trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" restored` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'archive_note',
|
||||
'Archive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" archived` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'unarchive_note',
|
||||
'Unarchive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" unarchived` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'delete_note',
|
||||
'Permanently delete a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const existing = getNote(userId, id);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
deleteNote(userId, id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${existing.title}" permanently deleted`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'search_notes',
|
||||
'Search notes by title, content, or tag name',
|
||||
{ query: z.string().describe('Search query') },
|
||||
async ({ query }: { query: string }) => {
|
||||
const results = searchNotes(userId, query);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool('list_tags', 'List all tags', {}, async () => {
|
||||
const allTags = listAllTags(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
|
||||
};
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'pin_note',
|
||||
'Pin or unpin a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||
},
|
||||
async ({ id, pinned }: { id: string; pinned: boolean }) => {
|
||||
const result = updateNote(userId, id, { pinned });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'reorder_notes',
|
||||
'Reorder notes by setting sort orders',
|
||||
{
|
||||
orders: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe('Note ID'),
|
||||
sortOrder: z.number().describe('New sort order')
|
||||
})
|
||||
)
|
||||
.describe('Array of note IDs with their new sort orders')
|
||||
},
|
||||
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
|
||||
reorderNotes(userId, orders);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'upload_image',
|
||||
'Attach an image to a note by fetching it from a URL',
|
||||
{
|
||||
noteId: z.string().describe('Note ID to attach the image to'),
|
||||
imageUrl: z.string().url().describe('URL of the image to fetch')
|
||||
},
|
||||
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
|
||||
const existing = getNote(userId, noteId);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to fetch image: ${response.status} ${response.statusText}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
const file = new File([buffer], filename, { type: contentType });
|
||||
const attachment = await saveAttachment(noteId, file, userId);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to upload image: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { db as defaultDb } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
const db = defaultDb;
|
||||
|
||||
interface NoteRow {
|
||||
id: string;
|
||||
userId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
color: string;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
trashed: boolean;
|
||||
trashedAt: Date | null;
|
||||
checklistMode: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
version: number;
|
||||
}
|
||||
|
||||
function hydrateNotes(noteRows: NoteRow[], userId: number) {
|
||||
const noteIds = noteRows.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const collabMap = fetchCollaboratorsForNotes(noteIds);
|
||||
|
||||
return noteRows.map((note) => {
|
||||
const collaborators = collabMap.get(note.id) ?? [];
|
||||
const isOwner = note.userId === userId;
|
||||
const isShared = collaborators.length > 0;
|
||||
return {
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? [],
|
||||
collaborators,
|
||||
isOwner,
|
||||
isShared
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared notes for a user with per-user state overlay.
|
||||
*/
|
||||
function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
const rows = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
userId: notes.userId,
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
color: notes.color,
|
||||
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||
trashed: notes.trashed,
|
||||
trashedAt: notes.trashedAt,
|
||||
checklistMode: notes.checklistMode,
|
||||
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||
createdAt: notes.createdAt,
|
||||
updatedAt: notes.updatedAt,
|
||||
version: notes.version
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||
.leftJoin(
|
||||
noteUserState,
|
||||
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(noteCollaborators.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
filter === 'archived'
|
||||
? sql`COALESCE(${noteUserState.archived}, 0) = 1`
|
||||
: sql`COALESCE(${noteUserState.archived}, 0) = 0`
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
return rows as NoteRow[];
|
||||
}
|
||||
|
||||
export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
// Owned notes
|
||||
let conditions;
|
||||
switch (filter) {
|
||||
case 'archived':
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
|
||||
break;
|
||||
case 'trashed':
|
||||
// Collaborators never see trashed notes — only owner's trashed notes
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
|
||||
break;
|
||||
default:
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
|
||||
}
|
||||
|
||||
const ownedNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(conditions)
|
||||
.all() as NoteRow[];
|
||||
|
||||
// Shared notes (not for trashed filter — collaborators don't see trash)
|
||||
let sharedNotes: NoteRow[] = [];
|
||||
if (filter !== 'trashed') {
|
||||
sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all');
|
||||
}
|
||||
|
||||
const combined = [...ownedNotes, ...sharedNotes];
|
||||
combined.sort((a, b) => {
|
||||
// Pinned first, then by updatedAt descending
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
});
|
||||
|
||||
return hydrateNotes(combined, userId);
|
||||
}
|
||||
|
||||
export function getNote(userId: number, id: string) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (!note) return null;
|
||||
|
||||
let effectiveNote = note as NoteRow;
|
||||
|
||||
// For collaborators, overlay per-user state
|
||||
if (!isOwner) {
|
||||
const userState = db
|
||||
.select()
|
||||
.from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
effectiveNote = {
|
||||
...note,
|
||||
pinned: userState?.pinned ?? false,
|
||||
archived: userState?.archived ?? false,
|
||||
sortOrder: userState?.sortOrder ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const result = hydrateNotes([effectiveNote], userId);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
id?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: string;
|
||||
pinned?: boolean;
|
||||
checklistMode?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export function createNote(userId: number, input: CreateNoteInput) {
|
||||
const now = new Date();
|
||||
const id = input.id || uuidv4();
|
||||
|
||||
const newNote = {
|
||||
id,
|
||||
userId,
|
||||
title: input.title || '',
|
||||
content: input.content || '',
|
||||
color: input.color || 'default',
|
||||
pinned: input.pinned || false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: input.checklistMode || false,
|
||||
sortOrder: input.sortOrder || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1
|
||||
};
|
||||
|
||||
const content = `${newNote.title} ${newNote.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.insert(notes).values(newNote).run();
|
||||
});
|
||||
syncNoteTags(id, extractedTags, userId);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
|
||||
export interface UpdateNoteInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: string;
|
||||
pinned?: boolean;
|
||||
archived?: boolean;
|
||||
trashed?: boolean;
|
||||
checklistMode?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/** Per-user fields that go to noteUserState for collaborators */
|
||||
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
|
||||
|
||||
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (!existing) return null;
|
||||
|
||||
// Collaborators cannot trash notes
|
||||
if (!isOwner && input.trashed !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Shared fields: update the notes table (all participants can edit content)
|
||||
const sharedUpdates: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
version: existing.version + 1
|
||||
};
|
||||
let hasSharedUpdates = false;
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) { sharedUpdates.content = input.content; hasSharedUpdates = true; }
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
|
||||
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table directly
|
||||
if (input.pinned !== undefined) { sharedUpdates.pinned = input.pinned; hasSharedUpdates = true; }
|
||||
if (input.archived !== undefined) { sharedUpdates.archived = input.archived; hasSharedUpdates = true; }
|
||||
if (input.trashed !== undefined) {
|
||||
sharedUpdates.trashed = input.trashed;
|
||||
sharedUpdates.trashedAt = input.trashed ? now : null;
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
|
||||
}
|
||||
|
||||
if (hasSharedUpdates || isOwner) {
|
||||
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
|
||||
}
|
||||
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUserFields = PER_USER_FIELDS.some((f) => input[f] !== undefined);
|
||||
if (hasPerUserFields) {
|
||||
const userState = db
|
||||
.select()
|
||||
.from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (userState) {
|
||||
const stateUpdates: Record<string, unknown> = {};
|
||||
if (input.pinned !== undefined) stateUpdates.pinned = input.pinned;
|
||||
if (input.archived !== undefined) stateUpdates.archived = input.archived;
|
||||
if (input.sortOrder !== undefined) stateUpdates.sortOrder = input.sortOrder;
|
||||
db.update(noteUserState)
|
||||
.set(stateUpdates)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(noteUserState)
|
||||
.values({
|
||||
noteId: id,
|
||||
userId,
|
||||
pinned: input.pinned ?? false,
|
||||
archived: input.archived ?? false,
|
||||
sortOrder: input.sortOrder ?? 0
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input.title !== undefined || input.content !== undefined) {
|
||||
const updated = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (updated) {
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(id, extractedTags, existing.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return getNote(userId, id);
|
||||
}
|
||||
|
||||
export function deleteNote(userId: number, id: string): boolean {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
if (!existing) return false;
|
||||
|
||||
db.delete(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function searchNotes(userId: number, query: string) {
|
||||
if (!query) return [];
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
// Owned notes matching query
|
||||
const ownedResults = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
// Shared notes matching query
|
||||
const sharedResults = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
userId: notes.userId,
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
color: notes.color,
|
||||
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||
trashed: notes.trashed,
|
||||
trashedAt: notes.trashedAt,
|
||||
checklistMode: notes.checklistMode,
|
||||
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||
createdAt: notes.createdAt,
|
||||
updatedAt: notes.updatedAt,
|
||||
version: notes.version
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||
.leftJoin(
|
||||
noteUserState,
|
||||
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(noteCollaborators.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
// Tag search for owned notes
|
||||
const tagResults = db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
|
||||
.all();
|
||||
|
||||
const resultIds = new Set([...ownedResults.map((n) => n.id), ...sharedResults.map((n) => n.id)]);
|
||||
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
|
||||
(id) => !resultIds.has(id)
|
||||
);
|
||||
|
||||
let extraNotes: typeof ownedResults = [];
|
||||
if (extraNoteIds.length > 0) {
|
||||
extraNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
|
||||
.all();
|
||||
}
|
||||
|
||||
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
|
||||
return hydrateNotes(combined, userId);
|
||||
}
|
||||
|
||||
export function listAllTags(userId: number) {
|
||||
return db.select().from(tags).where(eq(tags.userId, userId)).all();
|
||||
}
|
||||
|
||||
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||
const now = new Date();
|
||||
for (const { id, sortOrder } of orders) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
if (!canAccess) continue;
|
||||
|
||||
if (isOwner) {
|
||||
db.update(notes)
|
||||
.set({ sortOrder, updatedAt: now })
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
// Collaborator: upsert into noteUserState
|
||||
const existing = db
|
||||
.select()
|
||||
.from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
db.update(noteUserState)
|
||||
.set({ sortOrder })
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: id, userId, pinned: false, archived: false, sortOrder })
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from './db/index.js';
|
||||
import { noteTags, tags } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Batch fetch tags for multiple notes in a single query.
|
||||
@@ -32,13 +32,17 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
||||
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
|
||||
* Should be called within a transaction for atomicity.
|
||||
*/
|
||||
export function syncNoteTags(noteId: string, tagNames: string[]) {
|
||||
export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
|
||||
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
|
||||
|
||||
for (const name of tagNames) {
|
||||
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get();
|
||||
let tagRow = db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.name, name), eq(tags.userId, userId)))
|
||||
.get();
|
||||
if (!tagRow) {
|
||||
const result = db.insert(tags).values({ name }).returning().all();
|
||||
const result = db.insert(tags).values({ name, userId }).returning().all();
|
||||
tagRow = result[0];
|
||||
}
|
||||
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
|
||||
|
||||
+16
-3
@@ -51,9 +51,7 @@ export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortM
|
||||
|
||||
export const allTags = derived(notes, ($notes) => {
|
||||
const tagSet = new Set<string>();
|
||||
$notes
|
||||
.filter((n) => !n.archived && !n.trashed)
|
||||
.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
@@ -235,6 +233,21 @@ export async function togglePin(id: string, currentPinned: boolean): Promise<Not
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
export async function leaveNote(id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
showToast('Failed to leave note', 'error');
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
||||
// Optimistic update
|
||||
notes.update((list) =>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from '../server/db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from '../server/db/schema.js';
|
||||
import { getChangesSince, processSyncPush } from './server.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users).values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
]).run();
|
||||
}
|
||||
|
||||
function seedSharedNote(id: string, overrides: Partial<typeof notes.$inferInsert> = {}) {
|
||||
const now = new Date();
|
||||
db.insert(notes).values({
|
||||
id,
|
||||
userId: OWNER_ID,
|
||||
title: 'Shared Note',
|
||||
content: 'Original content',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
}).run();
|
||||
|
||||
db.insert(noteCollaborators).values({
|
||||
noteId: id,
|
||||
userId: COLLAB_ID,
|
||||
addedBy: OWNER_ID,
|
||||
addedAt: now
|
||||
}).run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('getChangesSince — shared notes', () => {
|
||||
it('should return shared notes updated after the given timestamp', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].id).toBe('n1');
|
||||
expect(changes[0].title).toBe('Shared Note');
|
||||
expect(changes[0].content).toBe('Original content');
|
||||
});
|
||||
|
||||
it('should not return shared notes updated before the given timestamp', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t2.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return per-user state overlay for collaborators', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
// Owner has pinned=true, archived=false on the note
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, archived: false });
|
||||
|
||||
// Collaborator has pinned=false, archived=true in noteUserState
|
||||
db.insert(noteUserState).values({
|
||||
noteId: 'n1',
|
||||
userId: COLLAB_ID,
|
||||
pinned: false,
|
||||
archived: true,
|
||||
sortOrder: 5
|
||||
}).run();
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Should reflect the collaborator's per-user state, not the owner's
|
||||
expect(changes[0].pinned).toBeFalsy();
|
||||
expect(changes[0].archived).toBeTruthy();
|
||||
expect(changes[0].sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('should default to false/0 when collaborator has no noteUserState', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
// Owner has pinned=true, sortOrder=10
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Without noteUserState, collaborator should get defaults (false/0), not owner's values
|
||||
expect(changes[0].pinned).toBeFalsy();
|
||||
expect(changes[0].sortOrder).toBe(0);
|
||||
});
|
||||
|
||||
it('should not return trashed notes for collaborators', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return owner edits to a shared note for the collaborator', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
// Simulate owner editing the note
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
db.update(notes)
|
||||
.set({ content: 'Updated by owner', updatedAt: t2, version: 2 })
|
||||
.where(eq(notes.id, 'n1'))
|
||||
.run();
|
||||
|
||||
// Collaborator syncs since t1
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].content).toBe('Updated by owner');
|
||||
expect(changes[0].version).toBe(2);
|
||||
});
|
||||
|
||||
it('should return both owned and shared notes for a user who is both owner and collaborator on different notes', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
// Note owned by COLLAB_ID
|
||||
db.insert(notes).values({
|
||||
id: 'owned-by-collab',
|
||||
userId: COLLAB_ID,
|
||||
title: 'My Own Note',
|
||||
content: '',
|
||||
createdAt: t1,
|
||||
updatedAt: t2
|
||||
}).run();
|
||||
|
||||
// Note shared with COLLAB_ID (owned by OWNER_ID)
|
||||
seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(2);
|
||||
const ids = changes.map((c) => c.id).sort();
|
||||
expect(ids).toEqual(['owned-by-collab', 'shared-with-collab']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processSyncPush — shared notes', () => {
|
||||
it('should allow collaborator to update shared content fields', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated by collab', content: 'New content' }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Updated by collab');
|
||||
expect(note.content).toBe('New content');
|
||||
expect(note.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should route collaborator per-user fields to noteUserState', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { pinned: true, sortOrder: 3 }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
// Per-user fields should be in noteUserState, not in notes
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.pinned).toBe(false); // Owner's pinned state unchanged
|
||||
|
||||
const userState = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
|
||||
.get();
|
||||
expect(userState).toBeDefined();
|
||||
expect(userState!.pinned).toBe(true);
|
||||
expect(userState!.sortOrder).toBe(3);
|
||||
});
|
||||
|
||||
it('should not allow collaborator to trash a note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { trashed: true }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.trashed).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject changes with older timestamp than server note', async () => {
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: new Date('2024-01-01T00:00:00Z') });
|
||||
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt
|
||||
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1,
|
||||
data: { title: 'Stale edit' }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Shared Note'); // Unchanged
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
// Create note without sharing with user 3
|
||||
db.insert(notes).values({
|
||||
id: 'n1',
|
||||
userId: OWNER_ID,
|
||||
title: 'Private',
|
||||
content: '',
|
||||
createdAt: t1,
|
||||
updatedAt: t1
|
||||
}).run();
|
||||
|
||||
// Add a third user
|
||||
db.insert(users).values({
|
||||
email: 'stranger@test.com',
|
||||
displayName: 'Stranger',
|
||||
role: 'user',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
}).run();
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Hacked' }
|
||||
}], 3, db); // User 3 is not owner or collaborator
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Private'); // Unchanged
|
||||
});
|
||||
|
||||
it('should log sync operations', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated' }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all();
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].userId).toBe(COLLAB_ID);
|
||||
expect(logs[0].operation).toBe('update');
|
||||
});
|
||||
});
|
||||
+123
-22
@@ -1,14 +1,18 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and } from 'drizzle-orm';
|
||||
import { db as defaultDb } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyDb = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<any>;
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise<void> {
|
||||
export async function processSyncPush(changes: SyncQueueItem[], userId: number, injectedDb?: AnyDb): Promise<void> {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
const noteIdsToSyncTags: string[] = [];
|
||||
|
||||
db.transaction((tx) => {
|
||||
@@ -17,24 +21,78 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
|
||||
case 'create':
|
||||
case 'update': {
|
||||
if (!change.data) continue;
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
if (change.timestamp > existing.updatedAt.getTime()) {
|
||||
// Check access: owner can update all fields, collaborator can update shared fields
|
||||
const note = tx.select().from(notes).where(eq(notes.id, change.noteId)).get();
|
||||
|
||||
if (note) {
|
||||
const isOwner = note.userId === userId;
|
||||
const isCollaborator = !isOwner && tx
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, change.noteId), eq(noteCollaborators.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (!isOwner && !isCollaborator) continue;
|
||||
|
||||
if (change.timestamp > note.updatedAt.getTime()) {
|
||||
// Shared fields: title, content, color, checklistMode
|
||||
const sharedData: Record<string, unknown> = {
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: note.version + 1
|
||||
};
|
||||
if (change.data.title !== undefined) sharedData.title = change.data.title;
|
||||
if (change.data.content !== undefined) sharedData.content = change.data.content;
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table
|
||||
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
|
||||
if (change.data.trashed !== undefined) {
|
||||
sharedData.trashed = change.data.trashed;
|
||||
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
|
||||
}
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
|
||||
tx.update(notes)
|
||||
.set({
|
||||
...change.data,
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: existing.version + 1
|
||||
})
|
||||
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.run();
|
||||
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUser = change.data.pinned !== undefined ||
|
||||
change.data.archived !== undefined ||
|
||||
change.data.sortOrder !== undefined;
|
||||
if (hasPerUser) {
|
||||
const existing = tx.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) updates.archived = change.data.archived;
|
||||
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
|
||||
tx.update(noteUserState).set(updates)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(noteUserState).values({
|
||||
noteId: change.noteId,
|
||||
userId,
|
||||
pinned: change.data.pinned ?? false,
|
||||
archived: change.data.archived ?? false,
|
||||
sortOrder: change.data.sortOrder ?? 0
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (change.operation === 'create' && change.data) {
|
||||
// Only owner can create notes
|
||||
tx.insert(notes)
|
||||
.values({
|
||||
id: change.noteId,
|
||||
@@ -60,6 +118,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Only owner can delete
|
||||
tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run();
|
||||
break;
|
||||
}
|
||||
@@ -78,21 +137,63 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
|
||||
});
|
||||
|
||||
for (const noteId of noteIdsToSyncTags) {
|
||||
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
|
||||
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (note) {
|
||||
const content = `${note.title} ${note.content}`;
|
||||
syncNoteTags(noteId, extractTags(content));
|
||||
syncNoteTags(noteId, extractTags(content), note.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notes updated since a given timestamp for a specific user.
|
||||
* Includes both owned and shared notes.
|
||||
* For shared notes, per-user state (pinned, archived, sortOrder) is overlaid
|
||||
* from noteUserState so collaborators get their own view.
|
||||
*/
|
||||
export async function getChangesSince(sinceTimestamp: number, userId: number) {
|
||||
return db
|
||||
export async function getChangesSince(sinceTimestamp: number, userId: number, injectedDb?: AnyDb) {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
const since = new Date(sinceTimestamp);
|
||||
|
||||
// Owned notes
|
||||
const ownedNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp))))
|
||||
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, since)))
|
||||
.all();
|
||||
|
||||
// Shared notes (via collaborator relationship) with per-user state overlay
|
||||
const sharedNotes = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
userId: notes.userId,
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
color: notes.color,
|
||||
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||
trashed: notes.trashed,
|
||||
trashedAt: notes.trashedAt,
|
||||
checklistMode: notes.checklistMode,
|
||||
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||
createdAt: notes.createdAt,
|
||||
updatedAt: notes.updatedAt,
|
||||
version: notes.version
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||
.leftJoin(
|
||||
noteUserState,
|
||||
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(noteCollaborators.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
gt(notes.updatedAt, since)
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
return [...ownedNotes, ...sharedNotes];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@ export interface Note {
|
||||
version: number;
|
||||
tags?: string[];
|
||||
attachments?: Attachment[];
|
||||
collaborators?: Collaborator[];
|
||||
isOwner?: boolean;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
userId: number;
|
||||
displayName: string;
|
||||
email: string;
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
export interface NoteCreate {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} user={data.user} />
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
|
||||
|
||||
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
|
||||
@@ -1,118 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
|
||||
// Notes may not be loaded yet — wait for them
|
||||
const unsubscribe = notes.subscribe(($notes) => {
|
||||
if ($notes.length === 0) return;
|
||||
const note = $notes.find((n) => n.id === hash);
|
||||
if (note) editingNote = note;
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if $currentFilter === 'all'}
|
||||
<div class="mx-auto mb-6 max-w-xl">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentFilter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentFilter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
{:else if $currentFilter === 'trashed'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
|
||||
{/if}
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
No crumbs in trash
|
||||
{:else if $currentFilter === 'archived'}
|
||||
No archived crumbs
|
||||
{:else if $selectedTag}
|
||||
No crumbs with tag #{$selectedTag}
|
||||
{:else}
|
||||
No crumbs yet
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
{/if}
|
||||
<NotesView filter="all" />
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Archive - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="archived" />
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let pathname = $derived(page.url.pathname);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-6 text-2xl font-bold text-[var(--text)]">Settings</h1>
|
||||
|
||||
<div class="flex flex-col gap-6 md:flex-row">
|
||||
<!-- Sidebar nav -->
|
||||
<nav class="w-full shrink-0 md:w-48">
|
||||
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
<li>
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<User size={16} />
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/settings/mcp"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Cpu size={16} />
|
||||
MCP Server
|
||||
</a>
|
||||
</li>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<li>
|
||||
<a
|
||||
href="/settings/users"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/users' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="min-w-0 flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
|
||||
// Profile state
|
||||
// svelte-ignore state_referenced_locally
|
||||
let displayName = $state(data.user?.displayName || '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let email = $state(data.user?.email || '');
|
||||
let profileMsg = $state('');
|
||||
let profileError = $state(false);
|
||||
|
||||
// Password state
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmNewPassword = $state('');
|
||||
let passwordMsg = $state('');
|
||||
let passwordError = $state(false);
|
||||
|
||||
// Sessions state
|
||||
let activeSessions = $state<SessionInfo[]>([]);
|
||||
let sessionsLoaded = $state(false);
|
||||
|
||||
// Delete account state
|
||||
let deletePassword = $state('');
|
||||
let deleteMsg = $state('');
|
||||
let deleteError = $state(false);
|
||||
let showDeleteConfirm = $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();
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
deleteMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/account', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: deletePassword })
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
const d = await res.json();
|
||||
deleteMsg = d.message || 'Failed to delete account';
|
||||
deleteError = true;
|
||||
}
|
||||
} catch {
|
||||
deleteMsg = 'Connection error';
|
||||
deleteError = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadSessions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Profile</h2>
|
||||
|
||||
{#if profileMsg}
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {profileError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{profileMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="display-name" class="mb-1 block text-sm font-medium text-[var(--text)]">Display Name</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-[var(--text)]">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={saveProfile}
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Save profile
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Change Password -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Change Password</h2>
|
||||
|
||||
{#if passwordMsg}
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {passwordError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{passwordMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
placeholder="Current password"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="New password (min 8 characters)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmNewPassword}
|
||||
placeholder="Confirm new password"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<button
|
||||
onclick={changePassword}
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">Active Sessions</h2>
|
||||
{#if activeSessions.length > 1}
|
||||
<button
|
||||
onclick={revokeAllOtherSessions}
|
||||
class="text-xs text-[var(--destructive)] hover:underline"
|
||||
>
|
||||
Log out everywhere else
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !sessionsLoaded}
|
||||
<p class="text-sm text-[var(--text-muted)]">Loading sessions...</p>
|
||||
{:else if activeSessions.length === 0}
|
||||
<p class="text-sm text-[var(--text-muted)]">No active sessions</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each activeSessions as session (session.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--text)]">
|
||||
{formatAgent(session.userAgent)}
|
||||
{#if session.isCurrent}
|
||||
<span class="ml-2 rounded-sm bg-[var(--success-bg)] px-2 py-0.5 text-xs text-[var(--success-text)]">current</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{#if !session.isCurrent}
|
||||
<button
|
||||
onclick={() => revokeSession(session.id)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Log Out & Delete Account -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-4 py-2 text-sm font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="logout-btn"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = !showDeleteConfirm)}
|
||||
class="rounded-sm px-4 py-2 text-sm font-medium text-[var(--destructive)] hover:underline"
|
||||
>
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<div class="mt-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-4">
|
||||
<p class="mb-3 text-sm text-[var(--error-text)]">
|
||||
This will permanently delete your account and all your notes. This cannot be undone.
|
||||
</p>
|
||||
|
||||
{#if deleteMsg}
|
||||
<div class="mb-3 text-sm text-[var(--error-text)]">{deleteMsg}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={deletePassword}
|
||||
placeholder="Enter your password to confirm"
|
||||
class="flex-1 rounded-sm border border-[var(--error-border)] bg-[var(--bg-base)] px-3 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--destructive)]"
|
||||
/>
|
||||
<button
|
||||
onclick={deleteAccount}
|
||||
class="rounded-sm bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { Key, Copy, Trash2, Plus, Check } from 'lucide-svelte';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
// API Keys state
|
||||
let keys = $state<ApiKey[]>([]);
|
||||
let newKeyName = $state('');
|
||||
let createdKey = $state<string | null>(null);
|
||||
let keyLoading = $state(false);
|
||||
let keyErrorMsg = $state('');
|
||||
let copiedKey = $state(false);
|
||||
let copiedJson = $state(false);
|
||||
let copiedPrompt = $state(false);
|
||||
let deleteConfirmId = $state<string | null>(null);
|
||||
|
||||
let baseUrl = $derived(typeof window !== 'undefined' ? window.location.origin : 'https://your-crumbs-instance');
|
||||
let activeApiKey = $derived(createdKey ?? 'YOUR_API_KEY');
|
||||
|
||||
let mcpJsonConfig = $derived(`{
|
||||
"mcpServers": {
|
||||
"crumbs": {
|
||||
"type": "http",
|
||||
"url": "${baseUrl}/api/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${activeApiKey}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
let llmInstallPrompt = $derived(`Install the Crumbs MCP server with the following configuration:
|
||||
|
||||
- Type: HTTP (Streamable HTTP)
|
||||
- URL: ${baseUrl}/api/mcp
|
||||
- Authentication: Bearer token in the Authorization header
|
||||
- API Key: ${activeApiKey}
|
||||
|
||||
Add this to your MCP client settings (e.g. Claude Code's .claude/settings.json):
|
||||
|
||||
${mcpJsonConfig}`);
|
||||
|
||||
function formatDateShort(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
const res = await fetch('/api/settings/api-keys');
|
||||
if (res.ok) keys = await res.json();
|
||||
}
|
||||
|
||||
async function createKey(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
keyLoading = true;
|
||||
keyErrorMsg = '';
|
||||
createdKey = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newKeyName.trim() })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
createdKey = data.key;
|
||||
newKeyName = '';
|
||||
await loadKeys();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
keyErrorMsg = data.error || 'Failed to create key';
|
||||
}
|
||||
} catch {
|
||||
keyErrorMsg = 'Connection error';
|
||||
} finally {
|
||||
keyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKey(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
deleteConfirmId = null;
|
||||
await loadKeys();
|
||||
}
|
||||
} catch {
|
||||
keyErrorMsg = 'Failed to delete key';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, target: 'key' | 'json' | 'prompt') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (target === 'key') { copiedKey = true; setTimeout(() => (copiedKey = false), 2000); }
|
||||
if (target === 'json') { copiedJson = true; setTimeout(() => (copiedJson = false), 2000); }
|
||||
if (target === 'prompt') { copiedPrompt = true; setTimeout(() => (copiedPrompt = false), 2000); }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadKeys();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- API Keys -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Key size={16} class="text-[var(--primary)]" />
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-[var(--text-muted)]">
|
||||
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
|
||||
</p>
|
||||
|
||||
<!-- Create Key Form -->
|
||||
<form onsubmit={createKey} class="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newKeyName}
|
||||
placeholder="Key name (e.g. Claude Code)"
|
||||
class="flex-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="api-key-name-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={keyLoading || !newKeyName.trim()}
|
||||
class="flex items-center gap-1 rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="create-api-key-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if keyErrorMsg}
|
||||
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]">
|
||||
{keyErrorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Newly Created Key (show once) -->
|
||||
{#if createdKey}
|
||||
<div class="mb-4 rounded-sm border border-[var(--success-border,#a3b18a)] bg-[var(--success-bg,#f0f4e8)] p-4" data-testid="created-key-display">
|
||||
<p class="mb-2 text-xs font-semibold text-[var(--success-text,#3a5a40)]">
|
||||
Key created! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 overflow-x-auto rounded-sm bg-[var(--bg-base)] px-3 py-2 text-xs text-[var(--text)]" data-testid="created-key-value">
|
||||
{createdKey}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => copyToClipboard(createdKey!, 'key')}
|
||||
class="flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] px-3 py-2 text-xs transition-colors hover:border-[var(--primary)]"
|
||||
data-testid="copy-key-btn"
|
||||
>
|
||||
{#if copiedKey}
|
||||
<Check size={14} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Copy size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Key List -->
|
||||
{#if keys.length === 0}
|
||||
<p class="py-4 text-center text-xs text-[var(--text-muted)]">No API keys yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-2" data-testid="api-keys-list">
|
||||
{#each keys as key (key.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3" data-testid="api-key-item">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-[var(--text)]" data-testid="api-key-name">{key.name}</span>
|
||||
<code class="text-xs text-[var(--text-muted)]">{key.keyPrefix}...</code>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Created {formatDateShort(key.createdAt)}
|
||||
{#if key.lastUsedAt}
|
||||
· Last used {formatDateShort(key.lastUsedAt)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
{#if deleteConfirmId === key.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => deleteKey(key.id)}
|
||||
class="rounded-sm bg-[var(--destructive)] px-3 py-1 text-xs text-white transition-colors hover:opacity-80"
|
||||
data-testid="confirm-delete-btn"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (deleteConfirmId = null)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1 text-xs transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (deleteConfirmId = key.id)}
|
||||
class="rounded-sm p-2 text-[var(--text-muted)] transition-colors hover:text-[var(--destructive)]"
|
||||
data-testid="delete-api-key-btn"
|
||||
title="Revoke key"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- MCP Config Help -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Add this to your <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
|
||||
</p>
|
||||
<div class="relative">
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)]">{mcpJsonConfig}</pre>
|
||||
<button
|
||||
onclick={() => copyToClipboard(mcpJsonConfig, 'json')}
|
||||
class="absolute right-2 top-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--text)]"
|
||||
title="Copy JSON config"
|
||||
>
|
||||
{#if copiedJson}
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if createdKey}
|
||||
<p class="mt-1 text-xs text-[var(--success-text,#3a5a40)]">
|
||||
Config updated with your new API key.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- LLM Installation Prompt -->
|
||||
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
|
||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">LLM Installation Prompt</h3>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Copy this prompt and paste it into your LLM to set up the MCP connection:
|
||||
</p>
|
||||
<div class="relative">
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)] whitespace-pre-wrap">{llmInstallPrompt}</pre>
|
||||
<button
|
||||
onclick={() => copyToClipboard(llmInstallPrompt, 'prompt')}
|
||||
class="absolute right-2 top-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--text)]"
|
||||
title="Copy LLM prompt"
|
||||
>
|
||||
{#if copiedPrompt}
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if createdKey}
|
||||
<p class="mt-1 text-xs text-[var(--success-text,#3a5a40)]">
|
||||
Prompt updated with your new API key.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import type { User } from '$lib/types/index.js';
|
||||
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
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 oauthOnly = $state(false);
|
||||
let createMsg = $state('');
|
||||
let createError = $state(false);
|
||||
|
||||
async function createUser() {
|
||||
createMsg = '';
|
||||
if (!newEmail) {
|
||||
createMsg = 'Email is required';
|
||||
createError = true;
|
||||
return;
|
||||
}
|
||||
if (!oauthOnly && !newPassword) {
|
||||
createMsg = 'Password is required (or enable OAuth-only)';
|
||||
createError = true;
|
||||
return;
|
||||
}
|
||||
if (!oauthOnly && 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: oauthOnly ? undefined : newPassword,
|
||||
role: newRole
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
users = [...users, user];
|
||||
newEmail = '';
|
||||
newDisplayName = '';
|
||||
newPassword = '';
|
||||
newRole = 'user';
|
||||
oauthOnly = false;
|
||||
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 revokeSessions(user: User) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ revokeSessions: true })
|
||||
});
|
||||
if (res.ok) {
|
||||
createMsg = `All sessions revoked for ${user.displayName || user.email}`;
|
||||
createError = false;
|
||||
}
|
||||
} 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>
|
||||
|
||||
<!-- Create User -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Create User</h2>
|
||||
|
||||
{#if createMsg}
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {createError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{createMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="Email"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newDisplayName}
|
||||
placeholder="Display name (optional)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<label class="flex items-center gap-2 text-sm text-[var(--text)]">
|
||||
<input type="checkbox" bind:checked={oauthOnly} />
|
||||
OAuth-only (no password)
|
||||
</label>
|
||||
{#if !oauthOnly}
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
{/if}
|
||||
<select
|
||||
bind:value={newRole}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={createUser}
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Create user
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- User List -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-4">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => toggleRole(user)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||
>
|
||||
{user.role === 'admin' ? 'Make user' : 'Make admin'}
|
||||
</button>
|
||||
{#if user.id !== data.user?.id}
|
||||
<button
|
||||
onclick={() => revokeSessions(user)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
title="Force logout"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{$page.params.name} - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="all" tag={$page.params.name} />
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Trash - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="trashed" />
|
||||
@@ -42,6 +42,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const providers = data.providers ?? [];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -92,8 +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
|
||||
type="password"
|
||||
@@ -102,8 +101,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
|
||||
type="submit"
|
||||
|
||||
@@ -12,14 +12,14 @@ 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 (!email) {
|
||||
throw error(400, 'Email is required');
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
if (password && 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');
|
||||
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
|
||||
return json(user, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||
import { getUser, deleteUser, updateUserRole, resetPassword } from '$lib/server/auth.js';
|
||||
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const admin = requireAdmin(event);
|
||||
@@ -26,6 +26,10 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
await resetPassword(userId, body.newPassword);
|
||||
}
|
||||
|
||||
if (body.revokeSessions) {
|
||||
revokeAllSessions(userId);
|
||||
}
|
||||
|
||||
const updated = getUser(userId);
|
||||
return json(updated);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const user = getUser(userId);
|
||||
if (!user) throw error(404, 'User not found');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// OAuth users don't have passwords — allow deletion without password
|
||||
if (user.authProvider === 'password') {
|
||||
if (!body.password) throw error(400, 'Password is required');
|
||||
const verified = await verifyPassword(user.email, body.password);
|
||||
if (!verified) throw error(401, 'Invalid password');
|
||||
}
|
||||
|
||||
// Delete user and all associated data
|
||||
await deleteUser(userId);
|
||||
|
||||
// Clear session cookie
|
||||
const token = cookies.get('session');
|
||||
if (token) {
|
||||
await deleteSession(token);
|
||||
cookies.delete('session', { path: '/' });
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { createMcpServer } from '$lib/server/mcp/server.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
|
||||
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
|
||||
export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const sessionId = request.headers.get('mcp-session-id');
|
||||
const body = await request.json();
|
||||
|
||||
// Reuse existing session
|
||||
if (sessionId && transports.has(sessionId)) {
|
||||
const transport = transports.get(sessionId)!;
|
||||
return transport.handleRequest(request, { parsedBody: body });
|
||||
}
|
||||
|
||||
// New session (initialize request)
|
||||
if (!sessionId && isInitializeRequest(body)) {
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
enableJsonResponse: true,
|
||||
onsessioninitialized: (id) => {
|
||||
transports.set(id, transport);
|
||||
}
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
transports.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const server = createMcpServer(userId);
|
||||
await server.connect(transport);
|
||||
|
||||
return transport.handleRequest(request, { parsedBody: body });
|
||||
}
|
||||
|
||||
// Invalid request
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Invalid session' },
|
||||
id: null
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ request }) => {
|
||||
const sessionId = request.headers.get('mcp-session-id');
|
||||
|
||||
if (sessionId && transports.has(sessionId)) {
|
||||
return transports.get(sessionId)!.handleRequest(request);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Invalid session' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request }) => {
|
||||
const sessionId = request.headers.get('mcp-session-id');
|
||||
|
||||
if (sessionId && transports.has(sessionId)) {
|
||||
return transports.get(sessionId)!.handleRequest(request);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Invalid session' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
@@ -1,79 +1,19 @@
|
||||
import { json } 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, and, desc } from 'drizzle-orm';
|
||||
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';
|
||||
import { listNotes, createNote } from '$lib/server/notes-service.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
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.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
|
||||
break;
|
||||
case 'trashed':
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
|
||||
break;
|
||||
default:
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
|
||||
}
|
||||
|
||||
const result = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(conditions)
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||
.all();
|
||||
|
||||
const noteIds = result.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const notesWithTags = result.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(notesWithTags);
|
||||
const filter = (url.searchParams.get('filter') || 'all') as NoteFilter;
|
||||
const result = listNotes(userId, filter);
|
||||
return json(result);
|
||||
};
|
||||
|
||||
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',
|
||||
pinned: body.pinned || false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: body.checklistMode || false,
|
||||
sortOrder: body.sortOrder || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1
|
||||
};
|
||||
|
||||
const content = `${newNote.title} ${newNote.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.insert(notes).values(newNote).run();
|
||||
});
|
||||
syncNoteTags(id, extractedTags);
|
||||
|
||||
return json({ ...newNote, tags: extractedTags }, { status: 201 });
|
||||
const newNote = createNote(userId, body);
|
||||
return json(newNote, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,98 +1,31 @@
|
||||
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, 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';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
|
||||
|
||||
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();
|
||||
const note = getNote(userId, params.id);
|
||||
if (!note) throw error(404, 'Note not found');
|
||||
|
||||
if (!note) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([note.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([note.id]);
|
||||
|
||||
return json({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
});
|
||||
return json(note);
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body = await request.json();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
const updated = updateNote(userId, params.id, body);
|
||||
if (!updated) throw error(404, 'Note not found');
|
||||
|
||||
if (!existing) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updates: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
version: existing.version + 1
|
||||
};
|
||||
|
||||
if (body.title !== undefined) updates.title = body.title;
|
||||
if (body.content !== undefined) updates.content = body.content;
|
||||
if (body.color !== undefined) updates.color = body.color;
|
||||
if (body.pinned !== undefined) updates.pinned = body.pinned;
|
||||
if (body.archived !== undefined) updates.archived = body.archived;
|
||||
if (body.trashed !== undefined) {
|
||||
updates.trashed = body.trashed;
|
||||
updates.trashedAt = body.trashed ? now : null;
|
||||
}
|
||||
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
|
||||
|
||||
db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
|
||||
|
||||
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}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(params.id, extractedTags);
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([params.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([params.id]);
|
||||
|
||||
return json({
|
||||
...updated,
|
||||
tags: tagMap.get(params.id) ?? [],
|
||||
attachments: attachmentMap.get(params.id) ?? []
|
||||
});
|
||||
return json(updated);
|
||||
};
|
||||
|
||||
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();
|
||||
requireNoteOwnership(params.id, userId);
|
||||
|
||||
if (!existing) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
const deleted = deleteNote(userId, params.id);
|
||||
if (!deleted) throw error(404, 'Note not found');
|
||||
|
||||
db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -2,30 +2,16 @@ 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;
|
||||
}
|
||||
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
|
||||
if (attachmentId) {
|
||||
const attachment = await getAttachment(attachmentId);
|
||||
const attachment = await getAttachment(attachmentId, params.id);
|
||||
if (!attachment) throw error(404, 'Attachment not found');
|
||||
|
||||
const wantThumb = url.searchParams.get('thumb') === '1';
|
||||
@@ -48,7 +34,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
@@ -73,7 +59,7 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
@@ -81,7 +67,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
|
||||
const body = await request.json();
|
||||
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
|
||||
|
||||
const updated = await updateAttachment(attachmentId, { featured: body.featured });
|
||||
const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured });
|
||||
if (!updated) throw error(404, 'Attachment not found');
|
||||
|
||||
return json(updated);
|
||||
@@ -89,11 +75,11 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
await deleteAttachment(attachmentId);
|
||||
await deleteAttachment(attachmentId, params.id);
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||
import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
return json(collaborators.get(params.id) ?? []);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteOwnership(params.id, userId);
|
||||
|
||||
const body = await request.json();
|
||||
const targetUserId = body.userId;
|
||||
if (!targetUserId || typeof targetUserId !== 'number') {
|
||||
throw error(400, 'userId is required');
|
||||
}
|
||||
|
||||
// Cannot add yourself
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, params.id)).get();
|
||||
if (note && note.userId === targetUserId) {
|
||||
throw error(400, 'Cannot add the owner as a collaborator');
|
||||
}
|
||||
|
||||
// Target user must exist
|
||||
const targetUser = db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).get();
|
||||
if (!targetUser) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
// Check not already a collaborator
|
||||
const existing = db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, params.id), eq(noteCollaborators.userId, targetUserId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw error(409, 'User is already a collaborator');
|
||||
}
|
||||
|
||||
addCollaborator(params.id, targetUserId, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
return json(collaborators.get(params.id) ?? [], { status: 201 });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const userIdParam = url.searchParams.get('userId');
|
||||
const targetUserId = userIdParam === 'self' ? userId : Number(userIdParam);
|
||||
if (!targetUserId) {
|
||||
throw error(400, 'userId query parameter is required');
|
||||
}
|
||||
|
||||
const { isOwner } = requireNoteAccess(params.id, userId);
|
||||
|
||||
// Owner can remove anyone. Collaborator can only remove self.
|
||||
if (!isOwner && targetUserId !== userId) {
|
||||
throw error(403, 'Only the owner can remove other collaborators');
|
||||
}
|
||||
|
||||
removeCollaborator(params.id, targetUserId);
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { json } 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 { reorderNotes } from '$lib/server/notes-service.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 body = await request.json();
|
||||
const orders: { id: string; sortOrder: number }[] = body.orders;
|
||||
|
||||
@@ -12,11 +12,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const { id, sortOrder } of orders) {
|
||||
tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run();
|
||||
}
|
||||
});
|
||||
|
||||
reorderNotes(userId, orders);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
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';
|
||||
import { searchNotes } from '$lib/server/notes-service.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -14,48 +11,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
const results = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
// Also search by tag name
|
||||
const tagResults = db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
|
||||
.all();
|
||||
|
||||
const resultIds = new Set(results.map((n) => n.id));
|
||||
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
|
||||
(id) => !resultIds.has(id)
|
||||
);
|
||||
|
||||
let extraNotes: typeof results = [];
|
||||
if (extraNoteIds.length > 0) {
|
||||
extraNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
|
||||
.all();
|
||||
}
|
||||
|
||||
const combined = [...results, ...extraNotes];
|
||||
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
|
||||
const withTags = combined.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(withTags);
|
||||
const results = searchNotes(userId, query);
|
||||
return json(results);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { listApiKeys, generateApiKey } from '$lib/server/api-keys.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const userId = getUserId(event);
|
||||
return json(listApiKeys(userId));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const { name } = await request.json();
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = generateApiKey(name.trim(), userId);
|
||||
return json(result, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { deleteApiKey } from '$lib/server/api-keys.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const deleted = deleteApiKey(params.id, userId);
|
||||
if (!deleted) throw error(404, 'API key not found');
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users } from '$lib/server/db/schema.js';
|
||||
import { ne } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const query = url.searchParams.get('q')?.trim();
|
||||
if (!query || query.length < 1) {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
const results = db
|
||||
.select({
|
||||
id: users.id,
|
||||
displayName: users.displayName,
|
||||
email: users.email
|
||||
})
|
||||
.from(users)
|
||||
.where(
|
||||
ne(users.id, userId)
|
||||
)
|
||||
.all()
|
||||
.filter(
|
||||
(u) =>
|
||||
u.displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
return json(results);
|
||||
};
|
||||
@@ -1,286 +0,0 @@
|
||||
<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>
|
||||
@@ -1,181 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,190 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { setupAndLogin } from './helpers/fixtures.js';
|
||||
|
||||
/**
|
||||
* MCP E2E tests exercise the full MCP protocol flow:
|
||||
* 1. Create an API key via the settings API (authenticated via session)
|
||||
* 2. Use the key to authenticate MCP requests
|
||||
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
|
||||
*/
|
||||
|
||||
const test = base.extend<{ apiKey: string }>({
|
||||
apiKey: async ({ page }, use) => {
|
||||
await setupAndLogin(page);
|
||||
|
||||
// Create API key via settings endpoint (session-authenticated)
|
||||
const res = await page.request.post('/api/settings/api-keys', {
|
||||
data: { name: 'E2E Test Key' }
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { key } = await res.json();
|
||||
await use(key);
|
||||
}
|
||||
});
|
||||
|
||||
async function mcpInitialize(page: Page, apiKey: string) {
|
||||
const res = await page.request.post('/api/mcp', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream'
|
||||
},
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'e2e-test', version: '1.0.0' }
|
||||
}
|
||||
}
|
||||
});
|
||||
const status = res.status();
|
||||
const body = await res.text();
|
||||
if (!res.ok()) {
|
||||
throw new Error(`MCP initialize failed with status ${status}: ${body.slice(0, 500)}`);
|
||||
}
|
||||
const sessionId = res.headers()['mcp-session-id'];
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
// Send initialized notification
|
||||
await page.request.post('/api/mcp', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'mcp-session-id': sessionId
|
||||
},
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized'
|
||||
}
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function mcpCallTool(
|
||||
page: Page,
|
||||
apiKey: string,
|
||||
sessionId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
id = 2
|
||||
) {
|
||||
const res = await page.request.post('/api/mcp', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'mcp-session-id': sessionId
|
||||
},
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args }
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const text = await res.text();
|
||||
// Response may be SSE format or JSON
|
||||
if (text.startsWith('event:') || text.startsWith('data:')) {
|
||||
// Parse SSE - find the last data line with actual content
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.result) return data.result;
|
||||
}
|
||||
}
|
||||
throw new Error('No result found in SSE response');
|
||||
}
|
||||
|
||||
return JSON.parse(text).result;
|
||||
}
|
||||
|
||||
test.describe('MCP Server', () => {
|
||||
test('Scenario: MCP client can list notes via the protocol', async ({ page, apiKey }) => {
|
||||
const sessionId = await mcpInitialize(page, apiKey);
|
||||
|
||||
// When calling list_notes tool
|
||||
const result = await mcpCallTool(page, apiKey, sessionId, 'list_notes');
|
||||
|
||||
// Then the response contains notes content
|
||||
expect(result.content).toBeDefined();
|
||||
expect(result.content[0].type).toBe('text');
|
||||
const notes = JSON.parse(result.content[0].text);
|
||||
expect(Array.isArray(notes)).toBe(true);
|
||||
});
|
||||
|
||||
test('Scenario: MCP client can create and retrieve a note', async ({ page, apiKey }) => {
|
||||
const sessionId = await mcpInitialize(page, apiKey);
|
||||
|
||||
// When creating a note via MCP
|
||||
const createResult = await mcpCallTool(page, apiKey, sessionId, 'create_note', {
|
||||
title: 'MCP Test Note',
|
||||
content: 'Created via MCP protocol'
|
||||
});
|
||||
const created = JSON.parse(createResult.content[0].text);
|
||||
expect(created.title).toBe('MCP Test Note');
|
||||
|
||||
// Then the note can be retrieved
|
||||
const getResult = await mcpCallTool(
|
||||
page,
|
||||
apiKey,
|
||||
sessionId,
|
||||
'get_note',
|
||||
{ id: created.id },
|
||||
3
|
||||
);
|
||||
const fetched = JSON.parse(getResult.content[0].text);
|
||||
expect(fetched.title).toBe('MCP Test Note');
|
||||
expect(fetched.content).toBe('Created via MCP protocol');
|
||||
});
|
||||
|
||||
test('Scenario: MCP client can search notes', async ({ page, apiKey }) => {
|
||||
const sessionId = await mcpInitialize(page, apiKey);
|
||||
|
||||
// Given a note with specific content exists
|
||||
await mcpCallTool(page, apiKey, sessionId, 'create_note', {
|
||||
title: 'Searchable Note',
|
||||
content: 'unique-search-term-xyz'
|
||||
});
|
||||
|
||||
// When searching for it
|
||||
const searchResult = await mcpCallTool(
|
||||
page,
|
||||
apiKey,
|
||||
sessionId,
|
||||
'search_notes',
|
||||
{ query: 'unique-search-term-xyz' },
|
||||
3
|
||||
);
|
||||
const results = JSON.parse(searchResult.content[0].text);
|
||||
|
||||
// Then it appears in results
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].title).toBe('Searchable Note');
|
||||
});
|
||||
|
||||
test('Scenario: MCP endpoint rejects requests without API key', async ({ page }) => {
|
||||
const res = await page.request.post('/api/mcp', {
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0.0' }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Settings — API Key Management', () => {
|
||||
test('Scenario: Created API key appears in the keys list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// When the user navigates to the MCP settings and creates an API key
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Settings Test Key');
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
|
||||
// Then the key is shown once for copying
|
||||
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
||||
const keyValue = await page.getByTestId('created-key-value').textContent();
|
||||
expect(keyValue).toMatch(/^crumbs_/);
|
||||
|
||||
// And it appears in the keys list
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Settings Test Key' });
|
||||
await expect(keyItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Revoked API key disappears from the keys list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given an API key exists
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Revoke Me');
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
||||
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Revoke Me' });
|
||||
await expect(keyItem).toBeVisible();
|
||||
|
||||
// When the user revokes the key
|
||||
await keyItem.getByTestId('delete-api-key-btn').click();
|
||||
await keyItem.getByTestId('confirm-delete-btn').click();
|
||||
|
||||
// Then the key is removed from the list
|
||||
await expect(keyItem).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings page is accessible from sidebar', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the main page
|
||||
// When the user clicks the Settings link in the sidebar
|
||||
await page.getByTestId('settings-link').click();
|
||||
|
||||
// Then the settings page is shown
|
||||
await expect(page).toHaveURL('/settings');
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings subpages are navigable', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the settings page
|
||||
await page.goto('/settings');
|
||||
|
||||
// When the user clicks the MCP Server nav link
|
||||
await page.getByRole('link', { name: 'MCP Server' }).click();
|
||||
|
||||
// Then the MCP settings page is shown
|
||||
await expect(page).toHaveURL('/settings/mcp');
|
||||
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
|
||||
|
||||
// When the user clicks back to Profile
|
||||
await page.getByRole('link', { name: 'Profile' }).click();
|
||||
await expect(page).toHaveURL('/settings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test, expect, noteCard, TEST_EMAIL, TEST_PASSWORD } from './helpers/fixtures.js';
|
||||
import type { Page, BrowserContext } from '@playwright/test';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/**
|
||||
* Create a second user via the admin API (using the admin's session cookies).
|
||||
*/
|
||||
async function createCollaboratorUser(adminPage: Page) {
|
||||
const cookies = await adminPage.context().cookies();
|
||||
const sessionCookie = cookies.find((c) => c.name === 'session');
|
||||
if (!sessionCookie) throw new Error('No session cookie found');
|
||||
|
||||
const res = await adminPage.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: COLLAB_EMAIL,
|
||||
displayName: COLLAB_NAME,
|
||||
password: COLLAB_PASSWORD,
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const body = await res.text();
|
||||
// User may already exist from prior test
|
||||
if (!body.includes('already exists') && res.status() !== 409) {
|
||||
throw new Error(`Failed to create collaborator user: ${res.status()} ${body}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the collaborator user in a new browser context.
|
||||
*/
|
||||
async function loginAsCollaborator(browser: import('@playwright/test').Browser): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
|
||||
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
|
||||
await page.getByTestId('login-btn').click();
|
||||
await page.waitForURL('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
test.describe.serial('Note Sharing', () => {
|
||||
let collabContext: BrowserContext;
|
||||
let collabPage: Page;
|
||||
|
||||
test('Scenario: Owner shares a note with another user', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given a second user exists
|
||||
await createCollaboratorUser(page);
|
||||
|
||||
// When the owner creates a note titled "Shared Note"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Shared Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Shared content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
|
||||
// And the owner opens the share dialog
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
|
||||
// And the owner searches for the collaborator
|
||||
await page.getByTestId('share-search-input').fill('collab');
|
||||
await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And the owner adds the collaborator
|
||||
await page.getByTestId('share-user-result').first().click();
|
||||
|
||||
// Then the collaborator appears in the share dialog
|
||||
await expect(page.getByTestId('share-collaborator')).toBeVisible();
|
||||
|
||||
// Close dialogs
|
||||
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note shows a collaborator indicator
|
||||
const card = noteCard(page, 'Shared Note');
|
||||
await expect(card.getByTestId('collaborator-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees the shared note in their list', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given the note was shared in the previous test
|
||||
// When the collaborator logs in
|
||||
const collab = await loginAsCollaborator(browser);
|
||||
collabContext = collab.context;
|
||||
collabPage = collab.page;
|
||||
|
||||
// Then the collaborator sees "Shared Note" in their notes list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// And the note shows a collaborator indicator
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('collaborator-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator can edit the shared note content', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator edits the note
|
||||
await noteCard(collabPage, 'Shared Note').click();
|
||||
const editor = collabPage.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially(' — edited by collaborator');
|
||||
await collabPage.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the owner reloads
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the owner sees the collaborator's changes
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
await expect(page.getByTestId('tiptap-editor')).toContainText('edited by collaborator');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Owner and collaborator have independent pin state', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator pins the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await card.getByTestId('pin-btn').click({ force: true });
|
||||
|
||||
// Then the collaborator sees the note as pinned
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('pin-indicator')).toBeVisible();
|
||||
|
||||
// But the owner does not see it as pinned
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const ownerCard = noteCard(page, 'Shared Note');
|
||||
await expect(ownerCard.getByTestId('pin-indicator')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Owner edits content and collaborator sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// When the owner edits the shared note content via the API
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
expect(sharedNote).toBeDefined();
|
||||
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by owner for sync test' }
|
||||
});
|
||||
|
||||
// Then the collaborator sees the updated content after fetching from the API
|
||||
const collabNotesRes = await collabPage.request.get('/api/notes');
|
||||
const collabNotes = await collabNotesRes.json();
|
||||
const collabSharedNote = collabNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(collabSharedNote).toBeDefined();
|
||||
expect(collabSharedNote.content).toBe('Content updated by owner for sync test');
|
||||
|
||||
// And the sync endpoint also returns the change for the collaborator
|
||||
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by owner for sync test');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator edits content and owner sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// Given we have the shared note ID
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
|
||||
// When the collaborator edits the content via the API
|
||||
await collabPage.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by collaborator for sync test' }
|
||||
});
|
||||
|
||||
// Then the owner sees the updated content from the sync endpoint
|
||||
const syncRes = await page.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by collaborator for sync test');
|
||||
});
|
||||
|
||||
test('Scenario: Sync returns per-user state overlay for collaborators', async ({ authenticatedPage: page }) => {
|
||||
// Given the shared note exists
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
|
||||
// And the owner pins the note (owner's pin state)
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { pinned: true }
|
||||
});
|
||||
|
||||
// Then the sync endpoint returns the collaborator's per-user state (not owner's)
|
||||
const collabSyncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const collabSyncedNotes = await collabSyncRes.json();
|
||||
const collabSyncedNote = collabSyncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
// Collaborator should see their own pinned state (from noteUserState), not the owner's
|
||||
// The collaborator pinned this note in a previous test, so it should be true for them
|
||||
// but via their own per-user state, not leaked from the owner
|
||||
expect(collabSyncedNote).toBeDefined();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action instead of trash', async () => {
|
||||
// Then the collaborator's card shows a leave button, not a trash button
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await expect(card.getByTestId('leave-btn')).toBeVisible();
|
||||
await expect(card.getByTestId('trash-btn')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator leaves shared note and it disappears', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator leaves the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await card.getByTestId('leave-btn').click({ force: true });
|
||||
|
||||
// Then the note disappears from the collaborator's list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// But the owner still sees it
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (collabContext) await collabContext.close();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
chunkSizeWarningLimit: 850
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: path.resolve(__dirname, 'src/lib')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
+37
-6
@@ -4,15 +4,19 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crumbs - Your notes. Your server. Your rules.</title>
|
||||
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app. A privacy-respecting Google Keep alternative you can run on your own server with a single Docker command.">
|
||||
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app that respects your privacy. Deploy with a single Docker command.">
|
||||
<meta property="og:title" content="Crumbs by Bretzel">
|
||||
<meta property="og:description" content="Self-hostable, offline-first note-taking. A Google Keep alternative that respects your privacy.">
|
||||
<meta property="og:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://crumbs.bretzel.app">
|
||||
<meta property="og:image" content="https://crumbs.bretzel.app/assets/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="og:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Crumbs by Bretzel">
|
||||
<meta name="twitter:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
|
||||
<meta name="twitter:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
|
||||
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
@@ -58,7 +62,7 @@
|
||||
Your notes. Your server.<br><span>Your rules.</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle animate-in delay-1">
|
||||
A self-hostable, offline-first note-taking app. The Google Keep alternative that respects your privacy.
|
||||
A self-hostable, offline-first note-taking app that respects your privacy.
|
||||
</p>
|
||||
<div class="hero-buttons animate-in delay-2">
|
||||
<a href="#deploy" class="btn btn-primary">
|
||||
@@ -135,6 +139,33 @@
|
||||
<h3>Full-Text Search</h3>
|
||||
<p>Find anything instantly. SQLite-powered search across all your notes and tags.</p>
|
||||
</div>
|
||||
|
||||
<!-- MCP / AI Integration -->
|
||||
<div class="feature-card animate-in delay-1">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
|
||||
</div>
|
||||
<h3>MCP Server</h3>
|
||||
<p>Built-in AI integration. Connect Claude, Cursor, or any MCP client to read and write your notes.</p>
|
||||
</div>
|
||||
|
||||
<!-- Multi-User -->
|
||||
<div class="feature-card animate-in delay-2">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</div>
|
||||
<h3>Multi-User</h3>
|
||||
<p>Share one instance with your team or family. Admin manages accounts, each user gets their own private notes.</p>
|
||||
</div>
|
||||
|
||||
<!-- OAuth / SSO -->
|
||||
<div class="feature-card animate-in delay-3">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
</div>
|
||||
<h3>OAuth & SSO</h3>
|
||||
<p>Sign in with Google, GitHub, or any OIDC provider. Or just use a password — your choice.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -222,7 +253,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.8.0</span>
|
||||
<span class="footer-version">v0.11.1</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user