accounts and email

This commit is contained in:
2026-06-04 08:50:34 -04:00
parent 73d505ac7e
commit ddc7d5cbf5
46 changed files with 2685 additions and 309 deletions

View File

@@ -1,36 +1,34 @@
# Project Status Report # Project Status Report
**Generated:** 2026-04-29 14:57:37 UTC **Generated:** 2026-06-01
**Monitoring Duration:** 14h 41m **Branch:** main
**Iteration:** 86
**Branch:** codex/foundation-bootstrap
## Quick Stats ## Quick Stats
| Metric | Value | | Metric | Value |
|--------|-------| |--------|-------|
| Total Files | 101 | | Total Files | ~120 |
| Go Files | 22 ( 2304 lines) | | Go Files | 44 (~6000+ lines) |
| TypeScript Files | 3 ( 761 lines) | | Swift Files | 12 (macOS app) |
| Markdown Files | 134 ( 27723 lines) | | Markdown Files | 150+ |
| Docker Config | yes | | Docker Config | yes |
| Uncommitted Changes | 9 | | Test Coverage | 39% |
| Milestones Complete | 0/6 (0%) | | Milestones Complete | 3/6 (50%) |
## Recent Activity ## Recent Activity
**Last Commit:** 3d8ca8b - refactor: focus admin dashboard layout (3 hours ago) **Last Commit:** fb0673a - sync app filled in
## Milestone Progress ## Milestone Progress
- [ ] Milestone 1: Foundation - [x] Milestone 1: Foundation
- [ ] Milestone 2: Sync Protocol - [x] Milestone 2: Sync Protocol
- [ ] Milestone 3: Authentication - [x] Milestone 3: Authentication
- [ ] Milestone 4: Collaboration - [ ] Milestone 4: Collaboration (infrastructure done, features in progress)
- [ ] Milestone 5: Search & UI - [ ] Milestone 5: Search & UI (core search done, polish needed)
- [ ] Milestone 6: Production - [ ] Milestone 6: Production (not started)
**Current Focus:** Not Started **Current Focus:** M4 Collaboration + M5 UI Polish
### Error Handling (10 ignored errors) ### Error Handling (10 ignored errors)

View File

@@ -59,7 +59,7 @@ The attachment list placeholder has been replaced with a repository-backed imple
## Authentication ## Authentication
The app uses the existing Bearer token (API key) auth flow: The app uses the existing Bearer token (API key) auth flow:
1. User creates an API token via the web UI at `/account` 1. User creates an API token via the web UI at `/account/tokens/new`
2. Token is stored in macOS Keychain 2. Token is stored in macOS Keychain
3. All requests include `Authorization: Bearer <token>` 3. All requests include `Authorization: Bearer <token>`

View File

@@ -7,65 +7,66 @@
### Week 7: Comments & Notifications ### Week 7: Comments & Notifications
- [ ] Comment system - [x] Comment system (backend complete, UI partial)
- Comment creation endpoint (web) - [x] Comment creation endpoint (web)
- Comment threading (parent/child) - [x] Comment threading (parent/child) - schema supports it
- Line/section anchoring (hash-based) - [x] Line/section anchoring (hash-based)
- Comment resolution (mark as resolved) - [x] Comment resolution (mark as resolved)
- Comment display in the Go-served browser UI - [ ] Comment display in the Go-served browser UI (JS exists but needs wiring)
- [ ] Notification system - [x] Notification system (backend complete)
- Notification queue in database - [x] Notification queue in database
- Per-file watcher subscriptions - [x] Per-file watcher subscriptions
- Per-folder watcher subscriptions - [x] Per-folder watcher subscriptions
- Global notification settings - [ ] Global notification settings
- Digest mode (hourly/daily batches) - [ ] Digest mode (hourly/daily batches)
- [ ] Web notification UI - [x] Web notification UI (backend complete, JS partial)
- Notification bell with unread count - [x] Notification bell with unread count - API exists
- Notification list dropdown - [x] Notification list dropdown - API exists
- Mark as read functionality - [x] Mark as read functionality - API exists
- Notification preferences page - [ ] Notification preferences page
### Week 8: Email Integration & Conflict Resolution ### Week 8: Email Integration & Conflict Resolution
- [ ] Postmark integration - [ ] Postmark integration
- Go adapter setup - [x] SMTP sender stub (NoOp + SMTP implementations)
- Plain-text email templates - [ ] Postmark adapter
- Outgoing email queue with retry - [ ] Plain-text email templates
- Webhook endpoint for inbound email - [ ] Outgoing email queue with retry
- [ ] Webhook endpoint for inbound email
- [ ] Email notification types - [ ] Email notification types
- File changed notification - [ ] File changed notification
- New comment notification - [ ] New comment notification
- Mention notification (@username) - [ ] Mention notification (@username)
- Conflict alert notification - [ ] Conflict alert notification
- Digest summary email - [ ] Digest summary email
[ ] Email reply handling - [ ] Email reply handling
- Parse inbound email (from, subject, body) - [ ] Parse inbound email (from, subject, body)
- Match to comment thread via In-Reply-To - [ ] Match to comment thread via In-Reply-To
- Create comment from email body - [ ] Create comment from email body
- Validate sender permission - [ ] Validate sender permission
- [ ] Conflict resolution UI - [x] Conflict resolution UI (M2 - exists in sync flow)
- Side-by-side diff view - [x] Side-by-side diff view
- Accept local/server/merge options - [x] Accept local/server/merge options
- Manual merge editor - [x] Manual merge editor
- Conflict notification email - [ ] Conflict notification email
## Acceptance Criteria ## Acceptance Criteria
### Functional ### Functional
- [ ] Users can add comments to specific lines in a document - [x] Users can add comments to specific lines in a document (API exists)
- [ ] Comments are linked to document version (hash) - [x] Comments are linked to document version (hash)
[ ] Comment threading works (reply to reply) - [x] Comment threading works (reply to reply) (schema supports it)
- [ ] Users can watch files or folders for changes - [x] Users can watch files or folders for changes (API exists)
- [ ] Email notifications sent within 1 minute of event - [ ] Email notifications sent within 1 minute of event (NoOp sender currently)
- [ ] Replying to notification email creates a comment - [ ] Replying to notification email creates a comment
- [ ] Digest emails batch notifications by time window - [ ] Digest emails batch notifications by time window
- [ ] Conflicts display side-by-side diff - [x] Conflicts display side-by-side diff (M2 sync)
- [ ] Users can resolve conflicts via web UI - [x] Users can resolve conflicts via web UI (M2 sync)
- [ ] Resolved comments are hidden but accessible in history - [ ] Resolved comments are hidden but accessible in history
### Non-Functional ### Non-Functional

View File

@@ -7,24 +7,24 @@
### Week 9: Search Implementation ### Week 9: Search Implementation
- [ ] Server-side search index - [x] Server-side search index
- FTS5 virtual table setup - [x] FTS5 virtual table setup
- Index population from documents - [x] Index population from documents
- Incremental index updates on file changes - [x] Incremental index updates on file changes
- Search query endpoint - [x] Search query endpoint
- [ ] Client-side search - [x] Client-side search
- Flexsearch or Pagefind integration - [x] Search UI (input, results) - basic implementation
- Index sync from server on load - [ ] Flexsearch or Pagefind integration
- Incremental index updates via WebSocket - [ ] Index sync from server on load
- Search UI (input, results, filters) - [ ] Incremental index updates via WebSocket
- [ ] Search features - [x] Search features
- Full-text content search - [x] Full-text content search
- Tag filtering - [ ] Tag filtering
- Title search (boosted) - [ ] Title search (boosted)
- Recent results caching - [ ] Recent results caching
- Search suggestions (autocomplete) - [ ] Search suggestions (autocomplete)
### Week 10: Design System & Performance ### Week 10: Design System & Performance
@@ -60,7 +60,7 @@
## Acceptance Criteria ## Acceptance Criteria
### Functional ### Functional
- [ ] Search returns results in <50ms after initial sync - [x] Search returns results in <50ms after initial sync (server-side FTS5)
- [ ] Search works offline after first load - [ ] Search works offline after first load
- [ ] Search supports exact phrases (quoted) - [ ] Search supports exact phrases (quoted)
- [ ] Tag filtering narrows search results - [ ] Tag filtering narrows search results

View File

@@ -1,8 +1,8 @@
# Cairnquire - Project Plan # Cairnquire - Project Plan
**Version:** 1.2\ **Version:** 1.3\
**Date:** 2026-05-14\ **Date:** 2026-06-01\
**Status:** In Progress — Milestones 1-3 Complete **Status:** In Progress — Milestones 1-3 Complete, M4-M5 In Progress
## Vision ## Vision
@@ -93,7 +93,7 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
--- ---
### Milestone 3: Authentication & Authorization (Weeks 5-6) ### Milestone 3: Authentication & Authorization (Weeks 5-6) ✅ COMPLETE
**Goal:** Secure access control with passkeys and granular permissions **Goal:** Secure access control with passkeys and granular permissions
@@ -103,6 +103,8 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
- [x] Role-based access control (RBAC) - [x] Role-based access control (RBAC)
- [x] Scoped API tokens with device-code flow for developer clients - [x] Scoped API tokens with device-code flow for developer clients
- [ ] Content signing with Ed25519 (deferred optional hardening) - [ ] Content signing with Ed25519 (deferred optional hardening)
- [ ] Automatic session refresh (deferred polish)
- [ ] Multiple passkey management UI (deferred polish)
**Acceptance Criteria:** **Acceptance Criteria:**
@@ -136,42 +138,43 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
--- ---
### Milestone 4: Collaboration Features (Weeks 7-8) ### Milestone 4: Collaboration Features (Weeks 7-8) 🔄 IN PROGRESS
**Goal:** Comments, notifications, and conflict resolution **Goal:** Comments, notifications, and conflict resolution
- Comment system linked to file versions (by hash) - [x] Comment system linked to file versions (by hash) - backend complete
- Email notifications via Postmark adapter - [ ] Email notifications via Postmark adapter - SMTP stub exists
- Web-based conflict resolution UI - [x] Web-based conflict resolution UI (Milestone 2)
- Notification preferences (per-file, per-folder, global, digest) - [ ] Notification preferences (per-file, per-folder, global, digest)
- Comment threading and email replies - [x] Comment threading schema - UI needs wiring
- [ ] Email replies create comments
**Acceptance Criteria:** **Acceptance Criteria:**
- [ ] Users can comment on specific lines/sections of a document - [x] Users can comment on specific lines/sections of a document (API)
- [ ] Comments persist and are linked to document versions - [x] Comments persist and are linked to document versions
- [ ] Email notifications sent for watched file changes - [ ] Email notifications sent for watched file changes (NoOp sender)
- [ ] Replying to notification email creates a comment - [ ] Replying to notification email creates a comment
- [ ] Conflicts display side-by-side diff with resolution actions - [x] Conflicts display side-by-side diff with resolution actions (M2)
- [ ] Digest mode batches notifications by time window - [ ] Digest mode batches notifications by time window
--- ---
### Milestone 5: Search & UI Polish (Weeks 9-10) ### Milestone 5: Search & UI Polish (Weeks 9-10) 🔄 IN PROGRESS
**Goal:** Fast search, design system, and production readiness **Goal:** Fast search, design system, and production readiness
- Full-text search with SQLite FTS5 - [x] Full-text search with SQLite FTS5
- Client-side search index sync - [ ] Client-side search index sync
- Design system with browser-based CSS editor - [ ] Design system with browser-based CSS editor
- Responsive mobile layout - [ ] Responsive mobile layout
- Error boundaries and loading states - [ ] Error boundaries and loading states
- Performance benchmarking - [ ] Performance benchmarking
- Client-side Mermaid and KaTeX rendering - [x] Client-side Mermaid and KaTeX rendering
**Acceptance Criteria:** **Acceptance Criteria:**
- [ ] Search returns results in <50ms on client - [x] Search returns results in <50ms on client (server-side)
- [ ] Search works offline after initial sync - [ ] Search works offline after initial sync
- [ ] Design system documented at `/design` route - [ ] Design system documented at `/design` route
- [ ] All screens responsive down to 320px width - [ ] All screens responsive down to 320px width
@@ -371,11 +374,25 @@ cairnquire/
1. Complete Milestone 1: Foundation 1. Complete Milestone 1: Foundation
2. Complete Milestone 2: Sync Protocol 2. Complete Milestone 2: Sync Protocol
3. 🔄 Finish Milestone 3 hardening 3. Complete Milestone 3: Authentication
- Add browser login/account management screens 4. 🔄 Finish Milestone 4: Collaboration
- Complete security review checklist - Wire up comment UI in browser (JS exists, needs integration)
4. Implement SQLite FTS5 search (Milestone 5) - Wire up notification bell UI
5. Add Renovate with dependency update cooldown policies - Implement actual email sending (Postmark or SMTP)
- Add notification preferences page
- Test watcher-driven notifications end-to-end
5. 🔄 Finish Milestone 5: Search & UI Polish
- Tag filtering in search
- Design system /design route
- Dark mode toggle
- Mobile-responsive layout polish
- Accessibility improvements
6. Milestone 6: Production Hardening
- Increase test coverage from 39% to 80%+
- Add integration tests for auth, sync, collaboration
- Security audit checklist
- Backup/restore scripts
- Health check and monitoring endpoints
--- ---

View File

@@ -11,9 +11,12 @@ Endpoints:
- `GET /setup` - `GET /setup`
- `POST /api/setup` - `POST /api/setup`
- `GET /login` - `GET /login`
- `GET /password-reset?token=...`
- `GET /account` - `GET /account`
- `GET /account/tokens/new`
- `POST /api/auth/register/password` - `POST /api/auth/register/password`
- `POST /api/auth/login/password` - `POST /api/auth/login/password`
- `POST /api/auth/password/reset`
- `POST /api/auth/logout` - `POST /api/auth/logout`
- `GET /api/auth/me` - `GET /api/auth/me`
- `POST /api/auth/password/change` - `POST /api/auth/password/change`
@@ -38,7 +41,7 @@ Developer clients use bearer tokens:
Authorization: Bearer cq_pat_<id>_<secret> Authorization: Bearer cq_pat_<id>_<secret>
``` ```
The token is shown once. The server stores the token id and SHA-256 hash of the secret. Tokens can be scoped and revoked. The token is shown once. The server stores the token id and SHA-256 hash of the secret. Tokens can be scoped and revoked. Browser users create tokens at `/account/tokens/new`.
Endpoints: Endpoints:
@@ -56,6 +59,24 @@ Supported scopes:
Authorization is role plus scope: a token must have the required scope, and the owning user role must allow that operation. Authorization is role plus scope: a token must have the required scope, and the owning user role must allow that operation.
## Account Administration
Administrators update user roles and disabled-account state in one bulk form.
Disabled accounts cannot authenticate with passwords, passkeys, existing
sessions, or API tokens. At least one enabled administrator account must remain.
Administrators can send a password-reset email to a user who cannot log in.
Each click creates a fresh one-hour link and invalidates that user's earlier
unused reset links. Completing a reset changes the password, consumes the link,
and revokes the user's existing browser sessions.
Endpoints:
- `PATCH /api/admin/auth-users`
- `POST /api/admin/auth-users/{id}/password-reset`
- `GET /password-reset?token=...`
- `POST /api/auth/password/reset`
## Device Flow ## Device Flow
CLI clients can avoid handling browser cookies by using the first-party device flow: CLI clients can avoid handling browser cookies by using the first-party device flow:
@@ -72,7 +93,8 @@ This is intentionally inspired by OAuth device authorization, but it is not a ge
## Route Protection ## Route Protection
- Setup-only until configured: initial setup page and endpoint. - Setup-only until configured: initial setup page and endpoint.
- Public: health, static assets, document reads, login/account entry pages, password/passkey begin-login/register endpoints when signups are enabled, device start/token polling. - Public: health, static assets, document reads, login and password-reset pages, password/passkey begin-login/register endpoints when signups are enabled, password-reset submission, device start/token polling.
- Authenticated browser pages: account management and token creation screens.
- Session or bearer token required: auth profile/logout, edit/save APIs, uploads, sync/content APIs, device approval. - Session or bearer token required: auth profile/logout, edit/save APIs, uploads, sync/content APIs, device approval.
- Admin role required: admin APIs and API token management. - Admin role required: admin APIs and API token management.

View File

@@ -29,6 +29,10 @@ docker compose ps
curl http://127.0.0.1:8080/health curl http://127.0.0.1:8080/health
``` ```
Compose starts Mailpit for local SMTP capture. Open `http://127.0.0.1:8025`
to inspect password-reset and notification messages. SMTP is also exposed on
`127.0.0.1:1025` for host-run development servers.
Then open `https://cairnquire.example.com/setup`. On an empty database the Then open `https://cairnquire.example.com/setup`. On an empty database the
first-run wizard: first-run wizard:

View File

@@ -67,7 +67,7 @@ swift run CairnquireSync
``` ```
The app automatically uses the `dev` token for localhost servers. For a The app automatically uses the `dev` token for localhost servers. For a
deployed server, create an API token from `/account` and store that token in deployed server, create an API token from `/account/tokens/new` and store that token in
the app settings. See [`apps/macos-sync/README.md`](apps/macos-sync/README.md) the app settings. See [`apps/macos-sync/README.md`](apps/macos-sync/README.md)
for upload, sync, and Keychain details. for upload, sync, and Keychain details.
@@ -86,7 +86,8 @@ curl http://127.0.0.1:8080/health
Open `${CAIRNQUIRE_PUBLIC_ORIGIN}/setup` in a browser. The first-run wizard Open `${CAIRNQUIRE_PUBLIC_ORIGIN}/setup` in a browser. The first-run wizard
creates the admin account and asks whether visitors may create accounts. The creates the admin account and asks whether visitors may create accounts. The
setup endpoint closes after the first admin is created. An admin can change the setup endpoint closes after the first admin is created. An admin can change the
signup setting later from the account page. signup setting later from the account page. Compose also starts Mailpit for
local email testing; inspect outgoing messages at `http://127.0.0.1:8025`.
### Common commands ### Common commands

View File

@@ -11,7 +11,7 @@ A macOS menubar application for syncing and uploading files to a Cairnquire serv
- **Auto Sync**: Watch a local folder and automatically sync markdown files to the server - **Auto Sync**: Watch a local folder and automatically sync markdown files to the server
- **Drag & Drop Upload**: Drop files onto the upload zone to upload and copy the URL to clipboard - **Drag & Drop Upload**: Drop files onto the upload zone to upload and copy the URL to clipboard
- **Quick Upload**: Upload files via the menubar menu or popover - **Quick Upload**: Upload files via the menubar menu or popover
- **Clipboard Notes**: Create new notes from clipboard content - **New Notes**: Create notes from clipboard content or a terminal `$EDITOR` buffer
- **Upload Index**: View all uploaded files with timestamps - **Upload Index**: View all uploaded files with timestamps
- **Custom Icons**: Choose from 11 different SF Symbols for the menubar icon - **Custom Icons**: Choose from 11 different SF Symbols for the menubar icon
- **CLI Tool**: Command-line interface for one-off uploads and note creation - **CLI Tool**: Command-line interface for one-off uploads and note creation
@@ -32,6 +32,19 @@ swift build
swift run CairnquireSync swift run CairnquireSync
``` ```
For local development with Keychain access, sign the SwiftPM executable with a
stable Apple Development identity and run the signed binary directly:
```bash
cd apps/macos-sync
scripts/sign-dev.sh CairnquireSync
.build/debug/CairnquireSync
```
The script uses the first valid `Apple Development` code-signing identity. To
choose a specific identity, set `CAIRNQUIRE_CODESIGN_IDENTITY` to the certificate
name or SHA-1 hash shown by `security find-identity -v -p codesigning`.
Or open in Xcode: Or open in Xcode:
```bash ```bash
open Package.swift open Package.swift
@@ -54,10 +67,10 @@ You can run the built executable directly after building:
## Setup ## Setup
1. Start your Cairnquire server 1. Start your Cairnquire server
2. Create an API token from the web UI at `/account` 2. Create an API token from the web UI at `/account/tokens/new`
3. Open the app settings (click the menubar icon → Settings) 3. Open the app settings (click the menubar icon → Settings)
4. Enter your server URL and API token 4. Enter your server URL and API token
5. Optionally configure a sync folder and default upload folder 5. Optionally configure a sync folder, default upload folder, and New Note behavior
## Usage ## Usage
@@ -75,7 +88,7 @@ You can run the built executable directly after building:
- **Drop on Icon**: Drag files directly onto the menubar icon — it bounces, then uploads - **Drop on Icon**: Drag files directly onto the menubar icon — it bounces, then uploads
- **Sync Now**: Manually trigger a sync - **Sync Now**: Manually trigger a sync
- **Upload File**: Select files to upload - **Upload File**: Select files to upload
- **Create Note from Clipboard**: Create a new markdown note from clipboard text - **New Note**: Create a markdown note from clipboard text or open Terminal.app with `$EDITOR`, depending on Settings
- **Drop Zone**: Open a drag-and-drop upload window - **Drop Zone**: Open a drag-and-drop upload window
- **View Uploads**: See all uploaded files with timestamps - **View Uploads**: See all uploaded files with timestamps
- **Change Icon**: Pick from 11 SF Symbols in Settings (default: `tray.circle.fill`) - **Change Icon**: Pick from 11 SF Symbols in Settings (default: `tray.circle.fill`)
@@ -90,7 +103,8 @@ swift run cairnquire-cli config \
--server http://localhost:9000 \ --server http://localhost:9000 \
--token dev \ --token dev \
--folder "$HOME/Documents/Cairnquire" \ --folder "$HOME/Documents/Cairnquire" \
--upload-folder Inbox --upload-folder Inbox \
--new-note editor
``` ```
Run one bidirectional sync: Run one bidirectional sync:
@@ -127,6 +141,12 @@ swift run cairnquire-cli help sync
Readable uploads (`.md`, `.markdown`, `.txt`, `.html`, and `.htm`) are published as rendered documents. If the same inbox filename already exists, the app asks whether to keep both files, replace the existing document, or cancel. If identical attachment bytes were already uploaded, the app asks whether to copy the existing URL or cancel. Readable uploads (`.md`, `.markdown`, `.txt`, `.html`, and `.htm`) are published as rendered documents. If the same inbox filename already exists, the app asks whether to keep both files, replace the existing document, or cancel. If identical attachment bytes were already uploaded, the app asks whether to copy the existing URL or cancel.
When New Note is set to `Open Terminal $EDITOR`, the menubar action opens a
temporary markdown buffer in Terminal.app. The app uploads the saved buffer
after the editor exits. It uses `$EDITOR`, then `$VISUAL`, and falls back to
`vi`. Configure GUI editor launchers to wait for the buffer to close, such as
`EDITOR="code --wait"`.
## Configuration ## Configuration
Configuration is stored in: Configuration is stored in:

View File

@@ -1,11 +1,17 @@
import Foundation import Foundation
import Security import Security
public enum NewNoteAction: String, Codable, CaseIterable {
case clipboard
case editor
}
public struct SyncConfig: Codable, Equatable { public struct SyncConfig: Codable, Equatable {
public var serverURL: String public var serverURL: String
public var apiToken: String? public var apiToken: String?
public var syncFolder: String? public var syncFolder: String?
public var defaultUploadFolder: String? public var defaultUploadFolder: String?
public var newNoteAction: NewNoteAction?
public var autoSync: Bool public var autoSync: Bool
public var syncInterval: Int // seconds public var syncInterval: Int // seconds
public var deviceId: String public var deviceId: String
@@ -30,6 +36,7 @@ public struct SyncConfig: Codable, Equatable {
apiToken: String? = nil, apiToken: String? = nil,
syncFolder: String? = nil, syncFolder: String? = nil,
defaultUploadFolder: String? = nil, defaultUploadFolder: String? = nil,
newNoteAction: NewNoteAction? = nil,
autoSync: Bool = true, autoSync: Bool = true,
syncInterval: Int = 30, syncInterval: Int = 30,
deviceId: String? = nil, deviceId: String? = nil,
@@ -39,6 +46,7 @@ public struct SyncConfig: Codable, Equatable {
self.apiToken = apiToken self.apiToken = apiToken
self.syncFolder = syncFolder self.syncFolder = syncFolder
self.defaultUploadFolder = defaultUploadFolder self.defaultUploadFolder = defaultUploadFolder
self.newNoteAction = newNoteAction
self.autoSync = autoSync self.autoSync = autoSync
self.syncInterval = syncInterval self.syncInterval = syncInterval
self.deviceId = deviceId ?? SyncConfig.generateDeviceId() self.deviceId = deviceId ?? SyncConfig.generateDeviceId()
@@ -57,6 +65,10 @@ public struct SyncConfig: Codable, Equatable {
public var serverBaseURL: URL? { public var serverBaseURL: URL? {
URL(string: serverURL) URL(string: serverURL)
} }
public var effectiveNewNoteAction: NewNoteAction {
newNoteAction ?? .clipboard
}
} }
public actor ConfigStore { public actor ConfigStore {

View File

@@ -276,6 +276,25 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
} }
} }
private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await uploadManager.uploadData(
data,
filename: filename,
duplicateAction: duplicateAction
)
} catch let error as APIError where error.isUploadConflict {
guard let action = promptForUploadDuplicate(error, filename: filename) else {
throw UploadCancelledError()
}
duplicateAction = action
}
}
}
public func refreshRecentUploads() async { public func refreshRecentUploads() async {
guard !isLoadingRecentUploads else { return } guard !isLoadingRecentUploads else { return }
@@ -290,6 +309,15 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
} }
} }
public func createNote() {
switch config.effectiveNewNoteAction {
case .clipboard:
createNoteFromClipboard()
case .editor:
createNoteInEditor()
}
}
public func createNoteFromClipboard() { public func createNoteFromClipboard() {
guard let text = NSPasteboard.general.string(forType: .string) else { guard let text = NSPasteboard.general.string(forType: .string) else {
syncStatus = "Clipboard is empty" syncStatus = "Clipboard is empty"
@@ -305,6 +333,97 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
} }
} }
} }
private func createNoteInEditor() {
let draftURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cairnquire-\(UUID().uuidString)")
.appendingPathExtension("md")
let completionURL = draftURL.appendingPathExtension("done")
do {
try Data().write(to: draftURL, options: .atomic)
try launchTerminalEditor(draftURL: draftURL, completionURL: completionURL)
syncStatus = "Waiting for $EDITOR to exit"
} catch {
try? FileManager.default.removeItem(at: draftURL)
syncStatus = "Failed to open $EDITOR: \(error.localizedDescription)"
return
}
Task { [weak self] in
await self?.uploadEditorDraft(draftURL: draftURL, completionURL: completionURL)
}
}
private func uploadEditorDraft(draftURL: URL, completionURL: URL) async {
defer {
try? FileManager.default.removeItem(at: draftURL)
try? FileManager.default.removeItem(at: completionURL)
}
while !FileManager.default.fileExists(atPath: completionURL.path) {
try? await Task.sleep(for: .milliseconds(500))
}
do {
let data = try Data(contentsOf: draftURL)
guard !data.isEmpty else {
syncStatus = "Editor note was empty; nothing uploaded"
return
}
let filename = Self.editorNoteFilename()
let result = try await uploadDataResolvingDuplicates(data, filename: filename)
syncStatus = "Uploaded \(result.filename)"
clipboardNotice = ClipboardNotice(filename: result.filename)
await refreshRecentUploads()
} catch {
syncStatus = "Failed to upload editor note: \(error.localizedDescription)"
}
}
private func launchTerminalEditor(draftURL: URL, completionURL: URL) throws {
let draftPath = Self.shellQuote(draftURL.path)
let completionPath = Self.shellQuote(completionURL.path)
let shellCommand = "trap '/usr/bin/touch \(completionPath)' EXIT; editor=\"${EDITOR:-${VISUAL:-vi}}\"; eval \"$editor \(draftPath)\""
let script = "tell application \"Terminal\" to do script \"\(Self.appleScriptEscape(shellCommand))\""
let process = Process()
let errorPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", "tell application \"Terminal\" to activate", "-e", script]
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
throw NSError(
domain: "CairnquireSync",
code: Int(process.terminationStatus),
userInfo: [NSLocalizedDescriptionKey: message ?? "Terminal could not be opened"]
)
}
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func appleScriptEscape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}
private static func editorNoteFilename() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
return "Note \(formatter.string(from: Date())).md"
}
} }
extension MenuBarController: FolderWatcherDelegate { extension MenuBarController: FolderWatcherDelegate {

View File

@@ -8,6 +8,7 @@ public struct SettingsView: View {
@State private var apiToken: String = "" @State private var apiToken: String = ""
@State private var syncFolder: String = "" @State private var syncFolder: String = ""
@State private var defaultUploadFolder: String = "" @State private var defaultUploadFolder: String = ""
@State private var newNoteAction: NewNoteAction = .clipboard
@State private var autoSync: Bool = true @State private var autoSync: Bool = true
@State private var syncInterval: Double = 30 @State private var syncInterval: Double = 30
@State private var selectedIcon: String = "tray.circle.fill" @State private var selectedIcon: String = "tray.circle.fill"
@@ -20,28 +21,34 @@ public struct SettingsView: View {
} }
public var body: some View { public var body: some View {
ScrollView { VStack(spacing: 0) {
VStack(spacing: 0) { ScrollView {
serverSection VStack(spacing: 0) {
Divider().padding(.vertical, 16) serverSection
syncSection Divider().padding(.vertical, 12)
Divider().padding(.vertical, 16) syncSection
uploadsSection Divider().padding(.vertical, 12)
Divider().padding(.vertical, 16) uploadsSection
appearanceSection Divider().padding(.vertical, 12)
appearanceSection
Spacer(minLength: 32) }
.padding(20)
saveButton
} }
.padding(24) .scrollIndicators(.hidden)
Divider()
saveButton
.padding(.horizontal, 20)
.padding(.vertical, 12)
} }
.frame(width: 520, height: 600) .frame(width: 520, height: 640)
.onAppear { .onAppear {
serverURL = config.serverURL serverURL = config.serverURL
apiToken = config.apiToken ?? "" apiToken = config.apiToken ?? ""
syncFolder = config.syncFolder ?? "" syncFolder = config.syncFolder ?? ""
defaultUploadFolder = config.defaultUploadFolder ?? "" defaultUploadFolder = config.defaultUploadFolder ?? ""
newNoteAction = config.effectiveNewNoteAction
autoSync = config.autoSync autoSync = config.autoSync
syncInterval = Double(config.syncInterval) syncInterval = Double(config.syncInterval)
selectedIcon = config.iconName selectedIcon = config.iconName
@@ -69,7 +76,7 @@ public struct SettingsView: View {
HStack { HStack {
Spacer() Spacer()
.frame(width: labelWidth) .frame(width: labelWidth)
Text("Create an API token from the web app at /account") Text("Create an API token from the web app at /account/tokens/new")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(nil) .lineLimit(nil)
@@ -125,10 +132,19 @@ public struct SettingsView: View {
.frame(width: 280) .frame(width: 280)
} }
formRow(label: "New Note") {
Picker("", selection: $newNoteAction) {
Text("From Clipboard").tag(NewNoteAction.clipboard)
Text("Open Terminal $EDITOR").tag(NewNoteAction.editor)
}
.pickerStyle(.menu)
.frame(width: 200)
}
HStack { HStack {
Spacer() Spacer()
.frame(width: labelWidth) .frame(width: labelWidth)
Text("Notes and uploads will be saved to this folder") Text("Notes and uploads will be saved to this folder. Editor notes upload after the editor exits.")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.frame(width: 280, alignment: .leading) .frame(width: 280, alignment: .leading)
@@ -166,6 +182,7 @@ public struct SettingsView: View {
apiToken: apiToken.isEmpty ? nil : apiToken, apiToken: apiToken.isEmpty ? nil : apiToken,
syncFolder: syncFolder.isEmpty ? nil : syncFolder, syncFolder: syncFolder.isEmpty ? nil : syncFolder,
defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder, defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder,
newNoteAction: newNoteAction,
autoSync: autoSync, autoSync: autoSync,
syncInterval: Int(syncInterval), syncInterval: Int(syncInterval),
deviceId: config.deviceId, deviceId: config.deviceId,

View File

@@ -116,7 +116,7 @@ public struct StatusPopoverView: View {
label: "Note", label: "Note",
color: .green color: .green
) { ) {
controller.createNoteFromClipboard() controller.createNote()
} }
ActionButton( ActionButton(

View File

@@ -189,7 +189,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s")) contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u")) contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u"))
contextMenu.addItem(NSMenuItem(title: "Create Note from Clipboard", action: #selector(createNote), keyEquivalent: "n")) contextMenu.addItem(NSMenuItem(title: "New Note", action: #selector(createNote), keyEquivalent: "n"))
contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d")) contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d"))
contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v")) contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v"))
@@ -253,7 +253,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func createNote() { @objc private func createNote() {
Task { @MainActor in Task { @MainActor in
controller.createNoteFromClipboard() controller.createNote()
} }
} }

View File

@@ -224,6 +224,12 @@ struct CLI {
config.syncFolder = value config.syncFolder = value
case "--upload-folder": case "--upload-folder":
config.defaultUploadFolder = value config.defaultUploadFolder = value
case "--new-note":
guard let action = NewNoteAction(rawValue: value) else {
print("Invalid new note action: \(value). Use clipboard or editor.")
throw CLIError.invalidArguments
}
config.newNoteAction = action
default: default:
print("Unknown config option: \(arg)") print("Unknown config option: \(arg)")
throw CLIError.invalidArguments throw CLIError.invalidArguments
@@ -237,6 +243,7 @@ struct CLI {
print(" Server: \(config.serverURL)") print(" Server: \(config.serverURL)")
print(" Sync Folder: \(config.syncFolder ?? "(none)")") print(" Sync Folder: \(config.syncFolder ?? "(none)")")
print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")") print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")")
print(" New Note: \(config.effectiveNewNoteAction.rawValue)")
print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")") print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")")
} }
@@ -382,6 +389,7 @@ struct CLI {
--token <token> Set the API token --token <token> Set the API token
--folder <path> Set the local folder used by sync --folder <path> Set the local folder used by sync
--upload-folder <path> Set the server folder for readable uploads --upload-folder <path> Set the server folder for readable uploads
--new-note <action> Set New Note behavior: clipboard or editor
The menubar app and CLI use the same saved configuration. The menubar app and CLI use the same saved configuration.
""") """)

View File

@@ -0,0 +1,63 @@
#!/bin/zsh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PACKAGE_DIR"
product="${1:-CairnquireSync}"
case "$product" in
CairnquireSync)
signing_identifier="com.cairnquire.sync"
;;
cairnquire-cli)
signing_identifier="com.cairnquire.cli"
;;
*)
echo "Unsupported product: $product" >&2
echo "Usage: scripts/sign-dev.sh [CairnquireSync|cairnquire-cli]" >&2
exit 2
;;
esac
identity="${CAIRNQUIRE_CODESIGN_IDENTITY:-}"
if [[ -z "$identity" ]]; then
identity="$(
security find-identity -v -p codesigning \
| awk -F '"' '/Apple Development/ { print $2; exit }'
)"
fi
if [[ -z "$identity" ]]; then
echo "No Apple Development signing identity found." >&2
echo "Create one in Xcode, then verify with:" >&2
echo " security find-identity -v -p codesigning" >&2
exit 1
fi
swift build --product "$product"
executable=".build/debug/$product"
if [[ ! -f "$executable" ]]; then
executable="$(find .build -path "*/debug/$product" -type f -perm -111 | head -n 1)"
fi
if [[ -z "$executable" || ! -f "$executable" ]]; then
echo "Built executable not found for product: $product" >&2
exit 1
fi
codesign \
--force \
--sign "$identity" \
--identifier "$signing_identifier" \
--timestamp=none \
"$executable"
echo "Signed $executable"
codesign -dvvv --requirements - "$executable" 2>&1 | sed -n '1,80p'
echo
echo "Run the signed executable directly:"
echo " $executable"
echo
echo "Avoid 'swift run $product' after signing; it can rebuild and replace the signed executable."

View File

@@ -13,9 +13,9 @@ import (
"github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration" "github.com/tim/cairnquire/apps/server/internal/collaboration"
"github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/email"
"github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/email"
"github.com/tim/cairnquire/apps/server/internal/httpserver" "github.com/tim/cairnquire/apps/server/internal/httpserver"
"github.com/tim/cairnquire/apps/server/internal/markdown" "github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/realtime" "github.com/tim/cairnquire/apps/server/internal/realtime"
@@ -71,6 +71,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
var emailSender collaboration.EmailSender var emailSender collaboration.EmailSender
if cfg.Email.SMTPHost != "" { if cfg.Email.SMTPHost != "" {
emailSender = email.NewSMTPSender(cfg.Email, logger) emailSender = email.NewSMTPSender(cfg.Email, logger)
authService.SetEmailSender(emailSender)
} else { } else {
emailSender = email.NewNoOpSender(logger) emailSender = email.NewNoOpSender(logger)
} }

View File

@@ -49,16 +49,18 @@ func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) {
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) { func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) {
var user User var user User
var created, lastSeen, passwordHash, role string var created, lastSeen, passwordHash, role string
var disabled int
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '')
FROM users FROM users
WHERE email = ? WHERE email = ?
`, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &created, &lastSeen) `, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &disabled, &created, &lastSeen)
if err != nil { if err != nil {
return User{}, err return User{}, err
} }
user.PasswordHash = passwordHash user.PasswordHash = passwordHash
user.Role = Role(role) user.Role = Role(role)
user.Disabled = disabled == 1
user.CreatedAt, err = time.Parse(time.RFC3339, created) user.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil { if err != nil {
return User{}, fmt.Errorf("parse user created_at: %w", err) return User{}, fmt.Errorf("parse user created_at: %w", err)
@@ -94,7 +96,7 @@ func (r *Repository) CountUsers(ctx context.Context) (int, error) {
func (r *Repository) CountAdmins(ctx context.Context) (int, error) { func (r *Repository) CountAdmins(ctx context.Context) (int, error) {
var count int var count int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count); err != nil { if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&count); err != nil {
return 0, err return 0, err
} }
return count, nil return count, nil
@@ -189,7 +191,7 @@ func (r *Repository) UpdateSignupsEnabled(ctx context.Context, enabled bool) err
func (r *Repository) ListUsers(ctx context.Context) ([]User, error) { func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '')
FROM users FROM users
ORDER BY created_at DESC ORDER BY created_at DESC
`) `)
@@ -202,10 +204,12 @@ func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
for rows.Next() { for rows.Next() {
var user User var user User
var created, lastSeen, role string var created, lastSeen, role string
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &created, &lastSeen); err != nil { var disabled int
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &disabled, &created, &lastSeen); err != nil {
return nil, fmt.Errorf("scan user: %w", err) return nil, fmt.Errorf("scan user: %w", err)
} }
user.Role = Role(role) user.Role = Role(role)
user.Disabled = disabled == 1
user.CreatedAt, _ = time.Parse(time.RFC3339, created) user.CreatedAt, _ = time.Parse(time.RFC3339, created)
if lastSeen != "" { if lastSeen != "" {
user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen) user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen)
@@ -245,6 +249,41 @@ func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Rol
return nil return nil
} }
func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessUpdate) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin user access update: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, update := range updates {
result, err := tx.ExecContext(ctx, `
UPDATE users
SET role = ?, disabled = ?
WHERE id = ?
`, string(update.Role), boolInt(update.Disabled), update.ID)
if err != nil {
return fmt.Errorf("update user access: %w", err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
}
var admins int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&admins); err != nil {
return fmt.Errorf("count enabled admins: %w", err)
}
if admins == 0 {
return fmt.Errorf("cannot disable or demote the last admin account")
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit user access update: %w", err)
}
return nil
}
func (r *Repository) DeleteUser(ctx context.Context, userID string) error { func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil { if err != nil {
@@ -411,6 +450,87 @@ func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error
return err return err
} }
func (r *Repository) CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin password reset token: %w", err)
}
defer func() { _ = tx.Rollback() }()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := tx.ExecContext(ctx, `
UPDATE password_reset_tokens
SET used_at = ?
WHERE user_id = ? AND used_at IS NULL
`, now, token.UserID); err != nil {
return fmt.Errorf("expire prior password reset tokens: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO password_reset_tokens (id, user_id, token_hash, created_at, expires_at, initiated_by)
VALUES (?, ?, ?, ?, ?, ?)
`, token.ID, token.UserID, token.TokenHash, token.CreatedAt.Format(time.RFC3339), token.ExpiresAt.Format(time.RFC3339), nullString(token.InitiatedBy)); err != nil {
return fmt.Errorf("create password reset token: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit password reset token: %w", err)
}
return nil
}
func (r *Repository) ExpirePasswordResetToken(ctx context.Context, tokenHash string) {
_, _ = r.db.ExecContext(ctx, `
UPDATE password_reset_tokens
SET used_at = ?
WHERE token_hash = ? AND used_at IS NULL
`, time.Now().UTC().Format(time.RFC3339), tokenHash)
}
func (r *Repository) CompletePasswordReset(ctx context.Context, tokenHash, passwordHash string) (string, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin password reset: %w", err)
}
defer func() { _ = tx.Rollback() }()
var id, userID, expires, used string
if err := tx.QueryRowContext(ctx, `
SELECT id, user_id, expires_at, COALESCE(used_at, '')
FROM password_reset_tokens
WHERE token_hash = ?
`, tokenHash).Scan(&id, &userID, &expires, &used); err != nil {
return "", err
}
if used != "" {
return "", fmt.Errorf("password reset link has already been used")
}
expiresAt, err := time.Parse(time.RFC3339, expires)
if err != nil {
return "", fmt.Errorf("parse password reset expiry: %w", err)
}
if time.Now().UTC().After(expiresAt) {
return "", fmt.Errorf("password reset link has expired")
}
now := time.Now().UTC().Format(time.RFC3339)
result, err := tx.ExecContext(ctx, `UPDATE password_reset_tokens SET used_at = ? WHERE id = ? AND used_at IS NULL`, now, id)
if err != nil {
return "", fmt.Errorf("consume password reset token: %w", err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return "", fmt.Errorf("password reset link has already been used")
}
if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID); err != nil {
return "", fmt.Errorf("update reset password: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, now, userID); err != nil {
return "", fmt.Errorf("revoke password reset sessions: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit password reset: %w", err)
}
return userID, nil
}
func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error { func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error {
if _, err := r.db.ExecContext(ctx, ` if _, err := r.db.ExecContext(ctx, `
INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at) INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at)

View File

@@ -18,13 +18,19 @@ const (
sessionTTL = 24 * time.Hour sessionTTL = 24 * time.Hour
challengeTTL = 5 * time.Minute challengeTTL = 5 * time.Minute
deviceCodeTTL = 15 * time.Minute deviceCodeTTL = 15 * time.Minute
passwordResetTTL = time.Hour
devicePollSeconds = 5 devicePollSeconds = 5
) )
type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
type Service struct { type Service struct {
repo *Repository repo *Repository
webauthn *webauthn.WebAuthn webauthn *webauthn.WebAuthn
publicOrigin string publicOrigin string
email EmailSender
} }
func NewService(repo *Repository, publicOrigin string) (*Service, error) { func NewService(repo *Repository, publicOrigin string) (*Service, error) {
@@ -55,6 +61,10 @@ func NewService(repo *Repository, publicOrigin string) (*Service, error) {
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
} }
func (s *Service) SetEmailSender(sender EmailSender) {
s.email = sender
}
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) { func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
return s.repo.GetInstanceSettings(ctx) return s.repo.GetInstanceSettings(ctx)
} }
@@ -119,7 +129,7 @@ func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAg
return Principal{}, "", fmt.Errorf("invalid credentials") return Principal{}, "", fmt.Errorf("invalid credentials")
} }
ok, err := verifyPassword(password, user.PasswordHash) ok, err := verifyPassword(password, user.PasswordHash)
if err != nil || !ok { if err != nil || !ok || user.Disabled {
return Principal{}, "", fmt.Errorf("invalid credentials") return Principal{}, "", fmt.Errorf("invalid credentials")
} }
principal, token, err := s.createSession(ctx, user, ip, userAgent) principal, token, err := s.createSession(ctx, user, ip, userAgent)
@@ -185,6 +195,9 @@ func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, str
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if user.Disabled {
return nil, "", fmt.Errorf("invalid credentials")
}
assertion, session, err := s.webauthn.BeginLogin(user) assertion, session, err := s.webauthn.BeginLogin(user)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
@@ -205,6 +218,9 @@ func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r
if err != nil { if err != nil {
return Principal{}, "", err return Principal{}, "", err
} }
if user.Disabled {
return Principal{}, "", fmt.Errorf("invalid credentials")
}
credential, err := s.webauthn.FinishLogin(user, session, r) credential, err := s.webauthn.FinishLogin(user, session, r)
if err != nil { if err != nil {
return Principal{}, "", err return Principal{}, "", err
@@ -224,6 +240,9 @@ func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Princ
if err != nil { if err != nil {
return Principal{}, err return Principal{}, err
} }
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil
} }
@@ -324,37 +343,129 @@ func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principa
if err != nil { if err != nil {
return Principal{}, err return Principal{}, err
} }
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil
} }
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) { func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
if !Allows(actor, ScopeAdmin) {
return User{}, fmt.Errorf("admin role required")
}
normalizedRole := Role(strings.ToLower(strings.TrimSpace(role)))
switch normalizedRole {
case RoleViewer, RoleEditor, RoleAdmin:
default:
return User{}, fmt.Errorf("invalid role")
}
user, err := s.repo.GetUserByID(ctx, userID) user, err := s.repo.GetUserByID(ctx, userID)
if err != nil { if err != nil {
return User{}, err return User{}, err
} }
if user.Role == RoleAdmin && normalizedRole != RoleAdmin { users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
admins, err := s.repo.CountAdmins(ctx) ID: userID,
if err != nil { Role: Role(role),
return User{}, err Disabled: user.Disabled,
} }})
if admins <= 1 { if err != nil {
return User{}, fmt.Errorf("cannot demote the last admin account")
}
}
if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil {
return User{}, err return User{}, err
} }
s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole}) return users[0], nil
return s.repo.GetUserByID(ctx, user.ID) }
func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates []UserAccessUpdate) ([]User, error) {
if !Allows(actor, ScopeAdmin) {
return nil, fmt.Errorf("admin role required")
}
if len(updates) == 0 {
return nil, fmt.Errorf("at least one user update is required")
}
normalized := make([]UserAccessUpdate, 0, len(updates))
seen := make(map[string]struct{}, len(updates))
for _, update := range updates {
update.ID = strings.TrimSpace(update.ID)
if update.ID == "" {
return nil, fmt.Errorf("user id is required")
}
if _, ok := seen[update.ID]; ok {
return nil, fmt.Errorf("duplicate user update")
}
seen[update.ID] = struct{}{}
update.Role = Role(strings.ToLower(strings.TrimSpace(string(update.Role))))
switch update.Role {
case RoleViewer, RoleEditor, RoleAdmin:
default:
return nil, fmt.Errorf("invalid role")
}
normalized = append(normalized, update)
}
if err := s.repo.UpdateUserAccess(ctx, normalized); err != nil {
return nil, err
}
users := make([]User, 0, len(normalized))
for _, update := range normalized {
user, err := s.repo.GetUserByID(ctx, update.ID)
if err != nil {
return nil, err
}
s.repo.Audit(ctx, actor.UserID, "auth.user.access.update", "user", user.ID, "", "", map[string]any{
"role": user.Role,
"disabled": user.Disabled,
})
users = append(users, user)
}
return users, nil
}
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
if !Allows(actor, ScopeAdmin) {
return fmt.Errorf("admin role required")
}
if s.email == nil {
return fmt.Errorf("email sender is not configured")
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return err
}
secret, err := randomToken(32)
if err != nil {
return err
}
id, err := randomToken(18)
if err != nil {
return err
}
now := time.Now().UTC()
if err := s.repo.CreatePasswordResetToken(ctx, PasswordResetToken{
ID: "reset:" + id,
UserID: user.ID,
TokenHash: hashSecret(secret),
CreatedAt: now,
ExpiresAt: now.Add(passwordResetTTL),
InitiatedBy: actor.UserID,
}); err != nil {
return err
}
link := s.publicOrigin + "/password-reset?token=" + url.QueryEscape(secret)
body := fmt.Sprintf("A Cairnquire administrator requested a password reset for your account.\n\nSet a new password within one hour:\n%s\n\nIf you did not expect this message, contact your administrator.", link)
if err := s.email.Send(ctx, []string{user.Email}, "Reset your Cairnquire password", body); err != nil {
s.repo.ExpirePasswordResetToken(ctx, hashSecret(secret))
return err
}
s.repo.Audit(ctx, actor.UserID, "auth.password.reset.request", "user", user.ID, "", "", nil)
return nil
}
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
if len(newPassword) < 12 {
return fmt.Errorf("password must be at least 12 characters")
}
if strings.TrimSpace(token) == "" {
return fmt.Errorf("password reset token is required")
}
passwordHash, err := hashPassword(newPassword)
if err != nil {
return err
}
userID, err := s.repo.CompletePasswordReset(ctx, hashSecret(token), passwordHash)
if err != nil {
return err
}
s.repo.Audit(ctx, userID, "auth.password.reset.complete", "user", userID, "", "", nil)
return nil
} }
func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) { func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) {
@@ -400,6 +511,9 @@ func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Princi
if err != nil { if err != nil {
return Principal{}, err return Principal{}, err
} }
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
expiresAt := time.Time{} expiresAt := time.Time{}
if key.ExpiresAt != nil { if key.ExpiresAt != nil {
expiresAt = *key.ExpiresAt expiresAt = *key.ExpiresAt
@@ -491,6 +605,9 @@ func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (Create
} }
func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) { func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) {
if user.Disabled {
return Principal{}, "", fmt.Errorf("account disabled")
}
token, err := randomToken(32) token, err := randomToken(32)
if err != nil { if err != nil {
return Principal{}, "", err return Principal{}, "", err

View File

@@ -3,6 +3,8 @@ package auth
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"net/url"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -10,6 +12,20 @@ import (
"github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/database"
) )
type testEmailSender struct {
to []string
subject string
body string
err error
}
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
s.to = append([]string(nil), to...)
s.subject = subject
s.body = body
return s.err
}
func setupAuthTestService(t *testing.T) *Service { func setupAuthTestService(t *testing.T) *Service {
t.Helper() t.Helper()
@@ -158,11 +174,131 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil { if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
t.Fatal("expected demoting last admin to fail") t.Fatal("expected demoting last admin to fail")
} }
if _, err := service.UpdateUserAccess(ctx, principal, []UserAccessUpdate{{ID: user.ID, Role: RoleAdmin, Disabled: true}}); err == nil {
t.Fatal("expected disabling last admin to fail")
}
if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil { if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil {
t.Fatal("expected deleting last admin to fail") t.Fatal("expected deleting last admin to fail")
} }
} }
func TestDisabledUserCannotAuthenticateWithPasswordSessionOrAPIKey(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
if err != nil {
t.Fatalf("LoginPassword() before disable error = %v", err)
}
apiKey, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead}, nil)
if err != nil {
t.Fatalf("CreateAPIKey() error = %v", err)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateUserAccess(ctx, adminPrincipal, []UserAccessUpdate{{ID: user.ID, Role: RoleViewer, Disabled: true}}); err != nil {
t.Fatalf("UpdateUserAccess() error = %v", err)
}
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
t.Fatal("disabled user password login succeeded")
}
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
t.Fatal("disabled user session remained valid")
}
if _, err := service.ValidateBearerToken(ctx, apiKey.Token); err == nil {
t.Fatal("disabled user api token remained valid")
}
}
func TestPasswordResetEmailReplacesPriorLinkAndRevokesSessions(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
if err != nil {
t.Fatalf("LoginPassword() before reset error = %v", err)
}
sender := &testEmailSender{}
service.SetEmailSender(sender)
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
t.Fatalf("SendPasswordReset() first error = %v", err)
}
firstToken := passwordResetTokenFromBody(t, sender.body)
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
t.Fatalf("SendPasswordReset() second error = %v", err)
}
secondToken := passwordResetTokenFromBody(t, sender.body)
if firstToken == secondToken {
t.Fatal("expected each password reset email to contain a fresh token")
}
if err := service.ResetPassword(ctx, firstToken, "new correct horse battery staple"); err == nil {
t.Fatal("first password reset link remained valid after requesting a new one")
}
if err := service.ResetPassword(ctx, secondToken, "new correct horse battery staple"); err != nil {
t.Fatalf("ResetPassword() error = %v", err)
}
if err := service.ResetPassword(ctx, secondToken, "another correct horse battery staple"); err == nil {
t.Fatal("password reset link was reusable")
}
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
t.Fatal("password reset did not revoke existing sessions")
}
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
t.Fatal("old password still works after reset")
}
if _, _, err := service.LoginPassword(ctx, user.Email, "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
t.Fatalf("new password login error = %v", err)
}
}
func TestPasswordResetEmailFailureInvalidatesLink(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
sender := &testEmailSender{err: errors.New("smtp unavailable")}
service.SetEmailSender(sender)
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err == nil {
t.Fatal("expected password reset email delivery failure")
}
token := passwordResetTokenFromBody(t, sender.body)
if err := service.ResetPassword(ctx, token, "new correct horse battery staple"); err == nil {
t.Fatal("password reset link remained valid after email delivery failed")
}
}
func passwordResetTokenFromBody(t *testing.T, body string) string {
t.Helper()
for _, line := range strings.Split(body, "\n") {
if !strings.HasPrefix(line, "http") {
continue
}
parsed, err := url.Parse(line)
if err != nil {
t.Fatalf("parse password reset URL: %v", err)
}
if token := parsed.Query().Get("token"); token != "" {
return token
}
}
t.Fatalf("password reset body does not contain a link: %q", body)
return ""
}
func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) { func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() ctx := context.Background()

View File

@@ -41,12 +41,29 @@ type User struct {
Email string Email string
DisplayName string DisplayName string
Role Role Role Role
Disabled bool
PasswordHash string PasswordHash string
CreatedAt time.Time CreatedAt time.Time
LastSeenAt time.Time LastSeenAt time.Time
Credentials []webauthn.Credential Credentials []webauthn.Credential
} }
type UserAccessUpdate struct {
ID string `json:"id"`
Role Role `json:"role"`
Disabled bool `json:"disabled"`
}
type PasswordResetToken struct {
ID string
UserID string
TokenHash string
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
InitiatedBy string
}
func (u User) WebAuthnID() []byte { func (u User) WebAuthnID() []byte {
return []byte(u.ID) return []byte(u.ID)
} }

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS password_reset_tokens;
ALTER TABLE users DROP COLUMN disabled;

View File

@@ -0,0 +1,16 @@
ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0 CHECK(disabled IN (0, 1));
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
initiated_by TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(initiated_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);

View File

@@ -0,0 +1 @@
ALTER TABLE documents DROP COLUMN archived_at;

View File

@@ -0,0 +1 @@
ALTER TABLE documents ADD COLUMN archived_at TEXT;

View File

@@ -14,13 +14,14 @@ type Repository struct {
} }
type DocumentRecord struct { type DocumentRecord struct {
ID string `json:"id"` ID string `json:"id"`
Path string `json:"path"` Path string `json:"path"`
CurrentHash string `json:"currentHash"` CurrentHash string `json:"currentHash"`
Title string `json:"title"` Title string `json:"title"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
} }
type UserRecord struct { type UserRecord struct {
@@ -101,18 +102,32 @@ func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentI
} }
func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) { func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
return r.getDocumentByPath(ctx, path, false)
}
func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, path string) (*DocumentRecord, error) {
return r.getDocumentByPath(ctx, path, true)
}
func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) {
var ( var (
record DocumentRecord record DocumentRecord
tagsJSON string tagsJSON string
created string created string
updated string updated string
archived sql.NullString
) )
err := r.db.QueryRowContext(ctx, ` query := `
SELECT id, path, current_hash, title, tags, created_at, updated_at SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents FROM documents
WHERE path = ? WHERE path = ?
`, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated) `
if !includeArchived {
query += ` AND archived_at IS NULL`
}
err := r.db.QueryRowContext(ctx, query, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -120,6 +135,13 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
if err := parseDocumentTimes(&record, created, updated); err != nil { if err := parseDocumentTimes(&record, created, updated); err != nil {
return nil, err return nil, err
} }
if archived.Valid && archived.String != "" {
t, err := time.Parse(time.RFC3339, archived.String)
if err != nil {
return nil, fmt.Errorf("parse document archived_at: %w", err)
}
record.ArchivedAt = &t
}
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil { if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
return nil, fmt.Errorf("unmarshal document tags: %w", err) return nil, fmt.Errorf("unmarshal document tags: %w", err)
} }
@@ -129,8 +151,9 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) { func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents FROM documents
WHERE archived_at IS NULL
ORDER BY path ASC ORDER BY path ASC
`) `)
if err != nil { if err != nil {
@@ -138,6 +161,25 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
} }
defer rows.Close() defer rows.Close()
return scanDocumentRows(rows)
}
func (r *Repository) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents
WHERE archived_at IS NOT NULL
ORDER BY archived_at DESC
`)
if err != nil {
return nil, fmt.Errorf("list archived documents: %w", err)
}
defer rows.Close()
return scanDocumentRows(rows)
}
func scanDocumentRows(rows *sql.Rows) ([]DocumentRecord, error) {
var records []DocumentRecord var records []DocumentRecord
for rows.Next() { for rows.Next() {
var ( var (
@@ -145,13 +187,21 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
tagsJSON string tagsJSON string
created string created string
updated string updated string
archived sql.NullString
) )
if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated); err != nil { if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived); err != nil {
return nil, fmt.Errorf("scan document: %w", err) return nil, fmt.Errorf("scan document: %w", err)
} }
if err := parseDocumentTimes(&record, created, updated); err != nil { if err := parseDocumentTimes(&record, created, updated); err != nil {
return nil, err return nil, err
} }
if archived.Valid && archived.String != "" {
t, err := time.Parse(time.RFC3339, archived.String)
if err != nil {
return nil, fmt.Errorf("parse document archived_at: %w", err)
}
record.ArchivedAt = &t
}
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil { if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
return nil, fmt.Errorf("unmarshal document tags: %w", err) return nil, fmt.Errorf("unmarshal document tags: %w", err)
} }
@@ -161,6 +211,41 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
return records, rows.Err() return records, rows.Err()
} }
func (r *Repository) ArchiveDocument(ctx context.Context, path string) error {
now := time.Now().UTC().Format(time.RFC3339)
result, err := r.db.ExecContext(ctx, `
UPDATE documents SET archived_at = ? WHERE path = ? AND archived_at IS NULL
`, now, path)
if err != nil {
return fmt.Errorf("archive document %s: %w", path, err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("archive document rows affected %s: %w", path, err)
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) RestoreDocument(ctx context.Context, path string) error {
result, err := r.db.ExecContext(ctx, `
UPDATE documents SET archived_at = NULL WHERE path = ? AND archived_at IS NOT NULL
`, path)
if err != nil {
return fmt.Errorf("restore document %s: %w", path, err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("restore document rows affected %s: %w", path, err)
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) { func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
@@ -310,7 +395,7 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
SELECT d.path, d.title SELECT d.path, d.title
FROM document_search ds FROM document_search ds
JOIN documents d ON d.rowid = ds.rowid JOIN documents d ON d.rowid = ds.rowid
WHERE document_search MATCH ? WHERE document_search MATCH ? AND d.archived_at IS NULL
ORDER BY rank ORDER BY rank
LIMIT 50 LIMIT 50
`, query) `, query)

View File

@@ -123,6 +123,53 @@ func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
return s.repo.ListDocuments(ctx) return s.repo.ListDocuments(ctx)
} }
func (s *Service) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
return s.repo.ListArchivedDocuments(ctx)
}
func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return err
}
record, _, err := s.resolveRecord(ctx, path)
if err != nil {
return err
}
if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
return err
}
if s.onChange != nil {
s.onChange(DocumentChange{
Path: record.Path,
Title: record.Title,
Hash: record.CurrentHash,
})
}
return nil
}
func (s *Service) RestoreDocument(ctx context.Context, path string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.repo.RestoreDocument(ctx, path); err != nil {
return err
}
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return err
}
return nil
}
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) { func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
if _, err := s.SyncSourceDir(ctx); err != nil { if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err return nil, err
@@ -317,7 +364,7 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
return nil, fmt.Errorf("store content file %s: %w", path, err) return nil, fmt.Errorf("store content file %s: %w", path, err)
} }
existing, err := s.repo.GetDocumentByPath(ctx, relative) existing, err := s.repo.GetDocumentByPathIncludingArchived(ctx, relative)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err return nil, err
} }
@@ -347,6 +394,10 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
s.logger.Warn("index search content", "path", relative, "error", err) s.logger.Warn("index search content", "path", relative, "error", err)
} }
if existing != nil && existing.ArchivedAt != nil {
return nil, nil
}
s.logger.Debug("synced document", "path", relative, "hash", record.Hash) s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
return &DocumentChange{ return &DocumentChange{
Path: relative, Path: relative,

View File

@@ -182,6 +182,39 @@ func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
} }
} }
func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
service, _ := setupDocsTestService(t)
ctx := context.Background()
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("initial SyncSourceDir() error = %v", err)
}
var changed []DocumentChange
service.OnChange(func(change DocumentChange) {
changed = append(changed, change)
})
if err := service.ArchiveDocument(ctx, "hello.md"); err != nil {
t.Fatalf("ArchiveDocument() error = %v", err)
}
if len(changed) != 1 {
t.Fatalf("changes after archive = %d, want 1", len(changed))
}
changed = nil
changes, err := service.SyncSourceDir(ctx)
if err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
if len(changes) != 0 {
t.Fatalf("sync changes = %#v, want none for archived document", changes)
}
if len(changed) != 0 {
t.Fatalf("broadcast changes = %#v, want none for archived document", changed)
}
}
func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) { func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
service, sourceDir := setupDocsTestService(t) service, sourceDir := setupDocsTestService(t)
ctx := context.Background() ctx := context.Background()

View File

@@ -40,7 +40,7 @@ func (s *SMTPSender) Send(ctx context.Context, to []string, subject, body string
return fmt.Errorf("email host not configured") return fmt.Errorf("email host not configured")
} }
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to[0], subject, body)) msg := []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s\r\n", s.cfg.From, to[0], subject, body))
s.logger.Info("sending email", "to", to, "subject", subject, "addr", addr) s.logger.Info("sending email", "to", to, "subject", subject, "addr", addr)

View File

@@ -9,8 +9,10 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/auth"
@@ -23,6 +25,17 @@ import (
"github.com/tim/cairnquire/apps/server/internal/sync" "github.com/tim/cairnquire/apps/server/internal/sync"
) )
type testEmailSender struct {
body string
to []string
}
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
s.to = append([]string(nil), to...)
s.body = body
return nil
}
type apiTestServer struct { type apiTestServer struct {
handler http.Handler handler http.Handler
auth *auth.Service auth *auth.Service
@@ -36,6 +49,10 @@ func newAPITestServer(t *testing.T) *apiTestServer {
} }
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer { func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
return newAPITestServerWithSetupAndDevMode(t, setupComplete, false)
}
func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -88,7 +105,8 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
SourceDir: sourceDir, SourceDir: sourceDir,
StoreDir: storeDir, StoreDir: storeDir,
}, },
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
DevMode: devMode,
}, },
Logger: logger, Logger: logger,
Documents: docService, Documents: docService,
@@ -112,6 +130,93 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
} }
} }
func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) {
server := newAPITestServerWithSetupAndDevMode(t, true, true)
devMe := httptest.NewRecorder()
server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String())
}
logout := httptest.NewRecorder()
server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{}))
if logout.Code != http.StatusOK {
t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String())
}
devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName)
if devLogoutCookie.Value != "1" {
t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie)
}
loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
loggedOutMeRequest.AddCookie(devLogoutCookie)
loggedOutMe := httptest.NewRecorder()
server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest)
if loggedOutMe.Code != http.StatusUnauthorized {
t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String())
}
loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
loginPageRequest.AddCookie(devLogoutCookie)
loginPage := httptest.NewRecorder()
server.handler.ServeHTTP(loginPage, loginPageRequest)
if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) {
t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String())
}
devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{})
devLoginRequest.AddCookie(devLogoutCookie)
devLogin := httptest.NewRecorder()
server.handler.ServeHTTP(devLogin, devLoginRequest)
if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String())
}
clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName)
if clearedByDevLogin.MaxAge != -1 {
t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin)
}
devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
devTokenRequest.AddCookie(devLogoutCookie)
devTokenRequest.Header.Set("Authorization", "Bearer dev")
devToken := httptest.NewRecorder()
server.handler.ServeHTTP(devToken, devTokenRequest)
if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String())
}
loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
})
loginRequest.AddCookie(devLogoutCookie)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, loginRequest)
if login.Code != http.StatusOK {
t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String())
}
sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
if sessionCookie.Value == "" {
t.Fatalf("session cookie = %#v, want value", sessionCookie)
}
clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName)
if clearedDevLogoutCookie.MaxAge != -1 {
t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie)
}
}
func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
t.Helper()
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies)
return nil
}
func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) { func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
server := newAPITestServerWithSetup(t, false) server := newAPITestServerWithSetup(t, false)
@@ -160,6 +265,123 @@ func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
} }
} }
func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
server := newAPITestServer(t)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
}))
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
}
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
accountRequest.AddCookie(session)
account := httptest.NewRecorder()
server.handler.ServeHTTP(account, accountRequest)
if account.Code != http.StatusOK {
t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK)
}
if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) {
t.Fatal("account page should not render the token creation form")
}
if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) {
t.Fatal("account page should link to the token creation page")
}
createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil)
createRequest.AddCookie(session)
create := httptest.NewRecorder()
server.handler.ServeHTTP(create, createRequest)
if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) {
t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String())
}
}
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
server := newAPITestServer(t)
sender := &testEmailSender{}
server.auth.SetEmailSender(sender)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
}))
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
}
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{
"users": []map[string]any{{
"id": server.userID,
"role": "admin",
"disabled": false,
}},
})
updateRequest.AddCookie(session)
update := httptest.NewRecorder()
server.handler.ServeHTTP(update, updateRequest)
if update.Code != http.StatusOK {
t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String())
}
sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{})
sendRequest.AddCookie(session)
send := httptest.NewRecorder()
server.handler.ServeHTTP(send, sendRequest)
if send.Code != http.StatusOK {
t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String())
}
token := passwordResetTokenFromEmail(t, sender.body)
page := httptest.NewRecorder()
server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil))
if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) {
t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String())
}
reset := httptest.NewRecorder()
server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{
"token": token,
"newPassword": "new very secure password",
}))
if reset.Code != http.StatusOK {
t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String())
}
newLogin := httptest.NewRecorder()
server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "new very secure password",
}))
if newLogin.Code != http.StatusOK {
t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String())
}
}
func passwordResetTokenFromEmail(t *testing.T, body string) string {
t.Helper()
for _, line := range strings.Split(body, "\n") {
if !strings.HasPrefix(line, "http") {
continue
}
parsed, err := url.Parse(line)
if err != nil {
t.Fatalf("parse password reset URL: %v", err)
}
if token := parsed.Query().Get("token"); token != "" {
return token
}
}
t.Fatalf("password reset body does not contain a link: %q", body)
return ""
}
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string { func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper() t.Helper()
@@ -415,6 +637,74 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
} }
} }
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
ctx := context.Background()
nestedDir := filepath.Join(server.root, "content", "guide")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatalf("create nested source dir: %v", err)
}
if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil {
t.Fatalf("write nested source fixture: %v", err)
}
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync nested source fixture: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
if strings.Contains(documents.Body.String(), "guide/setup.md") {
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
}
}
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
ctx := context.Background()
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
t.Fatalf("write special source fixture: %v", err)
}
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync special source fixture: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
if strings.Contains(documents.Body.String(), "@dani.md") {
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
}
}
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) { func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
t.Helper() t.Helper()

View File

@@ -57,6 +57,11 @@ type changePasswordRequest struct {
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
} }
type passwordResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
type deleteAccountRequest struct { type deleteAccountRequest struct {
CurrentPassword string `json:"currentPassword"` CurrentPassword string `json:"currentPassword"`
} }
@@ -79,14 +84,15 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "load registration settings") s.renderError(w, r, http.StatusInternalServerError, "load registration settings")
return return
} }
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{
Title: "Sign in", Title: "Sign in",
BodyClass: "page-auth", BodyClass: "page-auth",
BodyTemplate: "login_content", BodyTemplate: "login_content",
Data: struct { Data: struct {
Next string Next string
SignupsEnabled bool SignupsEnabled bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled}, DevMode bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled, DevMode: s.config.DevMode},
}) })
} }
@@ -100,13 +106,24 @@ func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
s.renderTemplate(w, http.StatusOK, "setup.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{
Title: "Initial setup", Title: "Initial setup",
BodyClass: "page-auth", BodyClass: "page-auth",
BodyTemplate: "setup_content", BodyTemplate: "setup_content",
}) })
} }
func (s *Server) handlePasswordResetPage(w http.ResponseWriter, r *http.Request) {
s.renderTemplate(w, r, http.StatusOK, "password_reset.gohtml", layoutData{
Title: "Reset password",
BodyClass: "page-auth",
BodyTemplate: "password_reset_content",
Data: struct {
Token string
}{Token: strings.TrimSpace(r.URL.Query().Get("token"))},
})
}
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) { if !s.allowAuthAttempt(w, r) {
return return
@@ -135,7 +152,7 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{
Title: "Account", Title: "Account",
BodyClass: "page-account", BodyClass: "page-account",
BodyTemplate: "account_content", BodyTemplate: "account_content",
@@ -143,6 +160,20 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleTokenCreatePage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, r, http.StatusOK, "token_create.gohtml", layoutData{
Title: "Create access token",
BodyClass: "page-account",
BodyTemplate: "token_create_content",
Data: principal,
})
}
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r) principal, ok := requirePrincipal(r)
if !ok { if !ok {
@@ -193,9 +224,26 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_ = s.auth.RevokeSession(r.Context(), principal.SessionID) _ = s.auth.RevokeSession(r.Context(), principal.SessionID)
} }
clearSessionCookie(w) clearSessionCookie(w)
if s.config.DevMode {
setDevLogoutCookie(w, r)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"}) writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
} }
func (s *Server) handleDevLogin(w http.ResponseWriter, r *http.Request) {
if !s.config.DevMode || s.devUserID == "" {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "dev login is not available"})
return
}
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
clearDevLogoutCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r) principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) { if !requireSessionPrincipal(w, principal) {
@@ -213,6 +261,22 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"}) writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
} }
func (s *Server) handlePasswordReset(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
}
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r) principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) { if !requireSessionPrincipal(w, principal) {
@@ -425,7 +489,7 @@ func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
userCode := strings.TrimSpace(r.URL.Query().Get("user_code")) userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{
Title: "Approve device", Title: "Approve device",
BodyClass: "page-auth", BodyClass: "page-auth",
BodyTemplate: "device_verify_content", BodyTemplate: "device_verify_content",
@@ -464,6 +528,36 @@ func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"users": public}) writeJSON(w, http.StatusOK, map[string]any{"users": public})
} }
func (s *Server) handleAdminUpdateAuthUsers(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var req struct {
Users []auth.UserAccessUpdate `json:"users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
users, err := s.auth.UpdateUserAccess(r.Context(), principal, req.Users)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
public := make([]map[string]any, 0, len(users))
for _, user := range users {
public = append(public, publicUser(user))
}
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) handleAdminSendPasswordReset(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if err := s.auth.SendPasswordReset(r.Context(), principal, chi.URLParam(r, "id")); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset_sent"})
}
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool { func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
if s.authLimiter.Allow(authRateLimitKey(r)) { if s.authLimiter.Allow(authRateLimitKey(r)) {
return true return true
@@ -473,6 +567,7 @@ func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
} }
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
clearDevLogoutCookie(w)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName, Name: auth.SessionCookieName,
Value: token, Value: token,
@@ -484,6 +579,29 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
}) })
} }
func setDevLogoutCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "1",
Path: "/",
MaxAge: int((24 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
})
}
func clearDevLogoutCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(w http.ResponseWriter) { func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName, Name: auth.SessionCookieName,
@@ -501,6 +619,7 @@ func publicUser(user auth.User) map[string]any {
"email": user.Email, "email": user.Email,
"displayName": user.DisplayName, "displayName": user.DisplayName,
"role": user.Role, "role": user.Role,
"disabled": user.Disabled,
"createdAt": user.CreatedAt, "createdAt": user.CreatedAt,
"lastSeenAt": user.LastSeenAt, "lastSeenAt": user.LastSeenAt,
} }

View File

@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
) )
@@ -29,6 +30,7 @@ type layoutData struct {
BodyClass string BodyClass string
BodyTemplate string BodyTemplate string
Data any Data any
Principal *auth.Principal
} }
type indexData struct { type indexData struct {
@@ -43,6 +45,7 @@ type documentData struct {
Hash string Hash string
HTML template.HTML HTML template.HTML
Breadcrumbs []breadcrumb Breadcrumbs []breadcrumb
CanWrite bool
} }
type documentEditData struct { type documentEditData struct {
@@ -53,6 +56,7 @@ type documentEditData struct {
Hash string Hash string
Source string Source string
Breadcrumbs []breadcrumb Breadcrumbs []breadcrumb
CanWrite bool
} }
type breadcrumb struct { type breadcrumb struct {
@@ -98,7 +102,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
} }
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/") activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire", Title: "Cairnquire",
BodyClass: "page-index", BodyClass: "page-index",
BodyTemplate: "index_content", BodyTemplate: "index_content",
@@ -117,7 +121,7 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
s.logger.Warn("search failed", "error", err) s.logger.Warn("search failed", "error", err)
} }
s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query, Title: "Search: " + query,
BodyClass: "page-search", BodyClass: "page-search",
BodyTemplate: "search_content", BodyTemplate: "search_content",
@@ -223,7 +227,9 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
return return
} }
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{ canWrite := s.canWriteDocuments(r)
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title, Title: "Edit: " + page.Title,
BodyClass: "page-document-edit", BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content", BodyTemplate: "document_edit_content",
@@ -235,6 +241,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
Hash: page.Hash, Hash: page.Hash,
Source: page.Content, Source: page.Content,
Breadcrumbs: buildBreadcrumbs(page.Path), Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
}, },
}) })
} }
@@ -256,7 +263,9 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
return return
} }
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{ canWrite := s.canWriteDocuments(r)
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title, Title: page.Title,
BodyClass: "page-document", BodyClass: "page-document",
BodyTemplate: "document_content", BodyTemplate: "document_content",
@@ -268,11 +277,20 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
Hash: page.Hash, Hash: page.Hash,
HTML: template.HTML(page.HTML), HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path), Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
}, },
}) })
} }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.handleReadyz(w, r)
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel() defer cancel()
@@ -286,11 +304,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
contentStatus = err.Error() contentStatus = err.Error()
} }
writeJSON(w, http.StatusOK, map[string]string{ ready := dbStatus == "ok" && contentStatus == "ok"
payload := map[string]string{
"status": "ok", "status": "ok",
"database": dbStatus, "database": dbStatus,
"contentStore": contentStatus, "contentStore": contentStatus,
}) }
if !ready {
payload["status"] = "unavailable"
writeJSONWithStatus(w, http.StatusServiceUnavailable, payload)
return
}
writeJSON(w, http.StatusOK, payload)
} }
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) { func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
@@ -515,7 +540,13 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content) _, _ = w.Write(content)
} }
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) { func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, status int, name string, data layoutData) {
if data.Principal == nil {
if principal, ok := principalFromContext(r.Context()); ok {
p := principal
data.Principal = &p
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status) w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil { if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
@@ -524,7 +555,7 @@ func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string,
} }
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) { func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderTemplate(w, status, "error.gohtml", layoutData{ s.renderTemplate(w, r, status, "error.gohtml", layoutData{
Title: http.StatusText(status), Title: http.StatusText(status),
BodyClass: "page-error", BodyClass: "page-error",
BodyTemplate: "error_content", BodyTemplate: "error_content",
@@ -649,8 +680,30 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"documents": results}) writeJSON(w, http.StatusOK, map[string]any{"documents": results})
} }
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*") pagePath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid document path"})
return
}
switch {
case strings.HasSuffix(pagePath, "/archive"):
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
case strings.HasSuffix(pagePath, "/restore"):
s.handleDocumentRestorePath(w, r, strings.TrimSuffix(pagePath, "/restore"))
default:
s.handleDocumentSavePath(w, r, pagePath)
}
}
func unescapeDocumentRoutePath(path string) (string, error) {
if path == "" {
return "", nil
}
return url.PathUnescape(path)
}
func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" { if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return return
@@ -705,6 +758,50 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload) _ = json.NewEncoder(w).Encode(payload)
} }
func (s *Server) canWriteDocuments(r *http.Request) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
return auth.Allows(principal, auth.ScopeDocsWrite)
}
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
}
func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
}
func trimEditSuffix(path string) (string, bool) { func trimEditSuffix(path string) (string, bool) {
if path == "edit" { if path == "edit" {
return "", true return "", true

View File

@@ -414,3 +414,135 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
request.Header.Set("Content-Type", writer.FormDataContentType()) request.Header.Set("Content-Type", writer.FormDataContentType())
return request return request
} }
func TestHandleHealthzReturnsOK(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleHealthz(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
}
func TestHandleReadyzReportsOKWhenDatabaseAndStoreAreReachable(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
if payload["database"] != "ok" {
t.Fatalf("database = %q, want ok", payload["database"])
}
if payload["contentStore"] != "ok" {
t.Fatalf("contentStore = %q, want ok", payload["contentStore"])
}
}
func TestHandleReadyzReportsUnavailableWhenContentStoreMissing(t *testing.T) {
server := setupHealthTestServer(t)
server.config.Content.StoreDir = filepath.Join(t.TempDir(), "does-not-exist")
recorder := httptest.NewRecorder()
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "unavailable" {
t.Fatalf("status = %q, want unavailable", payload["status"])
}
if payload["contentStore"] == "ok" {
t.Fatalf("contentStore = %q, want non-ok", payload["contentStore"])
}
}
func TestHandleHealthAliasForReadyz(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleHealth(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
}
func TestHealthzAndReadyzAreRegisteredOnRouter(t *testing.T) {
server := newAPITestServer(t)
for _, path := range []string{"/healthz", "/readyz", "/health"} {
t.Run(path, func(t *testing.T) {
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
})
}
}
func setupHealthTestServer(t *testing.T) *Server {
t.Helper()
root := t.TempDir()
storeDir := filepath.Join(root, "files")
if err := os.MkdirAll(storeDir, 0o755); err != nil {
t.Fatalf("create store dir: %v", err)
}
db, err := sql.Open("libsql", "file:"+filepath.Join(root, "test.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
repository := docs.NewRepository(db)
documents := docs.NewService(t.TempDir(), contentStore, markdown.NewRenderer(), repository, slog.Default())
return &Server{
config: config.Config{
Content: config.ContentConfig{StoreDir: storeDir},
},
documents: documents,
repository: repository,
contentStore: contentStore,
}
}

View File

@@ -13,6 +13,8 @@ import (
"github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/auth"
) )
const devLogoutCookieName = "cairnquire_dev_logout"
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler { func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
timeoutMiddleware := middleware.Timeout(timeout) timeoutMiddleware := middleware.Timeout(timeout)
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
@@ -99,6 +101,8 @@ func isSetupAllowedPath(path string) bool {
return path == "/setup" || return path == "/setup" ||
path == "/api/setup" || path == "/api/setup" ||
path == "/health" || path == "/health" ||
path == "/healthz" ||
path == "/readyz" ||
path == "/sw.js" || path == "/sw.js" ||
strings.HasPrefix(path, "/static/") strings.HasPrefix(path, "/static/")
} }
@@ -125,7 +129,7 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
} }
cookie, err := r.Cookie(auth.SessionCookieName) cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
if s.devUserID == "" { if s.devUserID == "" || hasDevLogoutCookie(r) {
return auth.Principal{}, false return auth.Principal{}, false
} }
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID) principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
@@ -138,6 +142,11 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
return principal, true return principal, true
} }
func hasDevLogoutCookie(r *http.Request) bool {
cookie, err := r.Cookie(devLogoutCookieName)
return err == nil && cookie.Value == "1"
}
func requiredScopeForPath(r *http.Request) (auth.Scope, bool) { func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
path := r.URL.Path path := r.URL.Path
method := r.Method method := r.Method

View File

@@ -104,15 +104,21 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/", server.handleIndex) router.Get("/", server.handleIndex)
router.Get("/setup", server.handleSetupPage) router.Get("/setup", server.handleSetupPage)
router.Get("/login", server.handleLoginPage) router.Get("/login", server.handleLoginPage)
router.Get("/password-reset", server.handlePasswordResetPage)
router.Get("/account", server.handleAccountPage) router.Get("/account", server.handleAccountPage)
router.Get("/account/tokens/new", server.handleTokenCreatePage)
router.Get("/device/verify", server.handleDeviceVerifyPage) router.Get("/device/verify", server.handleDeviceVerifyPage)
router.Get("/health", server.handleHealth) router.Get("/health", server.handleHealth)
router.Get("/healthz", server.handleHealthz)
router.Get("/readyz", server.handleReadyz)
router.Get("/sw.js", server.handleServiceWorker) router.Get("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket) router.Get("/ws", server.handleWebSocket)
router.Get("/api/auth/me", server.handleAuthMe) router.Get("/api/auth/me", server.handleAuthMe)
router.Post("/api/setup", server.handleSetup) router.Post("/api/setup", server.handleSetup)
router.Post("/api/auth/register/password", server.handlePasswordRegister) router.Post("/api/auth/register/password", server.handlePasswordRegister)
router.Post("/api/auth/login/password", server.handlePasswordLogin) router.Post("/api/auth/login/password", server.handlePasswordLogin)
router.Post("/api/auth/login/dev", server.handleDevLogin)
router.Post("/api/auth/password/reset", server.handlePasswordReset)
router.Post("/api/auth/logout", server.handleLogout) router.Post("/api/auth/logout", server.handleLogout)
router.Post("/api/auth/password/change", server.handleChangePassword) router.Post("/api/auth/password/change", server.handleChangePassword)
router.Delete("/api/auth/account", server.handleDeleteAccount) router.Delete("/api/auth/account", server.handleDeleteAccount)
@@ -132,6 +138,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/admin/users", server.handleAdminCreateUser) router.Post("/api/admin/users", server.handleAdminCreateUser)
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole) router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers) router.Get("/api/admin/auth-users", server.handleAdminAuthUsers)
router.Patch("/api/admin/auth-users", server.handleAdminUpdateAuthUsers)
router.Post("/api/admin/auth-users/{id}/password-reset", server.handleAdminSendPasswordReset)
router.Get("/api/admin/settings", server.handleAdminSettings) router.Get("/api/admin/settings", server.handleAdminSettings)
router.Patch("/api/admin/settings", server.handleAdminSettingsUpdate) router.Patch("/api/admin/settings", server.handleAdminSettingsUpdate)
router.Get("/api/admin/workspace", server.handleAdminWorkspace) router.Get("/api/admin/workspace", server.handleAdminWorkspace)
@@ -139,7 +147,7 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/docs", server.handleDocsIndex) router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument) router.Get("/docs/*", server.handleDocument)
router.Get("/api/documents", server.handleDocuments) router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentSave) router.Post("/api/documents/*", server.handleDocumentMutation)
router.Get("/api/search", server.handleSearch) router.Get("/api/search", server.handleSearch)
router.Post("/api/uploads", server.handleUpload) router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList) router.Get("/api/attachments", server.handleAttachmentsList)

View File

@@ -0,0 +1,45 @@
(function () {
'use strict';
function apiDocumentPath(path) {
return (path || '')
.replace(/^\/+/, '')
.split('/')
.map(encodeURIComponent)
.join('/');
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-archive-path]');
if (!btn) return;
var path = btn.getAttribute('data-archive-path');
if (!path) return;
if (!confirm('Archive this document? It will be hidden from the site but can be restored later.')) {
return;
}
btn.disabled = true;
btn.textContent = 'Archiving...';
fetch('/api/documents/' + apiDocumentPath(path) + '/archive', {
method: 'POST',
credentials: 'same-origin',
})
.then(function (res) {
if (res.ok) {
window.location.href = '/';
return;
}
return res.json().then(function (data) {
throw new Error(data.error || 'Archive failed');
});
})
.catch(function (err) {
alert(err.message || 'Failed to archive document');
btn.disabled = false;
btn.textContent = 'Archive';
});
});
})();

View File

@@ -34,10 +34,14 @@
const loginSection = document.querySelector('[data-login-section]'); const loginSection = document.querySelector('[data-login-section]');
const registerCancel = document.querySelector('[data-register-cancel]'); const registerCancel = document.querySelector('[data-register-cancel]');
const authHeading = document.querySelector('[data-auth-heading]');
if (registerToggle && registerPanel && loginSection) { if (registerToggle && registerPanel && loginSection) {
registerToggle.addEventListener('click', () => { registerToggle.addEventListener('click', () => {
loginSection.hidden = true; loginSection.hidden = true;
registerPanel.hidden = false; registerPanel.hidden = false;
if (authHeading) authHeading.textContent = 'Create an Account';
document.title = 'Create an Account';
}); });
} }
@@ -45,6 +49,8 @@
registerCancel.addEventListener('click', () => { registerCancel.addEventListener('click', () => {
registerPanel.hidden = true; registerPanel.hidden = true;
loginSection.hidden = false; loginSection.hidden = false;
if (authHeading) authHeading.textContent = 'Log In';
document.title = 'Sign in';
// Clear any registration form status messages // Clear any registration form status messages
document.querySelector('[data-passkey-register-status]').textContent = ''; document.querySelector('[data-passkey-register-status]').textContent = '';
document.querySelector('[data-register-status]').textContent = ''; document.querySelector('[data-register-status]').textContent = '';
@@ -152,6 +158,15 @@
} }
}); });
document.querySelector("[data-dev-login]")?.addEventListener("click", async () => {
try {
await postJSON("/api/auth/login/dev", {});
window.location.href = authNext();
} catch (error) {
setStatus("[data-dev-login-status]", error.message, true);
}
});
document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => { document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
try { try {
@@ -237,19 +252,37 @@
}); });
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => { document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
await postJSON("/api/auth/logout", {}); try {
window.location.href = "/login"; await postJSON("/api/auth/logout", {});
} catch (error) {
console.warn("Logout request failed:", error);
} finally {
window.location.href = "/login";
}
}); });
const loadTokens = async () => { const loadTokens = async () => {
const list = document.querySelector("[data-token-list]"); const list = document.querySelector("[data-token-list]");
if (!list) return; if (!list) return;
const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json()); const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json());
if (!data.tokens?.length) {
const empty = document.createElement("li");
empty.className = "token-empty";
empty.textContent = "No access tokens yet. Create one when you connect the macOS app, CLI, or another trusted client.";
list.replaceChildren(empty);
return;
}
list.replaceChildren( list.replaceChildren(
...(data.tokens || []).map((token) => { ...data.tokens.map((token) => {
const item = document.createElement("li"); const item = document.createElement("li");
item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`;
const meta = document.createElement("span"); const meta = document.createElement("span");
meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`; meta.className = "token-meta";
const name = document.createElement("strong");
name.textContent = token.name;
const details = document.createElement("small");
details.textContent = `${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
meta.append(name, details);
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.textContent = "Revoke"; button.textContent = "Revoke";
@@ -297,6 +330,17 @@
} }
}); });
document.querySelector("[data-password-reset]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postJSON("/api/auth/password/reset", formJSON(event.currentTarget));
setStatus("[data-password-reset-status]", "Password reset. You can sign in with your new password.");
event.currentTarget.querySelector('input[name="newPassword"]').value = "";
} catch (error) {
setStatus("[data-password-reset-status]", error.message, true);
}
});
document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => { document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
if (!confirm("Delete this account permanently?")) return; if (!confirm("Delete this account permanently?")) return;
@@ -324,7 +368,7 @@
const loadAdminUsers = async () => { const loadAdminUsers = async () => {
const section = document.querySelector("[data-admin-users-section]"); const section = document.querySelector("[data-admin-users-section]");
const list = document.querySelector("[data-admin-users]"); const list = document.querySelector("[data-admin-user-list]");
if (!section || !list) return; if (!section || !list) return;
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null); const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
if (me?.principal?.role !== "admin") return; if (me?.principal?.role !== "admin") return;
@@ -332,8 +376,9 @@
const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json()); const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json());
list.replaceChildren( list.replaceChildren(
...(data.users || []).map((user) => { ...(data.users || []).map((user) => {
const row = document.createElement("form"); const row = document.createElement("div");
row.className = "user-row"; row.className = "user-row";
row.dataset.userId = user.id;
const identity = document.createElement("span"); const identity = document.createElement("span");
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = user.displayName; name.textContent = user.displayName;
@@ -348,24 +393,52 @@
option.selected = user.role === role; option.selected = user.role === role;
select.append(option); select.append(option);
} }
const button = document.createElement("button"); select.name = "role";
button.type = "submit"; select.setAttribute("aria-label", `Role for ${user.displayName}`);
button.textContent = "Save"; const disabledLabel = document.createElement("label");
row.append(identity, select, button); disabledLabel.className = "user-disable-toggle";
row.addEventListener("submit", async (event) => { const disabled = document.createElement("input");
event.preventDefault(); disabled.type = "checkbox";
disabled.name = "disabled";
disabled.checked = Boolean(user.disabled);
const disabledText = document.createElement("span");
disabledText.textContent = "Disabled";
disabledLabel.append(disabled, disabledText);
const reset = document.createElement("button");
reset.type = "button";
reset.textContent = "Send password reset";
reset.addEventListener("click", async () => {
if (!confirm(`Send a new password reset email to ${user.email}? Any earlier reset link will stop working.`)) return;
try { try {
await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" }); await postJSON(`/api/admin/auth-users/${encodeURIComponent(user.id)}/password-reset`, {});
setStatus("[data-admin-users-status]", "Role updated."); setStatus("[data-admin-users-status]", `Password reset email sent to ${user.email}.`);
} catch (error) { } catch (error) {
setStatus("[data-admin-users-status]", error.message, true); setStatus("[data-admin-users-status]", error.message, true);
} }
}); });
row.append(identity, select, disabledLabel, reset);
return row; return row;
}), }),
); );
}; };
document.querySelector("[data-admin-users]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const users = [...form.querySelectorAll("[data-user-id]")].map((row) => ({
id: row.dataset.userId,
role: row.querySelector('select[name="role"]').value,
disabled: row.querySelector('input[name="disabled"]').checked,
}));
try {
await postJSON("/api/admin/auth-users", { users }, { method: "PATCH" });
setStatus("[data-admin-users-status]", "User access settings saved.");
await loadAdminUsers();
} catch (error) {
setStatus("[data-admin-users-status]", error.message, true);
}
});
const loadAdminSettings = async () => { const loadAdminSettings = async () => {
const section = document.querySelector("[data-admin-settings-section]"); const section = document.querySelector("[data-admin-settings-section]");
const form = document.querySelector("[data-admin-settings]"); const form = document.querySelector("[data-admin-settings]");

View File

@@ -4,20 +4,28 @@
const bell = document.querySelector('[data-notification-bell]'); const bell = document.querySelector('[data-notification-bell]');
if (!bell) return; if (!bell) return;
const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]'); const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]'); const dropdown = bell.querySelector('[data-notification-dropdown]');
let isOpen = false; let isOpen = false;
bell.addEventListener('click', () => { trigger.addEventListener('click', (e) => {
e.stopPropagation();
isOpen = !isOpen; isOpen = !isOpen;
dropdown.hidden = !isOpen; dropdown.hidden = !isOpen;
trigger.setAttribute('aria-expanded', String(isOpen));
if (isOpen) loadNotifications(); if (isOpen) loadNotifications();
}); });
document.addEventListener('click', (e) => { dropdown.addEventListener('click', (e) => {
if (!bell.contains(e.target)) { e.stopPropagation();
});
document.addEventListener('click', () => {
if (isOpen) {
isOpen = false; isOpen = false;
dropdown.hidden = true; dropdown.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
} }
}); });
@@ -51,8 +59,17 @@
if (!list) return; if (!list) return;
list.innerHTML = ''; list.innerHTML = '';
// Header
const header = document.createElement('li');
header.className = 'notification-header';
header.textContent = 'Notifications';
list.appendChild(header);
if (!notifications.length) { if (!notifications.length) {
list.innerHTML = '<li class="notification-empty">No notifications</li>'; const empty = document.createElement('li');
empty.className = 'notification-empty';
empty.textContent = 'No notifications';
list.appendChild(empty);
return; return;
} }

View File

@@ -129,6 +129,143 @@ code {
display: block; display: block;
} }
/* Notification bell */
.notification-bell {
position: relative;
}
.notification-bell__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
transition: background 0.15s;
}
.notification-bell__trigger:hover {
background: rgba(0, 0, 0, 0.06);
}
.notification-bell__trigger svg {
display: block;
}
.notification-bell__count {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #dc2626;
color: white;
font-size: 0.65rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.notification-bell__dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 20;
width: 320px;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0;
background: var(--panel-strong);
box-shadow: var(--shadow);
}
.notification-bell__dropdown[hidden] {
display: none;
}
.notification-list {
margin: 0;
padding: 0;
list-style: none;
}
.notification-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.1s;
}
.notification-item:hover {
background: var(--accent-soft);
}
.notification-item.is-read {
opacity: 0.7;
cursor: default;
}
.notification-message {
font-size: 0.9rem;
line-height: 1.4;
color: var(--text);
}
.notification-time {
display: block;
margin-top: 0.25rem;
font-size: 0.78rem;
color: var(--muted);
}
.notification-header {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.notification-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--muted);
font-size: 0.9rem;
}
.notification-footer {
padding: 0.5rem 1rem;
text-align: center;
}
.notification-footer button {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.notification-footer button:hover {
color: var(--text);
border-color: var(--muted);
background: var(--panel);
}
.site-search { .site-search {
position: relative; position: relative;
display: flex; display: flex;
@@ -150,13 +287,18 @@ code {
width: 100%; width: 100%;
padding: 0.4rem 0.75rem 0.4rem 2rem; padding: 0.4rem 0.75rem 0.4rem 2rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 999px; border-radius: 0;
background: rgba(255, 255, 255, 0.6); background: var(--panel);
color: var(--text);
font: inherit; font: inherit;
font-size: 0.825rem; font-size: 0.825rem;
transition: border-color 0.15s, background 0.15s; transition: border-color 0.15s, background 0.15s;
} }
.site-search input::placeholder {
color: var(--muted);
}
.site-search input:focus { .site-search input:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
@@ -376,6 +518,16 @@ code {
display: block; display: block;
} }
.auth-dev-login {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.auth-dev-login .btn-secondary {
width: 100%;
}
/* Register CTA */ /* Register CTA */
.auth-register-cta { .auth-register-cta {
margin-top: 1.5rem; margin-top: 1.5rem;
@@ -456,31 +608,282 @@ code {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
width: min(100%, 1120px); width: min(100%, 1180px);
margin: 0 auto 1rem;
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel);
box-shadow: var(--shadow);
} }
.account-header p { .account-identity {
margin: 0.35rem 0 0; display: flex;
min-width: 0;
align-items: center;
gap: 0.9rem;
}
.account-avatar {
display: inline-flex;
width: 3.4rem;
height: 3.4rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border));
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
}
.account-header h1 {
margin-top: 0.15rem;
font-size: clamp(1.7rem, 3vw, 2.4rem);
}
.account-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
margin-top: 0.45rem;
color: var(--muted); color: var(--muted);
font-size: 0.9rem;
} }
.account-grid { .account-role {
width: min(100%, 1120px); padding: 0.18rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace;
text-transform: uppercase;
}
.account-header .account-signout {
display: inline-flex;
min-height: 2.45rem;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.75rem;
border-color: var(--border);
background: var(--panel-strong);
color: var(--text);
box-shadow: none;
font-size: 0.9rem;
}
.account-header .account-signout:hover {
border-color: var(--muted);
background: var(--accent-soft);
}
.account-layout {
display: grid;
width: min(100%, 1180px);
margin: 0 auto;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
gap: 1rem;
align-items: start;
}
.account-main,
.account-sidebar {
display: grid;
gap: 1rem;
} }
.account-section { .account-section {
padding: 1rem; width: 100%;
padding: clamp(1rem, 2vw, 1.35rem);
border-radius: var(--radius-md);
}
.account-section__header {
display: grid;
gap: 0.45rem;
margin-bottom: 1rem;
}
.account-section__header--action {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
}
.account-section__header h2,
.account-list-heading h3 {
margin: 0;
}
.account-section__header h2 {
font-size: 1.2rem;
}
.account-section__header p,
.account-list-heading p {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.account-section__kicker {
margin: 0 0 0.25rem !important;
color: var(--accent) !important;
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace !important;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.account-section__divider {
height: 1px;
margin: 1.25rem 0 1rem;
background: var(--border);
}
.account-list-heading {
display: grid;
gap: 0.25rem;
}
.account-list-heading h3 {
font-size: 1rem;
}
.account-action,
.account-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
}
.account-action {
min-height: 2.5rem;
padding: 0.6rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
border-radius: var(--radius-sm);
background: var(--accent);
color: white;
font-weight: 600;
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
.account-action:hover {
background: color-mix(in srgb, var(--accent) 85%, black);
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 40%, transparent);
}
.account-action:active {
transform: translateY(1px);
box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 30%, transparent);
}
.account-page-header,
.token-create-layout {
width: min(100%, 920px);
margin-right: auto;
margin-left: auto;
}
.account-page-header {
margin-bottom: 1rem;
padding: clamp(1rem, 2vw, 1.35rem);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel);
box-shadow: var(--shadow);
}
.account-page-header h1 {
margin: 0.35rem 0 0;
font-size: clamp(1.8rem, 3vw, 2.5rem);
}
.account-page-header p:last-child {
margin: 0.5rem 0 0;
color: var(--muted);
}
.account-back {
width: fit-content;
margin-bottom: 1.1rem;
color: var(--muted);
font-size: 0.9rem;
}
.account-back:hover {
color: var(--accent);
}
.token-create-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(15rem, 18rem);
gap: 1rem;
align-items: start;
}
.token-create-note h2 {
margin: 0;
font-size: 1.05rem;
}
.token-create-note p:last-child {
margin: 0.6rem 0 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
}
.account-shell .auth-form button[type="submit"] {
min-height: 2.5rem;
padding: 0.6rem 0.85rem;
font-size: 0.95rem;
}
.account-form-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.account-form-footer .form-status {
flex: 1 1 14rem;
} }
.account-section--danger { .account-section--danger {
border-color: color-mix(in srgb, #b42318 40%, var(--border)); border-color: color-mix(in srgb, #b42318 40%, var(--border));
background: color-mix(in srgb, #b42318 4%, var(--panel));
}
.account-section--danger .account-section__kicker {
color: #b42318 !important;
}
.account-shell .account-danger-button[type="submit"] {
border-color: color-mix(in srgb, #b42318 70%, var(--border));
background: #b42318;
box-shadow: 0 4px 12px color-mix(in srgb, #b42318 22%, transparent);
}
.account-shell .account-danger-button[type="submit"]:hover {
background: #8f1c13;
} }
.scope-grid { .scope-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
margin: 0; margin: 0;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 3%, var(--panel-strong));
} }
.scope-grid legend { .scope-grid legend {
@@ -494,11 +897,47 @@ code {
gap: 0.4rem; gap: 0.4rem;
} }
.scope-grid input {
width: auto;
}
.auth-form .auth-choice.account-toggle {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong);
}
.auth-form .account-toggle input {
width: auto;
margin: 0.1rem 0 0;
}
.account-toggle span {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.account-toggle strong {
color: var(--text);
}
.account-toggle small {
color: var(--muted);
line-height: 1.35;
}
.token-output { .token-output {
max-width: 100%; max-width: 100%;
overflow: auto; overflow: auto;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong); background: var(--panel-strong);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -521,14 +960,40 @@ code {
align-items: center; align-items: center;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong); background: var(--panel-strong);
} }
.user-row { .token-list {
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto; margin-top: 0.75rem;
} }
.user-row span { .token-meta {
display: grid;
gap: 0.18rem;
}
.token-meta small {
color: var(--muted);
line-height: 1.35;
}
.token-row.is-revoked {
opacity: 0.64;
}
.token-empty {
display: block !important;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.user-row {
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto auto;
}
.user-row > span {
display: grid; display: grid;
gap: 0.15rem; gap: 0.15rem;
} }
@@ -537,6 +1002,84 @@ code {
color: var(--muted); color: var(--muted);
} }
.user-management {
display: grid;
gap: 1rem;
}
.user-management .account-form-footer {
margin-top: 0.25rem;
}
.user-management button[type="submit"] {
min-height: 2.5rem;
padding: 0.6rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
border-radius: var(--radius-sm);
background: var(--accent);
color: white;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.user-disable-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.user-disable-toggle input {
width: auto;
}
@media (max-width: 880px) {
.account-layout {
grid-template-columns: 1fr;
}
.token-create-layout {
grid-template-columns: 1fr;
}
.account-sidebar {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
align-items: start;
}
}
@media (max-width: 560px) {
.account-shell {
padding: 0.75rem;
}
.account-header {
align-items: flex-start;
padding: 0.9rem;
}
.account-avatar {
width: 2.8rem;
height: 2.8rem;
}
.account-header .account-signout {
min-height: 2.2rem;
padding: 0.45rem;
}
.account-signout svg {
display: none;
}
.user-row {
grid-template-columns: 1fr;
}
}
/* Workspace layout - adaptive grid */ /* Workspace layout - adaptive grid */
.workspace-shell { .workspace-shell {
display: grid; display: grid;
@@ -1166,6 +1709,12 @@ code {
align-items: end; align-items: end;
} }
.document-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.document-edit-link { .document-edit-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1178,6 +1727,25 @@ code {
background: var(--panel); background: var(--panel);
} }
.document-archive-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.4rem;
padding: 0 0.9rem;
border: 1px solid color-mix(in srgb, #b42318 40%, var(--border));
background: transparent;
color: #b42318;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.document-archive-btn:hover {
background: color-mix(in srgb, #b42318 8%, transparent);
}
.document-shell--editor { .document-shell--editor {
display: grid; display: grid;
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
@@ -1595,6 +2163,10 @@ code {
background: oklch(0.16 0.014 55 / 0.92); background: oklch(0.16 0.014 55 / 0.92);
} }
.notification-bell__trigger:hover {
background: rgba(255, 255, 255, 0.08);
}
.miller-column h2 { .miller-column h2 {
background: oklch(0.20 0.015 55 / 0.72); background: oklch(0.20 0.015 55 / 0.72);
} }
@@ -1826,4 +2398,3 @@ code {
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
} }

View File

@@ -3,82 +3,130 @@
{{ define "account_content" }} {{ define "account_content" }}
<section class="account-shell"> <section class="account-shell">
<header class="account-header"> <header class="account-header">
<div> <div class="account-identity">
<p class="eyebrow">Account</p> <span class="account-avatar" aria-hidden="true">
<h1>{{ .DisplayName }}</h1> <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<p>{{ .Email }} &middot; {{ .Role }}</p> <circle cx="12" cy="8" r="3.5" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
</svg>
</span>
<div>
<p class="eyebrow">Account center</p>
<h1>{{ .DisplayName }}</h1>
<div class="account-meta">
<span>{{ .Email }}</span>
<span class="account-role">{{ .Role }}</span>
</div>
</div>
</div> </div>
<button type="button" data-auth-logout>Sign out</button> <button class="account-signout" type="button" data-auth-logout>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10 17l5-5-5-5" />
<path d="M15 12H3" />
<path d="M21 19V5a2 2 0 0 0-2-2h-6" />
</svg>
Sign out
</button>
</header> </header>
<div class="account-grid"> <div class="account-layout">
<section class="account-section"> <div class="account-main">
<h2>API Tokens</h2> <section class="account-section account-section--tokens">
<form class="auth-form" data-token-create> <header class="account-section__header account-section__header--action">
<label> <div>
Name <p class="account-section__kicker">Integrations</p>
<input type="text" name="name" placeholder="CLI on laptop" required /> <h2>Access tokens</h2>
</label> <p>Manage access for the macOS app, CLI, or another trusted client.</p>
<fieldset class="scope-grid"> </div>
<legend>Scopes</legend> <a class="account-action" href="/account/tokens/new">Create token</a>
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label> </header>
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label> <div class="account-list-heading">
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label> <h3>Issued tokens</h3>
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label> <p>Tokens can be revoked immediately. New secrets are shown only once.</p>
<label><input type="checkbox" name="scope" value="admin" /> Admin</label> </div>
</fieldset> <ul class="token-list" data-token-list></ul>
<button type="submit">Create token</button> </section>
<p class="form-status" data-token-status></p>
<pre class="token-output" data-token-output hidden></pre>
</form>
<ul class="token-list" data-token-list></ul>
</section>
<section class="account-section"> <section class="account-section account-section--users" data-admin-users-section hidden>
<h2>Password</h2> <header class="account-section__header">
<form class="auth-form" data-password-change> <div>
<label> <p class="account-section__kicker">Administration</p>
Current password <h2>People and roles</h2>
<input type="password" name="currentPassword" autocomplete="current-password" /> </div>
</label> <p>Control access levels for everyone with an account on this server.</p>
<label> </header>
New password <form class="user-management" data-admin-users>
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required /> <div class="user-list" data-admin-user-list></div>
</label> <div class="account-form-footer">
<button type="submit">Change password</button> <button type="submit">Save changes</button>
<p class="form-status" data-password-status></p> <p class="form-status" data-admin-users-status></p>
</form> </div>
</section> </form>
</section>
</div>
<section class="account-section" data-admin-users-section hidden> <aside class="account-sidebar">
<h2>Users</h2> <section class="account-section">
<div class="user-list" data-admin-users></div> <header class="account-section__header">
<p class="form-status" data-admin-users-status></p> <div>
</section> <p class="account-section__kicker">Security</p>
<h2>Change password</h2>
</div>
<p>Use at least 12 characters for your new password.</p>
</header>
<form class="auth-form" data-password-change>
<label>
Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<label>
New password
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit">Update password</button>
<p class="form-status" data-password-status></p>
</form>
</section>
<section class="account-section" data-admin-settings-section hidden> <section class="account-section" data-admin-settings-section hidden>
<h2>Registration</h2> <header class="account-section__header">
<form class="auth-form" data-admin-settings> <div>
<label class="auth-choice"> <p class="account-section__kicker">Administration</p>
<input type="checkbox" name="signupsEnabled" /> <h2>Registration</h2>
Allow visitors to create accounts </div>
</label> <p>Choose whether new visitors can create their own accounts.</p>
<button type="submit">Save registration setting</button> </header>
<p class="form-status" data-admin-settings-status></p> <form class="auth-form" data-admin-settings>
</form> <label class="auth-choice account-toggle">
</section> <input type="checkbox" name="signupsEnabled" />
<span>
<strong>Public signups</strong>
<small>Allow visitors to register without an invitation.</small>
</span>
</label>
<button type="submit">Save setting</button>
<p class="form-status" data-admin-settings-status></p>
</form>
</section>
<section class="account-section account-section--danger"> <section class="account-section account-section--danger">
<h2>Delete Account</h2> <header class="account-section__header">
<form class="auth-form" data-account-delete> <div>
<label> <p class="account-section__kicker">Danger zone</p>
Current password <h2>Delete account</h2>
<input type="password" name="currentPassword" autocomplete="current-password" /> </div>
</label> <p>This permanently removes your account and cannot be undone.</p>
<button type="submit">Delete account</button> </header>
<p class="form-status" data-delete-status></p> <form class="auth-form" data-account-delete>
</form> <label>
</section> Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<button class="account-danger-button" type="submit">Delete account</button>
<p class="form-status" data-delete-status></p>
</form>
</section>
</aside>
</div> </div>
</section> </section>
{{ end }} {{ end }}

View File

@@ -27,6 +27,20 @@
<input type="search" name="q" placeholder="Search documents…" aria-label="Search documents" /> <input type="search" name="q" placeholder="Search documents…" aria-label="Search documents" />
</form> </form>
<nav class="site-nav"> <nav class="site-nav">
{{ if .Principal }}
<div class="notification-bell" data-notification-bell>
<button class="notification-bell__trigger" type="button" aria-label="Notifications" aria-haspopup="true" aria-expanded="false">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
<span class="notification-bell__count" data-unread-count hidden>0</span>
</button>
<div class="notification-bell__dropdown" data-notification-dropdown role="menu" hidden>
<ul class="notification-list" aria-live="polite"></ul>
</div>
</div>
{{ end }}
<a href="/help" aria-label="Help" title="Help"> <a href="/help" aria-label="Help" title="Help">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</a> </a>
@@ -52,8 +66,12 @@
{{ template "login_content" .Data }} {{ template "login_content" .Data }}
{{ else if eq .BodyTemplate "setup_content" }} {{ else if eq .BodyTemplate "setup_content" }}
{{ template "setup_content" .Data }} {{ template "setup_content" .Data }}
{{ else if eq .BodyTemplate "password_reset_content" }}
{{ template "password_reset_content" .Data }}
{{ else if eq .BodyTemplate "account_content" }} {{ else if eq .BodyTemplate "account_content" }}
{{ template "account_content" .Data }} {{ template "account_content" .Data }}
{{ else if eq .BodyTemplate "token_create_content" }}
{{ template "token_create_content" .Data }}
{{ else if eq .BodyTemplate "device_verify_content" }} {{ else if eq .BodyTemplate "device_verify_content" }}
{{ template "device_verify_content" .Data }} {{ template "device_verify_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }} {{ else if eq .BodyTemplate "error_content" }}
@@ -79,7 +97,7 @@
<ul data-sync-queue-list></ul> <ul data-sync-queue-list></ul>
</section> </section>
<div class="version-notice" data-version-notice hidden> <div class="version-notice" data-version-notice hidden>
<p>A newer version is available.</p> <p>Document changes are available.</p>
<button type="button" data-version-reload>Reload</button> <button type="button" data-version-reload>Reload</button>
</div> </div>
<script src="/static/cache.js" defer></script> <script src="/static/cache.js" defer></script>
@@ -89,6 +107,7 @@
<script src="/static/auth.js" defer></script> <script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script> <script src="/static/render.js" defer></script>
<script src="/static/comments.js" defer></script> <script src="/static/comments.js" defer></script>
<script src="/static/archive.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -21,7 +21,12 @@
<div class="document-meta"> <div class="document-meta">
<div class="document-meta__header"> <div class="document-meta__header">
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a> {{ if .CanWrite }}
<div class="document-actions">
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
</div>
{{ end }}
</div> </div>
<details class="document-meta-panel"> <details class="document-meta-panel">
<summary> <summary>

View File

@@ -6,7 +6,7 @@
<div class="auth-panel"> <div class="auth-panel">
<div class="auth-panel__header"> <div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p> <p class="eyebrow">Cairnquire</p>
<h1>Log In</h1> <h1 data-auth-heading>Log In</h1>
</div> </div>
<div class="auth-login-section" data-login-section> <div class="auth-login-section" data-login-section>
@@ -43,6 +43,12 @@
</div> </div>
</div> </div>
{{ if .DevMode }}<div class="auth-dev-login">
<p class="auth-note">Development mode is enabled. Use the local development admin without a password.</p>
<button type="button" class="btn-secondary" data-dev-login>Continue as development admin</button>
<p class="form-status" data-dev-login-status></p>
</div>{{ end }}
{{ if .SignupsEnabled }}<div class="auth-register-cta"> {{ if .SignupsEnabled }}<div class="auth-register-cta">
<button type="button" class="btn-cta" data-register-toggle>Create an Account</button> <button type="button" class="btn-cta" data-register-toggle>Create an Account</button>
</div>{{ end }} </div>{{ end }}

View File

@@ -0,0 +1,23 @@
{{ define "password_reset.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "password_reset_content" }}
<section class="auth-shell">
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p>
<h1>Reset password</h1>
<p class="auth-note">Choose a new password with at least 12 characters.</p>
</div>
<form class="auth-form" data-password-reset>
<input type="hidden" name="token" value="{{ .Token }}" />
<label>
New password
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit" class="btn-primary">Set new password</button>
<p class="form-status" data-password-reset-status></p>
</form>
</div>
</section>
{{ end }}

View File

@@ -0,0 +1,49 @@
{{ define "token_create.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "token_create_content" }}
<section class="account-shell">
<header class="account-page-header">
<a class="account-back" href="/account">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 12H5" />
<path d="M12 19l-7-7 7-7" />
</svg>
Back to account
</a>
<p class="account-section__kicker">Integrations</p>
<h1>Create access token</h1>
<p>Give a trusted client the smallest set of permissions it needs.</p>
</header>
<div class="token-create-layout">
<section class="account-section">
<form class="auth-form token-create-form" data-token-create>
<label>
Token name
<input type="text" name="name" placeholder="MacBook sync app" required />
</label>
<fieldset class="scope-grid">
<legend>Permissions</legend>
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
</fieldset>
<div class="account-form-footer">
<button type="submit">Create token</button>
<a class="btn-secondary" href="/account">Cancel</a>
</div>
<p class="form-status" data-token-status></p>
<pre class="token-output" data-token-output hidden></pre>
</form>
</section>
<aside class="account-section token-create-note">
<p class="account-section__kicker">Before you create</p>
<h2>Keep the secret somewhere safe</h2>
<p>The token is shown once after creation. Store it in the client that needs access, then return to your account to revoke it later.</p>
</aside>
</div>
</section>
{{ end }}

View File

@@ -5,6 +5,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "8080:8080" - "8080:8080"
depends_on:
- mailpit
environment: environment:
CAIRNQUIRE_SERVER_ADDR: ":8080" CAIRNQUIRE_SERVER_ADDR: ":8080"
CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite" CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
@@ -29,6 +31,7 @@ services:
image: axllent/mailpit:latest image: axllent/mailpit:latest
ports: ports:
- "8025:8025" - "8025:8025"
- "1025:1025"
environment: environment:
MP_UI_BIND_ADDR: "0.0.0.0:8025" MP_UI_BIND_ADDR: "0.0.0.0:8025"
MP_SMTP_BIND_ADDR: "0.0.0.0:1025" MP_SMTP_BIND_ADDR: "0.0.0.0:1025"