accounts and email

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

View File

@@ -1,36 +1,34 @@
# Project Status Report
**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)

View File

@@ -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 <token>`

View File

@@ -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
- [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)
- [ ] Notification system
- Notification queue in database
- Per-file watcher subscriptions
- Per-folder watcher subscriptions
- Global notification settings
- Digest mode (hourly/daily batches)
- [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)
- [ ] Web notification UI
- Notification bell with unread count
- Notification list dropdown
- Mark as read functionality
- Notification preferences page
- [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
- [ ] 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
- [ ] 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
- [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

View File

@@ -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
- [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
- [ ] Client-side search
- Flexsearch or Pagefind integration
- Index sync from server on load
- Incremental index updates via WebSocket
- Search UI (input, results, filters)
- [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
- [ ] Search features
- Full-text content search
- Tag filtering
- Title search (boosted)
- Recent results caching
- Search suggestions (autocomplete)
- [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

View File

@@ -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
---

View File

@@ -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_<id>_<secret>
```
The token is shown once. The server stores the token id and SHA-256 hash of the secret. Tokens can be scoped and revoked.
The token is shown once. The server stores the token id and SHA-256 hash of the secret. Tokens can be scoped and revoked. Browser users create tokens at `/account/tokens/new`.
Endpoints:
@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -11,7 +11,7 @@ A macOS menubar application for syncing and uploading files to a Cairnquire serv
- **Auto Sync**: Watch a local folder and automatically sync markdown files to the server
- **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:

View File

@@ -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 {

View File

@@ -276,6 +276,25 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
}
}
private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await uploadManager.uploadData(
data,
filename: filename,
duplicateAction: duplicateAction
)
} catch let error as APIError where error.isUploadConflict {
guard let action = promptForUploadDuplicate(error, filename: filename) else {
throw UploadCancelledError()
}
duplicateAction = action
}
}
}
public func refreshRecentUploads() async {
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 {

View File

@@ -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 {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
serverSection
Divider().padding(.vertical, 16)
Divider().padding(.vertical, 12)
syncSection
Divider().padding(.vertical, 16)
Divider().padding(.vertical, 12)
uploadsSection
Divider().padding(.vertical, 16)
Divider().padding(.vertical, 12)
appearanceSection
}
.padding(20)
}
.scrollIndicators(.hidden)
Spacer(minLength: 32)
Divider()
saveButton
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.padding(24)
}
.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)
@@ -125,10 +132,19 @@ public struct SettingsView: View {
.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,

View File

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

View File

@@ -189,7 +189,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
contextMenu.addItem(NSMenuItem(title: "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()
}
}

View File

@@ -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 <token> Set the API token
--folder <path> Set the local folder used by sync
--upload-folder <path> Set the server folder for readable uploads
--new-note <action> Set New Note behavior: clipboard or editor
The menubar app and CLI use the same saved configuration.
""")

View File

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

View File

@@ -13,9 +13,9 @@ import (
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/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)
}

View File

@@ -49,16 +49,18 @@ func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) {
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) {
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)

View File

@@ -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)
users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
ID: userID,
Role: Role(role),
Disabled: user.Disabled,
}})
if err != nil {
return User{}, err
}
if admins <= 1 {
return User{}, fmt.Errorf("cannot demote the last admin account")
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")
}
if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil {
return User{}, err
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")
}
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)
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

View File

@@ -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()

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ type DocumentRecord struct {
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)

View File

@@ -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,

View File

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

View File

@@ -40,7 +40,7 @@ func (s *SMTPSender) Send(ctx context.Context, to []string, subject, body string
return fmt.Errorf("email host not configured")
}
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)

View File

@@ -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()
@@ -89,6 +106,7 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
StoreDir: storeDir,
},
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()

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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)

View File

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

View File

@@ -34,10 +34,14 @@
const loginSection = document.querySelector('[data-login-section]');
const 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 () => {
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]");

View File

@@ -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 = '<li class="notification-empty">No notifications</li>';
const empty = document.createElement('li');
empty.className = 'notification-empty';
empty.textContent = 'No notifications';
list.appendChild(empty);
return;
}

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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