diff --git a/.project/STATUS.md b/.project/STATUS.md index 6f340d5..b78a425 100644 --- a/.project/STATUS.md +++ b/.project/STATUS.md @@ -1,36 +1,34 @@ # Project Status Report -**Generated:** 2026-04-29 14:57:37 UTC -**Monitoring Duration:** 14h 41m -**Iteration:** 86 -**Branch:** codex/foundation-bootstrap +**Generated:** 2026-06-01 +**Branch:** main ## Quick Stats | Metric | Value | |--------|-------| -| Total Files | 101 | -| Go Files | 22 ( 2304 lines) | -| TypeScript Files | 3 ( 761 lines) | -| Markdown Files | 134 ( 27723 lines) | +| Total Files | ~120 | +| Go Files | 44 (~6000+ lines) | +| Swift Files | 12 (macOS app) | +| Markdown Files | 150+ | | Docker Config | yes | -| Uncommitted Changes | 9 | -| Milestones Complete | 0/6 (0%) | +| Test Coverage | 39% | +| Milestones Complete | 3/6 (50%) | ## Recent Activity -**Last Commit:** 3d8ca8b - refactor: focus admin dashboard layout (3 hours ago) +**Last Commit:** fb0673a - sync app filled in ## Milestone Progress -- [ ] Milestone 1: Foundation -- [ ] Milestone 2: Sync Protocol -- [ ] Milestone 3: Authentication -- [ ] Milestone 4: Collaboration -- [ ] Milestone 5: Search & UI -- [ ] Milestone 6: Production +- [x] Milestone 1: Foundation +- [x] Milestone 2: Sync Protocol +- [x] Milestone 3: Authentication +- [ ] Milestone 4: Collaboration (infrastructure done, features in progress) +- [ ] Milestone 5: Search & UI (core search done, polish needed) +- [ ] Milestone 6: Production (not started) -**Current Focus:** Not Started +**Current Focus:** M4 Collaboration + M5 UI Polish ### Error Handling (10 ignored errors) diff --git a/.project/macos-app-api-gaps.md b/.project/macos-app-api-gaps.md index 42eb751..01a129e 100644 --- a/.project/macos-app-api-gaps.md +++ b/.project/macos-app-api-gaps.md @@ -59,7 +59,7 @@ The attachment list placeholder has been replaced with a repository-backed imple ## Authentication 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 3. All requests include `Authorization: Bearer ` diff --git a/.project/milestones/milestone-04-collaboration.md b/.project/milestones/milestone-04-collaboration.md index 9d97582..3b79d6e 100644 --- a/.project/milestones/milestone-04-collaboration.md +++ b/.project/milestones/milestone-04-collaboration.md @@ -7,65 +7,66 @@ ### Week 7: Comments & Notifications -- [ ] Comment system - - Comment creation endpoint (web) - - Comment threading (parent/child) - - Line/section anchoring (hash-based) - - Comment resolution (mark as resolved) - - Comment display in the Go-served browser UI - -- [ ] Notification system - - Notification queue in database - - Per-file watcher subscriptions - - Per-folder watcher subscriptions - - Global notification settings - - Digest mode (hourly/daily batches) - -- [ ] Web notification UI - - Notification bell with unread count - - Notification list dropdown - - Mark as read functionality - - Notification preferences page +- [x] Comment system (backend complete, UI partial) + - [x] Comment creation endpoint (web) + - [x] Comment threading (parent/child) - schema supports it + - [x] Line/section anchoring (hash-based) + - [x] Comment resolution (mark as resolved) + - [ ] Comment display in the Go-served browser UI (JS exists but needs wiring) + +- [x] Notification system (backend complete) + - [x] Notification queue in database + - [x] Per-file watcher subscriptions + - [x] Per-folder watcher subscriptions + - [ ] Global notification settings + - [ ] Digest mode (hourly/daily batches) + +- [x] Web notification UI (backend complete, JS partial) + - [x] Notification bell with unread count - API exists + - [x] Notification list dropdown - API exists + - [x] Mark as read functionality - API exists + - [ ] Notification preferences page ### Week 8: Email Integration & Conflict Resolution - [ ] Postmark integration - - Go adapter setup - - Plain-text email templates - - Outgoing email queue with retry - - Webhook endpoint for inbound email - + - [x] SMTP sender stub (NoOp + SMTP implementations) + - [ ] Postmark adapter + - [ ] Plain-text email templates + - [ ] Outgoing email queue with retry + - [ ] Webhook endpoint for inbound email + - [ ] Email notification types - - File changed notification - - New comment notification - - Mention notification (@username) - - Conflict alert notification - - Digest summary email - -[ ] Email reply handling - - Parse inbound email (from, subject, body) - - Match to comment thread via In-Reply-To - - Create comment from email body - - Validate sender permission - -- [ ] Conflict resolution UI - - Side-by-side diff view - - Accept local/server/merge options - - Manual merge editor - - Conflict notification email + - [ ] File changed notification + - [ ] New comment notification + - [ ] Mention notification (@username) + - [ ] Conflict alert notification + - [ ] Digest summary email + +- [ ] Email reply handling + - [ ] Parse inbound email (from, subject, body) + - [ ] Match to comment thread via In-Reply-To + - [ ] Create comment from email body + - [ ] Validate sender permission + +- [x] Conflict resolution UI (M2 - exists in sync flow) + - [x] Side-by-side diff view + - [x] Accept local/server/merge options + - [x] Manual merge editor + - [ ] Conflict notification email ## Acceptance Criteria ### Functional -- [ ] Users can add comments to specific lines in a document -- [ ] Comments are linked to document version (hash) -[ ] Comment threading works (reply to reply) -- [ ] Users can watch files or folders for changes -- [ ] Email notifications sent within 1 minute of event +- [x] Users can add comments to specific lines in a document (API exists) +- [x] Comments are linked to document version (hash) +- [x] Comment threading works (reply to reply) (schema supports it) +- [x] Users can watch files or folders for changes (API exists) +- [ ] Email notifications sent within 1 minute of event (NoOp sender currently) - [ ] Replying to notification email creates a comment - [ ] Digest emails batch notifications by time window -- [ ] Conflicts display side-by-side diff -- [ ] Users can resolve conflicts via web UI +- [x] Conflicts display side-by-side diff (M2 sync) +- [x] Users can resolve conflicts via web UI (M2 sync) - [ ] Resolved comments are hidden but accessible in history ### Non-Functional diff --git a/.project/milestones/milestone-05-search-ui.md b/.project/milestones/milestone-05-search-ui.md index 232f1e7..2dd0269 100644 --- a/.project/milestones/milestone-05-search-ui.md +++ b/.project/milestones/milestone-05-search-ui.md @@ -7,24 +7,24 @@ ### Week 9: Search Implementation -- [ ] Server-side search index - - FTS5 virtual table setup - - Index population from documents - - Incremental index updates on file changes - - Search query endpoint - -- [ ] Client-side search - - Flexsearch or Pagefind integration - - Index sync from server on load - - Incremental index updates via WebSocket - - Search UI (input, results, filters) - -- [ ] Search features - - Full-text content search - - Tag filtering - - Title search (boosted) - - Recent results caching - - Search suggestions (autocomplete) +- [x] Server-side search index + - [x] FTS5 virtual table setup + - [x] Index population from documents + - [x] Incremental index updates on file changes + - [x] Search query endpoint + +- [x] Client-side search + - [x] Search UI (input, results) - basic implementation + - [ ] Flexsearch or Pagefind integration + - [ ] Index sync from server on load + - [ ] Incremental index updates via WebSocket + +- [x] Search features + - [x] Full-text content search + - [ ] Tag filtering + - [ ] Title search (boosted) + - [ ] Recent results caching + - [ ] Search suggestions (autocomplete) ### Week 10: Design System & Performance @@ -60,7 +60,7 @@ ## Acceptance Criteria ### 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 supports exact phrases (quoted) - [ ] Tag filtering narrows search results diff --git a/.project/project-plan.md b/.project/project-plan.md index b4f21e8..a4da829 100644 --- a/.project/project-plan.md +++ b/.project/project-plan.md @@ -1,8 +1,8 @@ # Cairnquire - Project Plan -**Version:** 1.2\ -**Date:** 2026-05-14\ -**Status:** In Progress β€” Milestones 1-3 Complete +**Version:** 1.3\ +**Date:** 2026-06-01\ +**Status:** In Progress β€” Milestones 1-3 Complete, M4-M5 In Progress ## 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 @@ -103,6 +103,8 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown - [x] Role-based access control (RBAC) - [x] Scoped API tokens with device-code flow for developer clients - [ ] Content signing with Ed25519 (deferred optional hardening) +- [ ] Automatic session refresh (deferred polish) +- [ ] Multiple passkey management UI (deferred polish) **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 -- Comment system linked to file versions (by hash) -- Email notifications via Postmark adapter -- Web-based conflict resolution UI -- Notification preferences (per-file, per-folder, global, digest) -- Comment threading and email replies +- [x] Comment system linked to file versions (by hash) - backend complete +- [ ] Email notifications via Postmark adapter - SMTP stub exists +- [x] Web-based conflict resolution UI (Milestone 2) +- [ ] Notification preferences (per-file, per-folder, global, digest) +- [x] Comment threading schema - UI needs wiring +- [ ] Email replies create comments **Acceptance Criteria:** -- [ ] Users can comment on specific lines/sections of a document -- [ ] Comments persist and are linked to document versions -- [ ] Email notifications sent for watched file changes +- [x] Users can comment on specific lines/sections of a document (API) +- [x] Comments persist and are linked to document versions +- [ ] Email notifications sent for watched file changes (NoOp sender) - [ ] 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 --- -### 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 -- Full-text search with SQLite FTS5 -- Client-side search index sync -- Design system with browser-based CSS editor -- Responsive mobile layout -- Error boundaries and loading states -- Performance benchmarking -- Client-side Mermaid and KaTeX rendering +- [x] Full-text search with SQLite FTS5 +- [ ] Client-side search index sync +- [ ] Design system with browser-based CSS editor +- [ ] Responsive mobile layout +- [ ] Error boundaries and loading states +- [ ] Performance benchmarking +- [x] Client-side Mermaid and KaTeX rendering **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 - [ ] Design system documented at `/design` route - [ ] All screens responsive down to 320px width @@ -371,11 +374,25 @@ cairnquire/ 1. βœ… Complete Milestone 1: Foundation 2. βœ… Complete Milestone 2: Sync Protocol -3. πŸ”„ Finish Milestone 3 hardening - - Add browser login/account management screens - - Complete security review checklist -4. Implement SQLite FTS5 search (Milestone 5) -5. Add Renovate with dependency update cooldown policies +3. βœ… Complete Milestone 3: Authentication +4. πŸ”„ Finish Milestone 4: Collaboration + - Wire up comment UI in browser (JS exists, needs integration) + - Wire up notification bell UI + - 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 --- diff --git a/.project/specs/authentication-api.md b/.project/specs/authentication-api.md index a7a7b86..a0babc9 100644 --- a/.project/specs/authentication-api.md +++ b/.project/specs/authentication-api.md @@ -11,9 +11,12 @@ Endpoints: - `GET /setup` - `POST /api/setup` - `GET /login` +- `GET /password-reset?token=...` - `GET /account` +- `GET /account/tokens/new` - `POST /api/auth/register/password` - `POST /api/auth/login/password` +- `POST /api/auth/password/reset` - `POST /api/auth/logout` - `GET /api/auth/me` - `POST /api/auth/password/change` @@ -38,7 +41,7 @@ Developer clients use bearer tokens: Authorization: Bearer cq_pat__ ``` -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: @@ -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. +## 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 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 - 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. - Admin role required: admin APIs and API token management. diff --git a/.project/specs/deployment-guide.md b/.project/specs/deployment-guide.md index a833e46..789a0ca 100644 --- a/.project/specs/deployment-guide.md +++ b/.project/specs/deployment-guide.md @@ -29,6 +29,10 @@ docker compose ps 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 first-run wizard: diff --git a/README.md b/README.md index fe08f31..732348b 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ swift run CairnquireSync ``` 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) 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 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 -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 diff --git a/apps/macos-sync/README.md b/apps/macos-sync/README.md index a8eabe7..e27fae8 100644 --- a/apps/macos-sync/README.md +++ b/apps/macos-sync/README.md @@ -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 - **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 -- **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 - **Custom Icons**: Choose from 11 different SF Symbols for the menubar icon - **CLI Tool**: Command-line interface for one-off uploads and note creation @@ -32,6 +32,19 @@ swift build 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: ```bash open Package.swift @@ -54,10 +67,10 @@ You can run the built executable directly after building: ## Setup 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) 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 @@ -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 - **Sync Now**: Manually trigger a sync - **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 - **View Uploads**: See all uploaded files with timestamps - **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 \ --token dev \ --folder "$HOME/Documents/Cairnquire" \ - --upload-folder Inbox + --upload-folder Inbox \ + --new-note editor ``` 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. +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 is stored in: diff --git a/apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift b/apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift index 904ee9b..8977bcb 100644 --- a/apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift +++ b/apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift @@ -1,11 +1,17 @@ import Foundation import Security +public enum NewNoteAction: String, Codable, CaseIterable { + case clipboard + case editor +} + public struct SyncConfig: Codable, Equatable { public var serverURL: String public var apiToken: String? public var syncFolder: String? public var defaultUploadFolder: String? + public var newNoteAction: NewNoteAction? public var autoSync: Bool public var syncInterval: Int // seconds public var deviceId: String @@ -30,6 +36,7 @@ public struct SyncConfig: Codable, Equatable { apiToken: String? = nil, syncFolder: String? = nil, defaultUploadFolder: String? = nil, + newNoteAction: NewNoteAction? = nil, autoSync: Bool = true, syncInterval: Int = 30, deviceId: String? = nil, @@ -39,6 +46,7 @@ public struct SyncConfig: Codable, Equatable { self.apiToken = apiToken self.syncFolder = syncFolder self.defaultUploadFolder = defaultUploadFolder + self.newNoteAction = newNoteAction self.autoSync = autoSync self.syncInterval = syncInterval self.deviceId = deviceId ?? SyncConfig.generateDeviceId() @@ -57,6 +65,10 @@ public struct SyncConfig: Codable, Equatable { public var serverBaseURL: URL? { URL(string: serverURL) } + + public var effectiveNewNoteAction: NewNoteAction { + newNoteAction ?? .clipboard + } } public actor ConfigStore { diff --git a/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift b/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift index f55525a..03f1f47 100644 --- a/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift +++ b/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift @@ -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 { 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() { guard let text = NSPasteboard.general.string(forType: .string) else { 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 { diff --git a/apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift b/apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift index 9ec1da4..d57b0d2 100644 --- a/apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift +++ b/apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift @@ -8,6 +8,7 @@ public struct SettingsView: View { @State private var apiToken: String = "" @State private var syncFolder: String = "" @State private var defaultUploadFolder: String = "" + @State private var newNoteAction: NewNoteAction = .clipboard @State private var autoSync: Bool = true @State private var syncInterval: Double = 30 @State private var selectedIcon: String = "tray.circle.fill" @@ -20,28 +21,34 @@ public struct SettingsView: View { } public var body: some View { - ScrollView { - VStack(spacing: 0) { - serverSection - Divider().padding(.vertical, 16) - syncSection - Divider().padding(.vertical, 16) - uploadsSection - Divider().padding(.vertical, 16) - appearanceSection - - Spacer(minLength: 32) - - saveButton + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + serverSection + Divider().padding(.vertical, 12) + syncSection + Divider().padding(.vertical, 12) + uploadsSection + Divider().padding(.vertical, 12) + appearanceSection + } + .padding(20) } - .padding(24) + .scrollIndicators(.hidden) + + Divider() + + saveButton + .padding(.horizontal, 20) + .padding(.vertical, 12) } - .frame(width: 520, height: 600) + .frame(width: 520, height: 640) .onAppear { serverURL = config.serverURL apiToken = config.apiToken ?? "" syncFolder = config.syncFolder ?? "" defaultUploadFolder = config.defaultUploadFolder ?? "" + newNoteAction = config.effectiveNewNoteAction autoSync = config.autoSync syncInterval = Double(config.syncInterval) selectedIcon = config.iconName @@ -69,7 +76,7 @@ public struct SettingsView: View { HStack { Spacer() .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) .foregroundColor(.secondary) .lineLimit(nil) @@ -124,11 +131,20 @@ public struct SettingsView: View { .textFieldStyle(.roundedBorder) .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 { Spacer() .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) .foregroundColor(.secondary) .frame(width: 280, alignment: .leading) @@ -166,6 +182,7 @@ public struct SettingsView: View { apiToken: apiToken.isEmpty ? nil : apiToken, syncFolder: syncFolder.isEmpty ? nil : syncFolder, defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder, + newNoteAction: newNoteAction, autoSync: autoSync, syncInterval: Int(syncInterval), deviceId: config.deviceId, diff --git a/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift b/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift index 5cfcafa..667e007 100644 --- a/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift +++ b/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift @@ -116,7 +116,7 @@ public struct StatusPopoverView: View { label: "Note", color: .green ) { - controller.createNoteFromClipboard() + controller.createNote() } ActionButton( diff --git a/apps/macos-sync/Sources/CairnquireSyncApp/App.swift b/apps/macos-sync/Sources/CairnquireSyncApp/App.swift index ca4473b..acd6b8f 100644 --- a/apps/macos-sync/Sources/CairnquireSyncApp/App.swift +++ b/apps/macos-sync/Sources/CairnquireSyncApp/App.swift @@ -189,7 +189,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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: "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: "View Uploads", action: #selector(showUploads), keyEquivalent: "v")) @@ -253,7 +253,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func createNote() { Task { @MainActor in - controller.createNoteFromClipboard() + controller.createNote() } } diff --git a/apps/macos-sync/Sources/cairnquire-cli/main.swift b/apps/macos-sync/Sources/cairnquire-cli/main.swift index 54d0c97..28334e0 100644 --- a/apps/macos-sync/Sources/cairnquire-cli/main.swift +++ b/apps/macos-sync/Sources/cairnquire-cli/main.swift @@ -224,6 +224,12 @@ struct CLI { config.syncFolder = value case "--upload-folder": 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: print("Unknown config option: \(arg)") throw CLIError.invalidArguments @@ -237,6 +243,7 @@ struct CLI { print(" Server: \(config.serverURL)") print(" Sync Folder: \(config.syncFolder ?? "(none)")") print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")") + print(" New Note: \(config.effectiveNewNoteAction.rawValue)") print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")") } @@ -382,6 +389,7 @@ struct CLI { --token Set the API token --folder Set the local folder used by sync --upload-folder Set the server folder for readable uploads + --new-note Set New Note behavior: clipboard or editor The menubar app and CLI use the same saved configuration. """) diff --git a/apps/macos-sync/scripts/sign-dev.sh b/apps/macos-sync/scripts/sign-dev.sh new file mode 100755 index 0000000..ef7c537 --- /dev/null +++ b/apps/macos-sync/scripts/sign-dev.sh @@ -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." diff --git a/apps/server/internal/app/app.go b/apps/server/internal/app/app.go index 485c54d..94110b9 100644 --- a/apps/server/internal/app/app.go +++ b/apps/server/internal/app/app.go @@ -13,9 +13,9 @@ import ( "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/collaboration" "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/docs" + "github.com/tim/cairnquire/apps/server/internal/email" "github.com/tim/cairnquire/apps/server/internal/httpserver" "github.com/tim/cairnquire/apps/server/internal/markdown" "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 if cfg.Email.SMTPHost != "" { emailSender = email.NewSMTPSender(cfg.Email, logger) + authService.SetEmailSender(emailSender) } else { emailSender = email.NewNoOpSender(logger) } diff --git a/apps/server/internal/auth/repository.go b/apps/server/internal/auth/repository.go index 3b97801..02c8056 100644 --- a/apps/server/internal/auth/repository.go +++ b/apps/server/internal/auth/repository.go @@ -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) { var user User var created, lastSeen, passwordHash, role string + var disabled int 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 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 { return User{}, err } user.PasswordHash = passwordHash user.Role = Role(role) + user.Disabled = disabled == 1 user.CreatedAt, err = time.Parse(time.RFC3339, created) if err != nil { 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) { 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 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) { 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 ORDER BY created_at DESC `) @@ -202,10 +204,12 @@ func (r *Repository) ListUsers(ctx context.Context) ([]User, error) { for rows.Next() { var user User 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) } user.Role = Role(role) + user.Disabled = disabled == 1 user.CreatedAt, _ = time.Parse(time.RFC3339, created) if 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 } +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 { result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) if err != nil { @@ -411,6 +450,87 @@ func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error 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 { 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) diff --git a/apps/server/internal/auth/service.go b/apps/server/internal/auth/service.go index 8a6d115..173e991 100644 --- a/apps/server/internal/auth/service.go +++ b/apps/server/internal/auth/service.go @@ -18,13 +18,19 @@ const ( sessionTTL = 24 * time.Hour challengeTTL = 5 * time.Minute deviceCodeTTL = 15 * time.Minute + passwordResetTTL = time.Hour devicePollSeconds = 5 ) +type EmailSender interface { + Send(ctx context.Context, to []string, subject, body string) error +} + type Service struct { repo *Repository webauthn *webauthn.WebAuthn publicOrigin string + email EmailSender } 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 } +func (s *Service) SetEmailSender(sender EmailSender) { + s.email = sender +} + func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) { 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") } ok, err := verifyPassword(password, user.PasswordHash) - if err != nil || !ok { + if err != nil || !ok || user.Disabled { return Principal{}, "", fmt.Errorf("invalid credentials") } 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 { return nil, "", err } + if user.Disabled { + return nil, "", fmt.Errorf("invalid credentials") + } assertion, session, err := s.webauthn.BeginLogin(user) if err != nil { return nil, "", err @@ -205,6 +218,9 @@ func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r if err != nil { return Principal{}, "", err } + if user.Disabled { + return Principal{}, "", fmt.Errorf("invalid credentials") + } credential, err := s.webauthn.FinishLogin(user, session, r) if err != nil { return Principal{}, "", err @@ -224,6 +240,9 @@ func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Princ if err != nil { return Principal{}, err } + if user.Disabled { + return Principal{}, fmt.Errorf("account disabled") + } 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 { return Principal{}, err } + if user.Disabled { + return Principal{}, fmt.Errorf("account disabled") + } return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil } 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) if err != nil { return User{}, err } - if user.Role == RoleAdmin && normalizedRole != RoleAdmin { - admins, err := s.repo.CountAdmins(ctx) - if err != nil { - return User{}, err - } - if admins <= 1 { - return User{}, fmt.Errorf("cannot demote the last admin account") - } - } - if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil { + users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{ + ID: userID, + Role: Role(role), + Disabled: user.Disabled, + }}) + if err != nil { return User{}, err } - s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole}) - return s.repo.GetUserByID(ctx, user.ID) + return users[0], nil +} + +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) { @@ -400,6 +511,9 @@ func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Princi if err != nil { return Principal{}, err } + if user.Disabled { + return Principal{}, fmt.Errorf("account disabled") + } expiresAt := time.Time{} if key.ExpiresAt != nil { 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) { + if user.Disabled { + return Principal{}, "", fmt.Errorf("account disabled") + } token, err := randomToken(32) if err != nil { return Principal{}, "", err diff --git a/apps/server/internal/auth/service_test.go b/apps/server/internal/auth/service_test.go index 2e67422..9d019d1 100644 --- a/apps/server/internal/auth/service_test.go +++ b/apps/server/internal/auth/service_test.go @@ -3,6 +3,8 @@ package auth import ( "context" "database/sql" + "errors" + "net/url" "strings" "testing" "time" @@ -10,6 +12,20 @@ import ( "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 { t.Helper() @@ -158,11 +174,131 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) { if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil { 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 { 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) { service := setupAuthTestService(t) ctx := context.Background() diff --git a/apps/server/internal/auth/types.go b/apps/server/internal/auth/types.go index 9282a65..82f9847 100644 --- a/apps/server/internal/auth/types.go +++ b/apps/server/internal/auth/types.go @@ -41,12 +41,29 @@ type User struct { Email string DisplayName string Role Role + Disabled bool PasswordHash string CreatedAt time.Time LastSeenAt time.Time 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 { return []byte(u.ID) } diff --git a/apps/server/internal/database/migrations/000013_auth_account_management.down.sql b/apps/server/internal/database/migrations/000013_auth_account_management.down.sql new file mode 100644 index 0000000..268695b --- /dev/null +++ b/apps/server/internal/database/migrations/000013_auth_account_management.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS password_reset_tokens; +ALTER TABLE users DROP COLUMN disabled; diff --git a/apps/server/internal/database/migrations/000013_auth_account_management.up.sql b/apps/server/internal/database/migrations/000013_auth_account_management.up.sql new file mode 100644 index 0000000..9c7f45d --- /dev/null +++ b/apps/server/internal/database/migrations/000013_auth_account_management.up.sql @@ -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); diff --git a/apps/server/internal/database/migrations/000014_document_archive.down.sql b/apps/server/internal/database/migrations/000014_document_archive.down.sql new file mode 100644 index 0000000..33cc71f --- /dev/null +++ b/apps/server/internal/database/migrations/000014_document_archive.down.sql @@ -0,0 +1 @@ +ALTER TABLE documents DROP COLUMN archived_at; diff --git a/apps/server/internal/database/migrations/000014_document_archive.up.sql b/apps/server/internal/database/migrations/000014_document_archive.up.sql new file mode 100644 index 0000000..097368f --- /dev/null +++ b/apps/server/internal/database/migrations/000014_document_archive.up.sql @@ -0,0 +1 @@ +ALTER TABLE documents ADD COLUMN archived_at TEXT; diff --git a/apps/server/internal/docs/repository.go b/apps/server/internal/docs/repository.go index 6ec202d..c09f42d 100644 --- a/apps/server/internal/docs/repository.go +++ b/apps/server/internal/docs/repository.go @@ -14,13 +14,14 @@ type Repository struct { } type DocumentRecord struct { - ID string `json:"id"` - Path string `json:"path"` - CurrentHash string `json:"currentHash"` - Title string `json:"title"` - Tags []string `json:"tags"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Path string `json:"path"` + CurrentHash string `json:"currentHash"` + Title string `json:"title"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ArchivedAt *time.Time `json:"archivedAt,omitempty"` } 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) { + 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 ( record DocumentRecord tagsJSON string created string updated string + archived sql.NullString ) - err := r.db.QueryRowContext(ctx, ` - SELECT id, path, current_hash, title, tags, created_at, updated_at + query := ` + SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at FROM documents 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 { 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 { 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 { 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) { 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 + WHERE archived_at IS NULL ORDER BY path ASC `) if err != nil { @@ -138,6 +161,25 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error } 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 for rows.Next() { var ( @@ -145,13 +187,21 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error tagsJSON string created 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) } if err := parseDocumentTimes(&record, created, updated); err != nil { 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 { 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() } +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) { rows, err := r.db.QueryContext(ctx, ` 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 FROM document_search ds 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 LIMIT 50 `, query) diff --git a/apps/server/internal/docs/service.go b/apps/server/internal/docs/service.go index 50a81af..79a1a05 100644 --- a/apps/server/internal/docs/service.go +++ b/apps/server/internal/docs/service.go @@ -123,6 +123,53 @@ func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) { 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) { if _, err := s.SyncSourceDir(ctx); err != nil { 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) } - existing, err := s.repo.GetDocumentByPath(ctx, relative) + existing, err := s.repo.GetDocumentByPathIncludingArchived(ctx, relative) if err != nil && !errors.Is(err, sql.ErrNoRows) { 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) } + if existing != nil && existing.ArchivedAt != nil { + return nil, nil + } + s.logger.Debug("synced document", "path", relative, "hash", record.Hash) return &DocumentChange{ Path: relative, diff --git a/apps/server/internal/docs/service_test.go b/apps/server/internal/docs/service_test.go index fe13051..aed2ec8 100644 --- a/apps/server/internal/docs/service_test.go +++ b/apps/server/internal/docs/service_test.go @@ -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) { service, sourceDir := setupDocsTestService(t) ctx := context.Background() diff --git a/apps/server/internal/email/sender.go b/apps/server/internal/email/sender.go index 553a7fd..e4c998f 100644 --- a/apps/server/internal/email/sender.go +++ b/apps/server/internal/email/sender.go @@ -40,10 +40,10 @@ func (s *SMTPSender) Send(ctx context.Context, to []string, subject, body string return fmt.Errorf("email host not configured") } 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) - + // Use SMTP without auth for local testing (Mailpit/MailHog) var auth smtp.Auth err := smtp.SendMail(addr, auth, s.cfg.From, to, msg) diff --git a/apps/server/internal/httpserver/api_handlers_test.go b/apps/server/internal/httpserver/api_handlers_test.go index 8ba433b..cacc44b 100644 --- a/apps/server/internal/httpserver/api_handlers_test.go +++ b/apps/server/internal/httpserver/api_handlers_test.go @@ -9,8 +9,10 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" "testing" "github.com/tim/cairnquire/apps/server/internal/auth" @@ -23,6 +25,17 @@ import ( "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 { handler http.Handler auth *auth.Service @@ -36,6 +49,10 @@ func newAPITestServer(t *testing.T) *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() ctx := context.Background() @@ -88,7 +105,8 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer SourceDir: sourceDir, StoreDir: storeDir, }, - Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, + Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, + DevMode: devMode, }, Logger: logger, 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) { 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 { 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) { t.Helper() diff --git a/apps/server/internal/httpserver/auth_handlers.go b/apps/server/internal/httpserver/auth_handlers.go index 71c22cd..33876ac 100644 --- a/apps/server/internal/httpserver/auth_handlers.go +++ b/apps/server/internal/httpserver/auth_handlers.go @@ -57,6 +57,11 @@ type changePasswordRequest struct { NewPassword string `json:"newPassword"` } +type passwordResetRequest struct { + Token string `json:"token"` + NewPassword string `json:"newPassword"` +} + type deleteAccountRequest struct { 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") return } - s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{ + s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{ Title: "Sign in", BodyClass: "page-auth", BodyTemplate: "login_content", Data: struct { Next string 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) return } - s.renderTemplate(w, http.StatusOK, "setup.gohtml", layoutData{ + s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{ Title: "Initial setup", BodyClass: "page-auth", 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) { if !s.allowAuthAttempt(w, r) { return @@ -135,7 +152,7 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{ + s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{ Title: "Account", BodyClass: "page-account", 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) { principal, ok := requirePrincipal(r) if !ok { @@ -193,9 +224,26 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { _ = s.auth.RevokeSession(r.Context(), principal.SessionID) } clearSessionCookie(w) + if s.config.DevMode { + setDevLogoutCookie(w, r) + } 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) { principal, _ := requirePrincipal(r) 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"}) } +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) { principal, _ := requirePrincipal(r) 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) { 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", BodyClass: "page-auth", 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}) } +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 { if s.authLimiter.Allow(authRateLimitKey(r)) { 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) { + clearDevLogoutCookie(w) http.SetCookie(w, &http.Cookie{ Name: auth.SessionCookieName, 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) { http.SetCookie(w, &http.Cookie{ Name: auth.SessionCookieName, @@ -501,6 +619,7 @@ func publicUser(user auth.User) map[string]any { "email": user.Email, "displayName": user.DisplayName, "role": user.Role, + "disabled": user.Disabled, "createdAt": user.CreatedAt, "lastSeenAt": user.LastSeenAt, } diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index c71e873..7bc18d4 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/go-chi/chi/v5" + "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/docs" ) @@ -29,6 +30,7 @@ type layoutData struct { BodyClass string BodyTemplate string Data any + Principal *auth.Principal } type indexData struct { @@ -43,6 +45,7 @@ type documentData struct { Hash string HTML template.HTML Breadcrumbs []breadcrumb + CanWrite bool } type documentEditData struct { @@ -53,6 +56,7 @@ type documentEditData struct { Hash string Source string Breadcrumbs []breadcrumb + CanWrite bool } 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"), "/") - s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{ + s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{ Title: "Cairnquire", BodyClass: "page-index", 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.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{ + s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{ Title: "Search: " + query, BodyClass: "page-search", BodyTemplate: "search_content", @@ -223,7 +227,9 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa 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, BodyClass: "page-document-edit", BodyTemplate: "document_edit_content", @@ -235,6 +241,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa Hash: page.Hash, Source: page.Content, Breadcrumbs: buildBreadcrumbs(page.Path), + CanWrite: canWrite, }, }) } @@ -256,7 +263,9 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page 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, BodyClass: "page-document", BodyTemplate: "document_content", @@ -268,11 +277,20 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page Hash: page.Hash, HTML: template.HTML(page.HTML), Breadcrumbs: buildBreadcrumbs(page.Path), + CanWrite: canWrite, }, }) } 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) defer cancel() @@ -286,11 +304,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { contentStatus = err.Error() } - writeJSON(w, http.StatusOK, map[string]string{ + ready := dbStatus == "ok" && contentStatus == "ok" + payload := map[string]string{ "status": "ok", "database": dbStatus, "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) { @@ -515,7 +540,13 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) { _, _ = 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.WriteHeader(status) 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) { - s.renderTemplate(w, status, "error.gohtml", layoutData{ + s.renderTemplate(w, r, status, "error.gohtml", layoutData{ Title: http.StatusText(status), BodyClass: "page-error", 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}) } -func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) { - pagePath := chi.URLParam(r, "*") +func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) { + 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 == "" { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"}) return @@ -705,6 +758,50 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) { _ = 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) { if path == "edit" { return "", true diff --git a/apps/server/internal/httpserver/handlers_test.go b/apps/server/internal/httpserver/handlers_test.go index 2a3c079..1dc6878 100644 --- a/apps/server/internal/httpserver/handlers_test.go +++ b/apps/server/internal/httpserver/handlers_test.go @@ -414,3 +414,135 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde request.Header.Set("Content-Type", writer.FormDataContentType()) 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, + } +} diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index 14efc3b..164f395 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -13,6 +13,8 @@ import ( "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 { timeoutMiddleware := middleware.Timeout(timeout) return func(next http.Handler) http.Handler { @@ -99,6 +101,8 @@ func isSetupAllowedPath(path string) bool { return path == "/setup" || path == "/api/setup" || path == "/health" || + path == "/healthz" || + path == "/readyz" || path == "/sw.js" || strings.HasPrefix(path, "/static/") } @@ -125,7 +129,7 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) { } cookie, err := r.Cookie(auth.SessionCookieName) if err != nil || cookie.Value == "" { - if s.devUserID == "" { + if s.devUserID == "" || hasDevLogoutCookie(r) { return auth.Principal{}, false } 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 } +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) { path := r.URL.Path method := r.Method diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 6adf15a..1841ce1 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -104,15 +104,21 @@ func New(deps Dependencies) (http.Handler, error) { router.Get("/", server.handleIndex) router.Get("/setup", server.handleSetupPage) router.Get("/login", server.handleLoginPage) + router.Get("/password-reset", server.handlePasswordResetPage) router.Get("/account", server.handleAccountPage) + router.Get("/account/tokens/new", server.handleTokenCreatePage) router.Get("/device/verify", server.handleDeviceVerifyPage) router.Get("/health", server.handleHealth) + router.Get("/healthz", server.handleHealthz) + router.Get("/readyz", server.handleReadyz) router.Get("/sw.js", server.handleServiceWorker) router.Get("/ws", server.handleWebSocket) router.Get("/api/auth/me", server.handleAuthMe) router.Post("/api/setup", server.handleSetup) router.Post("/api/auth/register/password", server.handlePasswordRegister) 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/password/change", server.handleChangePassword) 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.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole) 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.Patch("/api/admin/settings", server.handleAdminSettingsUpdate) 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.handleDocument) 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.Post("/api/uploads", server.handleUpload) router.Get("/api/attachments", server.handleAttachmentsList) diff --git a/apps/server/internal/httpserver/static/archive.js b/apps/server/internal/httpserver/static/archive.js new file mode 100644 index 0000000..37845bd --- /dev/null +++ b/apps/server/internal/httpserver/static/archive.js @@ -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'; + }); + }); +})(); diff --git a/apps/server/internal/httpserver/static/auth.js b/apps/server/internal/httpserver/static/auth.js index 696ebbe..422fa77 100644 --- a/apps/server/internal/httpserver/static/auth.js +++ b/apps/server/internal/httpserver/static/auth.js @@ -34,10 +34,14 @@ const loginSection = document.querySelector('[data-login-section]'); const registerCancel = document.querySelector('[data-register-cancel]'); + const authHeading = document.querySelector('[data-auth-heading]'); + if (registerToggle && registerPanel && loginSection) { registerToggle.addEventListener('click', () => { loginSection.hidden = true; registerPanel.hidden = false; + if (authHeading) authHeading.textContent = 'Create an Account'; + document.title = 'Create an Account'; }); } @@ -45,6 +49,8 @@ registerCancel.addEventListener('click', () => { registerPanel.hidden = true; loginSection.hidden = false; + if (authHeading) authHeading.textContent = 'Log In'; + document.title = 'Sign in'; // Clear any registration form status messages document.querySelector('[data-passkey-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) => { event.preventDefault(); try { @@ -237,19 +252,37 @@ }); document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => { - await postJSON("/api/auth/logout", {}); - window.location.href = "/login"; + try { + await postJSON("/api/auth/logout", {}); + } catch (error) { + console.warn("Logout request failed:", error); + } finally { + window.location.href = "/login"; + } }); const loadTokens = async () => { const list = document.querySelector("[data-token-list]"); if (!list) return; 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( - ...(data.tokens || []).map((token) => { + ...data.tokens.map((token) => { const item = document.createElement("li"); + item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`; 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"); button.type = "button"; 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) => { event.preventDefault(); if (!confirm("Delete this account permanently?")) return; @@ -324,7 +368,7 @@ const loadAdminUsers = async () => { 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; const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null); 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()); list.replaceChildren( ...(data.users || []).map((user) => { - const row = document.createElement("form"); + const row = document.createElement("div"); row.className = "user-row"; + row.dataset.userId = user.id; const identity = document.createElement("span"); const name = document.createElement("strong"); name.textContent = user.displayName; @@ -348,24 +393,52 @@ option.selected = user.role === role; select.append(option); } - const button = document.createElement("button"); - button.type = "submit"; - button.textContent = "Save"; - row.append(identity, select, button); - row.addEventListener("submit", async (event) => { - event.preventDefault(); + select.name = "role"; + select.setAttribute("aria-label", `Role for ${user.displayName}`); + const disabledLabel = document.createElement("label"); + disabledLabel.className = "user-disable-toggle"; + const disabled = document.createElement("input"); + 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 { - await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" }); - setStatus("[data-admin-users-status]", "Role updated."); + await postJSON(`/api/admin/auth-users/${encodeURIComponent(user.id)}/password-reset`, {}); + setStatus("[data-admin-users-status]", `Password reset email sent to ${user.email}.`); } catch (error) { setStatus("[data-admin-users-status]", error.message, true); } }); + row.append(identity, select, disabledLabel, reset); 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 section = document.querySelector("[data-admin-settings-section]"); const form = document.querySelector("[data-admin-settings]"); diff --git a/apps/server/internal/httpserver/static/notification-bell.js b/apps/server/internal/httpserver/static/notification-bell.js index 1fb860a..fd5d724 100644 --- a/apps/server/internal/httpserver/static/notification-bell.js +++ b/apps/server/internal/httpserver/static/notification-bell.js @@ -4,20 +4,28 @@ const bell = document.querySelector('[data-notification-bell]'); if (!bell) return; + const trigger = bell.querySelector('.notification-bell__trigger'); const countEl = bell.querySelector('[data-unread-count]'); const dropdown = bell.querySelector('[data-notification-dropdown]'); let isOpen = false; - bell.addEventListener('click', () => { + trigger.addEventListener('click', (e) => { + e.stopPropagation(); isOpen = !isOpen; dropdown.hidden = !isOpen; + trigger.setAttribute('aria-expanded', String(isOpen)); if (isOpen) loadNotifications(); }); - document.addEventListener('click', (e) => { - if (!bell.contains(e.target)) { + dropdown.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + document.addEventListener('click', () => { + if (isOpen) { isOpen = false; dropdown.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); } }); @@ -51,8 +59,17 @@ if (!list) return; list.innerHTML = ''; + // Header + const header = document.createElement('li'); + header.className = 'notification-header'; + header.textContent = 'Notifications'; + list.appendChild(header); + if (!notifications.length) { - list.innerHTML = '
  • No notifications
  • '; + const empty = document.createElement('li'); + empty.className = 'notification-empty'; + empty.textContent = 'No notifications'; + list.appendChild(empty); return; } diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index 155e67e..50175a7 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -129,6 +129,143 @@ code { 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 { position: relative; display: flex; @@ -150,13 +287,18 @@ code { width: 100%; padding: 0.4rem 0.75rem 0.4rem 2rem; border: 1px solid var(--border); - border-radius: 999px; - background: rgba(255, 255, 255, 0.6); + border-radius: 0; + background: var(--panel); + color: var(--text); font: inherit; font-size: 0.825rem; transition: border-color 0.15s, background 0.15s; } +.site-search input::placeholder { + color: var(--muted); +} + .site-search input:focus { outline: none; border-color: var(--accent); @@ -376,6 +518,16 @@ code { 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 */ .auth-register-cta { margin-top: 1.5rem; @@ -456,31 +608,282 @@ code { align-items: center; justify-content: space-between; 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 { - margin: 0.35rem 0 0; +.account-identity { + 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); + font-size: 0.9rem; } -.account-grid { - width: min(100%, 1120px); +.account-role { + 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 { - 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 { 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 { - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); margin: 0; padding: 0.75rem; border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 3%, var(--panel-strong)); } .scope-grid legend { @@ -494,11 +897,47 @@ code { 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 { max-width: 100%; overflow: auto; padding: 0.75rem; border: 1px solid var(--border); + border-radius: var(--radius-sm); background: var(--panel-strong); white-space: pre-wrap; overflow-wrap: anywhere; @@ -521,14 +960,40 @@ code { align-items: center; padding: 0.75rem; border: 1px solid var(--border); + border-radius: var(--radius-sm); background: var(--panel-strong); } -.user-row { - grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto; +.token-list { + 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; gap: 0.15rem; } @@ -537,6 +1002,84 @@ code { 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-shell { display: grid; @@ -1166,6 +1709,12 @@ code { align-items: end; } +.document-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + .document-edit-link { display: inline-flex; align-items: center; @@ -1178,6 +1727,25 @@ code { 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 { display: grid; grid-template-rows: auto auto 1fr; @@ -1595,6 +2163,10 @@ code { background: oklch(0.16 0.014 55 / 0.92); } + .notification-bell__trigger:hover { + background: rgba(255, 255, 255, 0.08); + } + .miller-column h2 { background: oklch(0.20 0.015 55 / 0.72); } @@ -1826,4 +2398,3 @@ code { font-size: 0.9rem; line-height: 1.5; } - diff --git a/apps/server/internal/httpserver/templates/account.gohtml b/apps/server/internal/httpserver/templates/account.gohtml index 17f7d42..a65fbe7 100644 --- a/apps/server/internal/httpserver/templates/account.gohtml +++ b/apps/server/internal/httpserver/templates/account.gohtml @@ -3,82 +3,130 @@ {{ define "account_content" }} {{ end }} diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index 7eede07..dea3cf5 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -27,6 +27,20 @@