From d51c6da18da3e7782abaac04261ce619571e73d4 Mon Sep 17 00:00:00 2001 From: Joakim Date: Tue, 17 Feb 2026 17:07:34 +0100 Subject: [PATCH] feat: replace mock mode with real backend dev mode Add --dev flag to `opal server start` that disables auth (injects userID=1 for all requests) and exposes a /auth/dev-session endpoint, so the frontend can develop against a real backend without OAuth config. Remove VITE_MOCK_MODE and all mock data/branches from the frontend stores. Add scripts/dev.sh to start both services locally. Co-Authored-By: Claude Opus 4.6 --- opal-task/cmd/server.go | 21 +- opal-task/internal/api/middleware.go | 10 + opal-task/internal/api/server.go | 47 +++- opal-web/.env.example | 2 +- opal-web/src/lib/mock/tasks.js | 315 --------------------------- opal-web/src/lib/stores/auth.js | 27 +-- opal-web/src/lib/stores/tasks.js | 125 +---------- scripts/dev.sh | 32 +++ 8 files changed, 111 insertions(+), 468 deletions(-) delete mode 100644 opal-web/src/lib/mock/tasks.js create mode 100755 scripts/dev.sh diff --git a/opal-task/cmd/server.go b/opal-task/cmd/server.go index dc24031..b18fa77 100644 --- a/opal-task/cmd/server.go +++ b/opal-task/cmd/server.go @@ -97,20 +97,30 @@ var serverStartCmd = &cobra.Command{ Examples: opal server start opal server start --addr :8080 + opal server start --dev opal server start --db /var/lib/opal/opal.db`, Run: func(cmd *cobra.Command, args []string) { addr, _ := cmd.Flags().GetString("addr") dbPath, _ := cmd.Flags().GetString("db") + devMode, _ := cmd.Flags().GetBool("dev") // Override DB path if specified if dbPath != "" { os.Setenv("OPAL_DB_PATH", dbPath) } - // Validate server configuration - if err := validateServerConfig(); err != nil { - fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err) - os.Exit(1) + // In dev mode, skip OAuth config validation + if devMode { + fmt.Println("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓") + fmt.Println("┃ ⚠ DEV MODE ENABLED ⚠ ┃") + fmt.Println("┃ Auth disabled — all requests use uid 1 ┃") + fmt.Println("┃ Do NOT use in production! ┃") + fmt.Println("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛") + } else { + if err := validateServerConfig(); err != nil { + fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err) + os.Exit(1) + } } // Load config (read-only — uses defaults if no opal.yml exists) @@ -127,7 +137,7 @@ Examples: defer engine.CloseDB() // Create and start server - server := api.NewServer(addr) + server := api.NewServer(addr, devMode) if err := server.Start(); err != nil { fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) os.Exit(1) @@ -194,6 +204,7 @@ func init() { serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address") serverStartCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") + serverStartCmd.Flags().Bool("dev", false, "Enable dev mode (no auth, no OAuth env vars required)") keygenCmd.Flags().StringP("name", "n", "", "Name for this API key (e.g., device name)") keygenCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") diff --git a/opal-task/internal/api/middleware.go b/opal-task/internal/api/middleware.go index 7e6d935..2d431a3 100644 --- a/opal-task/internal/api/middleware.go +++ b/opal-task/internal/api/middleware.go @@ -73,6 +73,16 @@ func GetUserID(r *http.Request) int { return userID } +// DevAuthMiddleware always injects userID=1 into context — for local dev only +func DevAuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), userIDKey, 1) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + // CORSMiddleware adds CORS headers for future web frontend func CORSMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/opal-task/internal/api/server.go b/opal-task/internal/api/server.go index d949971..9374e39 100644 --- a/opal-task/internal/api/server.go +++ b/opal-task/internal/api/server.go @@ -11,15 +11,17 @@ import ( // Server represents the API server type Server struct { - router chi.Router - addr string + router chi.Router + addr string + devMode bool } // NewServer creates a new API server -func NewServer(addr string) *Server { +func NewServer(addr string, devMode bool) *Server { s := &Server{ - router: chi.NewRouter(), - addr: addr, + router: chi.NewRouter(), + addr: addr, + devMode: devMode, } s.setupRoutes() return s @@ -39,16 +41,37 @@ func (s *Server) setupRoutes() { JSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) - // OAuth endpoints (no auth required) - r.Get("/auth/login", handlers.GetLoginURL) - r.Get("/auth/callback", handlers.OAuthCallback) - r.Post("/auth/callback", handlers.OAuthCallback) - r.Post("/auth/refresh", handlers.RefreshToken) - r.Post("/auth/logout", handlers.Logout) + if s.devMode { + // Dev mode: fake session endpoint so the frontend can "log in" + r.Get("/auth/dev-session", func(w http.ResponseWriter, r *http.Request) { + JSON(w, http.StatusOK, map[string]interface{}{ + "access_token": "dev-token", + "refresh_token": "", + "expires_at": 9999999999, + "token_type": "bearer", + "user": map[string]interface{}{ + "id": 1, + "username": "dev", + "email": "dev@localhost", + }, + }) + }) + } else { + // OAuth endpoints (no auth required) + r.Get("/auth/login", handlers.GetLoginURL) + r.Get("/auth/callback", handlers.OAuthCallback) + r.Post("/auth/callback", handlers.OAuthCallback) + r.Post("/auth/refresh", handlers.RefreshToken) + r.Post("/auth/logout", handlers.Logout) + } // Protected routes r.Group(func(r chi.Router) { - r.Use(AuthMiddleware()) + if s.devMode { + r.Use(DevAuthMiddleware()) + } else { + r.Use(AuthMiddleware()) + } // Tasks r.Route("/tasks", func(r chi.Router) { diff --git a/opal-web/.env.example b/opal-web/.env.example index 0b4f926..11eac77 100644 --- a/opal-web/.env.example +++ b/opal-web/.env.example @@ -2,5 +2,5 @@ VITE_API_URL=https://opal.example.com/api VITE_AUTH_URL=https://auth.example.com -# OAuth +# OAuth — set to "false" for local dev (used with `opal server start --dev`) VITE_OAUTH_ENABLED=true diff --git a/opal-web/src/lib/mock/tasks.js b/opal-web/src/lib/mock/tasks.js deleted file mode 100644 index 5b74730..0000000 --- a/opal-web/src/lib/mock/tasks.js +++ /dev/null @@ -1,315 +0,0 @@ -/** - * Mock task data for local development / design review. - * Covers a realistic spread of projects, priorities, tags, due dates, and statuses. - */ - -const now = Math.floor(Date.now() / 1000); -const HOUR = 3600; -const DAY = 86400; - -/** @type {import('$lib/api/types.js').Task[]} */ -export const mockTasks = [ - // ── Pending tasks ──────────────────────────────────────────── - { - uuid: '11111111-1111-4111-a111-111111111101', - id: 1, - status: 'P', - description: 'Set up Caddy reverse proxy for opal-web', - project: 'Infrastructure', - priority: 3, - created: now - 7 * DAY, - modified: now - 1 * DAY, - start: now - 2 * HOUR, - end: null, - due: now + 2 * DAY, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['devops', 'selfhosted'], - urgency: 14.2 - }, - { - uuid: '11111111-1111-4111-a111-111111111102', - id: 2, - status: 'P', - description: 'Write unit tests for task filter parsing', - project: 'Opal', - priority: 2, - created: now - 5 * DAY, - modified: now - 3 * DAY, - start: null, - end: null, - due: now + 5 * DAY, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['testing', 'backend'], - urgency: 7.3 - }, - { - uuid: '11111111-1111-4111-a111-111111111103', - id: 3, - status: 'P', - description: 'Fix tag extraction for nested wiki-links', - project: 'Jade', - priority: 2, - created: now - 3 * DAY, - modified: now - 3 * DAY, - start: null, - end: null, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['bug'], - urgency: 4.1 - }, - { - uuid: '11111111-1111-4111-a111-111111111104', - id: 4, - status: 'P', - description: 'Grocery run - farmers market', - project: null, - priority: 1, - created: now - 1 * DAY, - modified: now - 1 * DAY, - start: null, - end: null, - due: now + 1 * DAY, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['errand'], - urgency: 3.5 - }, - { - uuid: '11111111-1111-4111-a111-111111111105', - id: 5, - status: 'P', - description: 'Design task detail page for opal-web', - project: 'Opal', - priority: 3, - created: now - 2 * DAY, - modified: now - 2 * DAY, - start: null, - end: null, - due: now - 1 * DAY, // overdue - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['frontend', 'design'], - urgency: 15.8 - }, - { - uuid: '11111111-1111-4111-a111-111111111106', - id: 6, - status: 'P', - description: 'Renew domain registration for jnss.me', - project: 'Infrastructure', - priority: 1, - created: now - 14 * DAY, - modified: now - 14 * DAY, - start: null, - end: null, - due: now + 30 * DAY, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['admin'], - urgency: 2.4 - }, - { - uuid: '11111111-1111-4111-a111-111111111107', - id: 7, - status: 'P', - description: 'Add recurrence UI to task creation form', - project: 'Opal', - priority: 1, - created: now - 4 * DAY, - modified: now - 4 * DAY, - start: null, - end: null, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['frontend'], - urgency: 2.9 - }, - { - uuid: '11111111-1111-4111-a111-111111111108', - id: 8, - status: 'P', - description: 'Migrate Nextcloud to latest stable', - project: 'Infrastructure', - priority: 0, - created: now - 10 * DAY, - modified: now - 10 * DAY, - start: null, - end: null, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['selfhosted', 'maintenance'], - urgency: 1.6 - }, - { - uuid: '11111111-1111-4111-a111-111111111109', - id: 9, - status: 'P', - description: 'Read "Designing Data-Intensive Applications" ch. 7', - project: null, - priority: 0, - created: now - 6 * DAY, - modified: now - 6 * DAY, - start: null, - end: null, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['reading', 'learning'], - urgency: 1.2 - }, - { - uuid: '11111111-1111-4111-a111-111111111110', - id: 10, - status: 'P', - description: 'Review PR: sync conflict resolution strategy', - project: 'Opal', - priority: 2, - created: now - 1 * DAY, - modified: now - 1 * DAY, - start: null, - end: null, - due: now, // due today - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['review', 'backend'], - urgency: 10.5 - }, - - // ── Completed tasks ────────────────────────────────────────── - { - uuid: '22222222-2222-4222-a222-222222222201', - id: 11, - status: 'C', - description: 'Implement XDG directory support', - project: 'Opal', - priority: 2, - created: now - 14 * DAY, - modified: now - 7 * DAY, - start: null, - end: now - 7 * DAY, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['backend', 'refactor'], - urgency: 0 - }, - { - uuid: '22222222-2222-4222-a222-222222222202', - id: 12, - status: 'C', - description: 'Set up Authentik OAuth provider', - project: 'Infrastructure', - priority: 3, - created: now - 21 * DAY, - modified: now - 10 * DAY, - start: null, - end: now - 10 * DAY, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['auth', 'selfhosted'], - urgency: 0 - }, - { - uuid: '22222222-2222-4222-a222-222222222203', - id: 13, - status: 'C', - description: 'Build setup wizard for first-run', - project: 'Opal', - priority: 2, - created: now - 10 * DAY, - modified: now - 5 * DAY, - start: null, - end: now - 5 * DAY, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['ux', 'backend'], - urgency: 0 - }, - { - uuid: '22222222-2222-4222-a222-222222222204', - id: 14, - status: 'C', - description: 'Fix PersistentPreRun initialization order', - project: 'Opal', - priority: 3, - created: now - 8 * DAY, - modified: now - 6 * DAY, - start: null, - end: now - 6 * DAY, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['bug', 'backend'], - urgency: 0 - }, - { - uuid: '22222222-2222-4222-a222-222222222205', - id: 15, - status: 'C', - description: 'Write deployment guide with Caddy config', - project: 'Opal', - priority: 1, - created: now - 9 * DAY, - modified: now - 6 * DAY, - start: null, - end: now - 6 * DAY, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: ['docs'], - urgency: 0 - } -]; diff --git a/opal-web/src/lib/stores/auth.js b/opal-web/src/lib/stores/auth.js index 8b5205b..6e20969 100644 --- a/opal-web/src/lib/stores/auth.js +++ b/opal-web/src/lib/stores/auth.js @@ -17,17 +17,18 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js'; */ const STORAGE_KEY = 'opal_auth'; -const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true'; +const DEV_MODE = import.meta.env.VITE_OAUTH_ENABLED === 'false'; /** * Load auth state from localStorage * @returns {AuthState} */ function loadAuth() { - // In mock mode, always return authenticated - if (MOCK_MODE) { + // In dev mode, auto-authenticate with a dev user. + // API requests still go to the real backend (which runs with auth disabled). + if (DEV_MODE) { return { - accessToken: 'mock-token', + accessToken: 'dev-token', refreshToken: '', expiresAt: 9999999999, user: { id: 1, username: 'dev', email: 'dev@localhost' }, @@ -73,10 +74,10 @@ function loadAuth() { */ function createAuthStore() { const { subscribe, set, update } = writable(loadAuth()); - + return { subscribe, - + /** * Set authentication tokens * @param {AuthTokens} tokens @@ -90,15 +91,15 @@ function createAuthStore() { expiresAt: tokens.expires_at, isAuthenticated: true }; - + if (browser) { setItem(STORAGE_KEY, newState); } - + return newState; }); }, - + /** * Set user info * @param {User} user @@ -112,7 +113,7 @@ function createAuthStore() { return newState; }); }, - + /** * Set full auth data (tokens + user) * @param {AuthTokens & {user: User}} data @@ -125,14 +126,14 @@ function createAuthStore() { user: data.user, isAuthenticated: true }; - + if (browser) { setItem(STORAGE_KEY, newState); } - + set(newState); }, - + /** * Clear auth (logout) */ diff --git a/opal-web/src/lib/stores/tasks.js b/opal-web/src/lib/stores/tasks.js index 0d550d1..894ae3c 100644 --- a/opal-web/src/lib/stores/tasks.js +++ b/opal-web/src/lib/stores/tasks.js @@ -1,35 +1,18 @@ import { writable, derived } from 'svelte/store'; import { tasks as tasksAPI } from '$lib/api/endpoints.js'; import { queueChange } from '$lib/utils/sync-queue.js'; -import { generateUUID } from '$lib/utils/uuid.js'; /** * @typedef {import('$lib/api/types.js').Task} Task * @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters */ -const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true'; - -/** Report names that map to pending tasks in mock mode */ -const PENDING_REPORTS = new Set(['list', 'next', 'active', 'ready', 'overdue', 'waiting', 'newest', 'oldest']); - /** * Create tasks store */ function createTasksStore() { const { subscribe, set, update } = writable(/** @type {Task[]} */ ([])); - /** @type {Task[]} */ - let mockData = []; - - /** Ensure mock data is loaded */ - async function ensureMockData() { - if (mockData.length === 0) { - const { mockTasks } = await import('$lib/mock/tasks.js'); - mockData = [...mockTasks]; - } - } - return { subscribe, @@ -38,18 +21,6 @@ function createTasksStore() { * @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed') */ async loadReport(reportName) { - if (MOCK_MODE) { - await ensureMockData(); - if (reportName === 'completed') { - set(mockData.filter(t => t.status === 'C')); - } else if (PENDING_REPORTS.has(reportName)) { - set(mockData.filter(t => t.status === 'P')); - } else { - set(mockData.filter(t => t.status === 'P')); - } - return; - } - try { const tasks = await tasksAPI.listByReport(reportName); set(tasks); @@ -65,55 +36,9 @@ function createTasksStore() { * @returns {Promise} */ async parseAndCreate(input) { - if (MOCK_MODE) { - await ensureMockData(); - // Naive parse: non-modifier words become description - const words = input.split(/\s+/); - const descWords = []; - const task = /** @type {Task} */ ({ - uuid: generateUUID(), - id: mockData.length + 1, - status: 'P', - description: '', - project: null, - priority: 1, - created: Date.now() / 1000, - modified: Date.now() / 1000, - start: null, - end: null, - due: null, - scheduled: null, - wait: null, - until: null, - recurrence_duration: null, - parent_uuid: null, - tags: [] - }); - - for (let i = 0; i < words.length; i++) { - const w = words[i]; - if (w.startsWith('project:')) { - task.project = w.slice(8); - } else if (w.startsWith('priority:') || w.startsWith('pri:')) { - const val = w.includes(':') ? w.split(':')[1] : '1'; - /** @type {Record} */ - const map = { H: 3, M: 2, L: 0, h: 3, m: 2, l: 0 }; - task.priority = /** @type {import('$lib/api/types.js').TaskPriority} */ ((map[val] ?? parseInt(val, 10)) || 1); - } else if (w.startsWith('+')) { - task.tags = [...(task.tags || []), w.slice(1)]; - } else { - descWords.push(w); - } - } - task.description = descWords.join(' ') || 'New task'; - - mockData = [task, ...mockData]; - update(tasks => [task, ...tasks]); - return task; - } - try { - const created = await tasksAPI.parse(input); + const result = await tasksAPI.parse(input); + const created = result.task ?? result; update(tasks => [created, ...tasks]); return created; } catch (error) { @@ -123,20 +48,10 @@ function createTasksStore() { }, /** - * Load all tasks from API (or mock data in dev) + * Load all tasks from API * @param {TaskFilters} [filters] */ async load(filters = {}) { - if (MOCK_MODE) { - await ensureMockData(); - let filtered = mockData; - if (filters.status) { - filtered = filtered.filter(t => t.status === filters.status); - } - set(filtered); - return; - } - try { const tasks = await tasksAPI.list(filters); set(tasks); @@ -151,13 +66,6 @@ function createTasksStore() { * @param {Partial} task */ async add(task) { - if (MOCK_MODE) { - const fullTask = /** @type {Task} */ ({ ...task, id: mockData.length + 1 }); - mockData = [...mockData, fullTask]; - update(tasks => [...tasks, fullTask]); - return fullTask; - } - try { const created = await tasksAPI.create(task); update(tasks => [...tasks, created]); @@ -191,14 +99,6 @@ function createTasksStore() { return tasks; }); - if (MOCK_MODE) { - const index = mockData.findIndex(t => t.uuid === uuid); - if (index >= 0) { - mockData[index] = { ...mockData[index], ...updates, modified: Date.now() / 1000 }; - } - return; - } - try { const updated = await tasksAPI.update(uuid, updates); // Sync with server response @@ -228,11 +128,6 @@ function createTasksStore() { // Optimistic removal update(tasks => tasks.filter(t => t.uuid !== uuid)); - if (MOCK_MODE) { - mockData = mockData.filter(t => t.uuid !== uuid); - return; - } - try { await tasksAPI.delete(uuid); } catch (error) { @@ -250,20 +145,6 @@ function createTasksStore() { * @param {string} uuid */ async complete(uuid) { - if (MOCK_MODE) { - const mi = mockData.findIndex(t => t.uuid === uuid); - if (mi >= 0) { - mockData[mi] = { - ...mockData[mi], - status: /** @type {'P'|'C'} */ ('C'), - end: Date.now() / 1000, - modified: Date.now() / 1000 - }; - } - update(tasks => tasks.filter(t => t.uuid !== uuid)); - return; - } - try { await tasksAPI.complete(uuid); update(tasks => tasks.filter(t => t.uuid !== uuid)); diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..111630a --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Start backend and frontend for local development. +# Backend runs with --dev (auth disabled), frontend points at localhost:8080. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +cleanup() { + echo "" + echo "Shutting down..." + kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true + wait $BACKEND_PID $FRONTEND_PID 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# Start backend +echo "Starting backend (dev mode) on :8080..." +cd "$ROOT/opal-task" +go run . server start --dev --addr :8080 & +BACKEND_PID=$! + +# Give the backend a moment to start before launching the frontend +sleep 1 + +# Start frontend +echo "Starting frontend..." +cd "$ROOT/opal-web" +bun run dev & +FRONTEND_PID=$! + +wait