diff --git a/CLAUDE.md b/CLAUDE.md index 29202d2671..d1c17096d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ curl http://localhost:3000/api/v1/health # backend (via web proxy) ```text src/synthorg/ - api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop) + api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, setup endpoint (first-run wizard: status check, template listing, company/agent creation, completion gate), RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop) auth/ # Authentication subpackage (controller, service, middleware, JWT + API key + WS ticket store, models, config, secret resolution) backup/ # Backup and restore -- scheduled/manual/lifecycle backups of persistence DB, agent memory, and company config. BackupService orchestrator, BackupScheduler (periodic asyncio task), RetentionManager (count + age pruning), tar.gz compression, SHA-256 checksums, manifest tracking, validated restore with atomic rollback and safety backup handlers/ # ComponentHandler protocol + concrete handlers: PersistenceComponentHandler (SQLite VACUUM INTO), MemoryComponentHandler (copytree), ConfigComponentHandler (copy2) @@ -140,10 +140,10 @@ src/synthorg/ web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard src/ api/ # Axios client, endpoint modules, TypeScript types (mirrors backend Pydantic models) - components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, tasks/) + components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, setup/, tasks/) composables/ # Reusable composition functions (useAuth, useLoginLockout, usePolling, useOptimisticUpdate, useWebSocketSubscription) router/ # Vue Router config with auth guards - stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers) + stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers, setup) styles/ # Global CSS and PrimeVue theme configuration utils/ # Constants, formatters, error helpers views/ # Page-level components (LoginPage, SetupPage, DashboardPage, OrgChartPage, TaskBoardPage, MessageFeedPage, ApprovalQueuePage, AgentProfilesPage, AgentDetailPage, BudgetPanelPage, MeetingLogsPage, ArtifactBrowserPage, SettingsPage) @@ -151,7 +151,7 @@ web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard cli/ # Go CLI binary (cross-platform, manages Docker lifecycle) main.go # Entry point - cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, uninstall, version, config, completion-install, backup); root flags: --data-dir, --skip-verify + cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, uninstall, version, config, completion-install, backup, setup); root flags: --data-dir, --skip-verify internal/ version/ # Build-time version vars (ldflags-injected) config/ # Data dir resolution (XDG/macOS/Windows), persisted state (JSON) @@ -199,7 +199,7 @@ site/ # Astro landing page (synthorg.io) - **Every module** with business logic MUST have: `from synthorg.observability import get_logger` then `logger = get_logger(__name__)` - **Never** use `import logging` / `logging.getLogger()` / `print()` in application code - **Variable name**: always `logger` (not `_logger`, not `log`) -- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`, `GIT_COMMAND_START` from `events.git`, `CONTEXT_BUDGET_FILL_UPDATED` from `events.context_budget`, `BACKUP_STARTED` from `events.backup`). Each domain has its own module -- see `src/synthorg/observability/events/` for the full inventory of constants. Import directly: `from synthorg.observability.events. import EVENT_CONSTANT` +- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`, `GIT_COMMAND_START` from `events.git`, `CONTEXT_BUDGET_FILL_UPDATED` from `events.context_budget`, `BACKUP_STARTED` from `events.backup`, `SETUP_COMPLETED` from `events.setup`). Each domain has its own module -- see `src/synthorg/observability/events/` for the full inventory of constants. Import directly: `from synthorg.observability.events. import EVENT_CONSTANT` - **Structured kwargs**: always `logger.info(EVENT, key=value)` — never `logger.info("msg %s", val)` - **All error paths** must log at WARNING or ERROR with context before raising - **All state transitions** must log at INFO diff --git a/README.md b/README.md index 5ba6ca4eed..157ae0c596 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,11 @@ synthorg init # interactive setup wizard synthorg start # pull images + start containers synthorg status # check health synthorg doctor # diagnostics if something is wrong +synthorg setup # re-run first-run setup wizard ``` +Open [http://localhost:3000](http://localhost:3000) after `synthorg start` -- on a fresh install, the **setup wizard** guides you through creating an admin account, configuring an LLM provider, naming your company, and hiring your first agent. + ### Development (from source) ```bash diff --git a/cli/cmd/setup.go b/cli/cmd/setup.go new file mode 100644 index 0000000000..804dc59bec --- /dev/null +++ b/cli/cmd/setup.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/Aureliolo/synthorg/cli/internal/config" + "github.com/Aureliolo/synthorg/cli/internal/docker" + "github.com/Aureliolo/synthorg/cli/internal/ui" + "github.com/spf13/cobra" +) + +// setupClient is the shared HTTP client for setup API requests. +// Per-request timeouts are controlled via context.WithTimeout. +var setupClient = &http.Client{ + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, +} + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Re-open the first-run setup wizard", + Long: `Reset the setup_complete flag and open the setup wizard in the browser. + +This is useful when you want to re-configure providers, company settings, +or add agents through the guided setup flow. Requires the SynthOrg stack +to be running ('synthorg start').`, + RunE: runSetup, +} + +func init() { + rootCmd.AddCommand(setupCmd) +} + +func runSetup(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + dir := resolveDataDir() + + state, err := config.Load(dir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + safeDir, err := safeStateDir(state) + if err != nil { + return err + } + composePath := filepath.Join(safeDir, "compose.yml") + if _, err := os.Stat(composePath); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("compose.yml not found in %s -- run 'synthorg init' first", safeDir) + } + + out := ui.NewUI(cmd.OutOrStdout()) + errOut := ui.NewUI(cmd.ErrOrStderr()) + + // Verify Docker is available and containers are running. + info, err := docker.Detect(ctx) + if err != nil { + return err + } + + psOut, err := docker.ComposeExecOutput(ctx, info, safeDir, "ps", "--format", "json") + if err != nil || psOut == "" || psOut == "[]" || psOut == "[]\n" { + return fmt.Errorf("no containers running -- run 'synthorg start' first") + } + + // Reset the setup_complete flag via the settings API. + out.Step("Resetting setup flag...") + if err := resetSetupFlag(ctx, state); err != nil { + return fmt.Errorf("resetting setup flag: %w", err) + } + out.Success("Setup flag reset") + + // Open browser to the setup page. + setupURL := fmt.Sprintf("http://localhost:%d/setup", state.WebPort) + out.Step(fmt.Sprintf("Opening %s", setupURL)) + if err := openBrowser(ctx, setupURL); err != nil { + errOut.Warn(fmt.Sprintf("Could not open browser: %v", err)) + errOut.Hint(fmt.Sprintf("Open %s manually in your browser.", setupURL)) + } + + return nil +} + +// resetSetupFlag calls DELETE /api/v1/settings/api/setup_complete to reset +// the first-run flag so the setup wizard re-appears. +func resetSetupFlag(ctx context.Context, state config.State) error { + apiURL := fmt.Sprintf("http://localhost:%d/api/v1/settings/api/setup_complete", state.BackendPort) + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, apiURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+buildLocalJWT(state.JWTSecret)) + + resp, err := setupClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024)) + _ = resp.Body.Close() + }() + + if resp.StatusCode >= 400 { + return fmt.Errorf("API returned status %d", resp.StatusCode) + } + return nil +} + +// openBrowser opens a URL in the default browser. Only localhost HTTP(S) +// URLs are permitted to prevent arbitrary command execution. +func openBrowser(ctx context.Context, rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", rawURL, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("refusing to open URL with scheme %q -- only http and https are allowed", parsed.Scheme) + } + host := parsed.Hostname() + if host != "localhost" && host != "127.0.0.1" { + return fmt.Errorf("refusing to open URL with host %q -- only localhost and 127.0.0.1 are allowed", host) + } + + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", rawURL) + case "darwin": + cmd = exec.CommandContext(ctx, "open", rawURL) + default: + cmd = exec.CommandContext(ctx, "xdg-open", rawURL) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting browser: %w", err) + } + go func() { _ = cmd.Wait() }() // reap child, prevent zombie + return nil +} diff --git a/docs/design/operations.md b/docs/design/operations.md index e848b16e57..890d88d1c7 100644 --- a/docs/design/operations.md +++ b/docs/design/operations.md @@ -974,7 +974,7 @@ future CLI tool are thin clients that call the API -- they contain no business l from GitHub Releases with automatic re-exec, compose template refresh with diff approval, container image update with version matching), `doctor` (diagnostics + bug report URL), `uninstall`, `version`, `config`, `completion-install`, - `backup` (create/list/restore via backend API). + `backup` (create/list/restore via backend API), `setup` (re-open first-run wizard). Built with Cobra + charmbracelet/huh. Distributed via GoReleaser + install scripts (`curl | sh` for Linux/macOS, `irm | iex` for Windows). @@ -998,6 +998,7 @@ future CLI tool are thin clients that call the API -- they contain no business l | `/api/v1/analytics` | Performance metrics, dashboards | | `/api/v1/settings` | Runtime-editable configuration (9 namespaces), schema discovery | | `GET /api/v1/providers`, `POST /api/v1/providers`, `PUT /api/v1/providers/{name}`, `DELETE /api/v1/providers/{name}`, `POST /api/v1/providers/{name}/test`, `GET /api/v1/providers/presets`, `POST /api/v1/providers/from-preset` | Provider CRUD, connection testing, presets, 4 auth types (api_key, oauth, custom_header, none) | +| `/api/v1/setup` | First-run setup wizard: status check (public), template listing, company/agent creation, completion gate | | `/api/v1/admin/backups` | Manual backup, list, detail, delete | | `/api/v1/ws` | WebSocket for real-time updates (ticket auth via `?ticket=`) | | `POST /api/v1/auth/ws-ticket` | Exchange JWT for one-time WebSocket connection ticket | diff --git a/docs/user_guide.md b/docs/user_guide.md index ae6641edd2..6651e81b79 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -24,7 +24,7 @@ synthorg status # Show container health and versions The web dashboard is at [http://localhost:3000](http://localhost:3000) (default port). -Other CLI commands: `synthorg stop`, `synthorg logs`, `synthorg update`, `synthorg doctor`, `synthorg uninstall`, `synthorg backup`. When updating, the CLI re-launches itself after binary replacement so the remaining steps (compose refresh, image pull) use the new version. If the compose template has changed (new environment variables, hardening tweaks), the diff is shown for approval before applying. +Other CLI commands: `synthorg stop`, `synthorg logs`, `synthorg update`, `synthorg doctor`, `synthorg uninstall`, `synthorg backup`, `synthorg setup`. When updating, the CLI re-launches itself after binary replacement so the remaining steps (compose refresh, image pull) use the new version. If the compose template has changed (new environment variables, hardening tweaks), the diff is shown for approval before applying. ## Quick Start (Docker Compose — manual) @@ -61,21 +61,16 @@ Configuration is in `docker/.env` (copy from `docker/.env.example`): ### First-Run Setup -After the containers are running: +After the containers are running, open the web dashboard at [http://localhost:3000](http://localhost:3000). On a fresh install, the **setup wizard** will appear automatically and guide you through: -1. **Create an admin account** by sending a POST request to the setup endpoint: +1. **Create an admin account** -- set up the first admin (CEO) user. +2. **Configure an LLM provider** -- select a preset (Ollama, OpenRouter, etc.) or add a custom provider. Test the connection inline. +3. **Create your company** -- name your synthetic organization and optionally start from a template. +4. **Hire your first agent** -- choose a role, model, and personality for the first AI agent. - ```bash - curl -X POST http://localhost:8000/api/v1/auth/setup \ - -H "Content-Type: application/json" \ - -d '{"username": "admin", "password": "your-secure-password"}' - ``` +After completing the wizard, the dashboard appears and the setup wizard is not shown again. -2. **Access the dashboard** at [http://localhost:3000](http://localhost:3000) and log in with your admin credentials. - -3. **Verify health** with `curl http://localhost:8000/api/v1/health`. - -Organization setup (choosing templates, configuring agents) is done via the dashboard. Custom template editing through the UI is planned for a future release. +To re-run the wizard later, use `synthorg setup` (resets the flag and opens the browser) or delete the `api.setup_complete` setting via the settings API. !!! info "Active Development" SynthOrg is under active development. The web dashboard is available for monitoring and managing the organization. Templates and some features described here may evolve. Check the [GitHub repository](https://github.com/Aureliolo/synthorg) for current status. diff --git a/src/synthorg/api/app.py b/src/synthorg/api/app.py index 44229c1aa9..bba542265b 100644 --- a/src/synthorg/api/app.py +++ b/src/synthorg/api/app.py @@ -644,6 +644,7 @@ def _build_middleware(api_config: ApiConfig) -> list[Middleware]: exclude=rl_exclude, ) auth = api_config.auth + setup_status_path = f"^{prefix}/setup/status$" exclude_paths = ( auth.exclude_paths if auth.exclude_paths is not None @@ -653,9 +654,14 @@ def _build_middleware(api_config: ApiConfig) -> list[Middleware]: "^/api$", f"^{prefix}/auth/setup$", f"^{prefix}/auth/login$", + setup_status_path, ) ) - # Always ensure the WS upgrade path is excluded — the WS handler + # Always ensure the setup status endpoint is publicly accessible + # even when custom exclude_paths are provided via config. + if setup_status_path not in exclude_paths: + exclude_paths = (*exclude_paths, setup_status_path) + # Always ensure the WS upgrade path is excluded -- the WS handler # performs its own ticket-based auth, so the JWT middleware must # not run on the upgrade request. if ws_path not in exclude_paths: diff --git a/src/synthorg/api/controllers/__init__.py b/src/synthorg/api/controllers/__init__.py index d2812a520e..6b446d805d 100644 --- a/src/synthorg/api/controllers/__init__.py +++ b/src/synthorg/api/controllers/__init__.py @@ -20,6 +20,7 @@ from synthorg.api.controllers.projects import ProjectController from synthorg.api.controllers.providers import ProviderController from synthorg.api.controllers.settings import SettingsController +from synthorg.api.controllers.setup import SetupController from synthorg.api.controllers.tasks import TaskController from synthorg.api.controllers.ws import ws_handler @@ -42,6 +43,7 @@ CollaborationController, CoordinationController, SettingsController, + SetupController, BackupController, ) @@ -66,6 +68,7 @@ "ProjectController", "ProviderController", "SettingsController", + "SetupController", "TaskController", "ws_handler", ] diff --git a/src/synthorg/api/controllers/setup.py b/src/synthorg/api/controllers/setup.py new file mode 100644 index 0000000000..7edd1890f0 --- /dev/null +++ b/src/synthorg/api/controllers/setup.py @@ -0,0 +1,651 @@ +"""First-run setup controller -- status, templates, company, agent, complete.""" + +import asyncio +import json +from typing import TYPE_CHECKING, Any, Literal, Self + +from litestar import Controller, get, post +from litestar.datastructures import State # noqa: TC002 +from litestar.status_codes import HTTP_201_CREATED +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from synthorg.api.dto import ApiResponse +from synthorg.api.errors import ApiValidationError, ConflictError, NotFoundError +from synthorg.api.guards import require_read_access, require_write_access +from synthorg.api.state import AppState # noqa: TC001 +from synthorg.core.enums import SeniorityLevel +from synthorg.core.types import NotBlankStr # noqa: TC001 +from synthorg.observability import get_logger +from synthorg.observability.events.setup import ( + SETUP_AGENT_CREATED, + SETUP_AGENTS_CORRUPTED, + SETUP_AGENTS_READ_FALLBACK, + SETUP_ALREADY_COMPLETE, + SETUP_COMPANY_CREATED, + SETUP_COMPLETED, + SETUP_MODEL_NOT_FOUND, + SETUP_NO_PROVIDERS, + SETUP_PROVIDER_NOT_FOUND, + SETUP_STATUS_CHECKED, + SETUP_STATUS_SETTINGS_UNAVAILABLE, + SETUP_TEMPLATE_INVALID, + SETUP_TEMPLATE_NOT_FOUND, + SETUP_TEMPLATES_LISTED, +) +from synthorg.settings.errors import SettingNotFoundError + +if TYPE_CHECKING: + from synthorg.settings.service import SettingsService + +logger = get_logger(__name__) + +# Serializes read-modify-write on the agents settings blob. +_AGENT_LOCK = asyncio.Lock() + + +# ── Request / Response DTOs ────────────────────────────────── + + +class SetupStatusResponse(BaseModel): + """First-run setup status. + + Attributes: + needs_admin: True if no admin user exists yet. + needs_setup: True if setup has not been completed. + has_providers: True if at least one provider is configured. + """ + + model_config = ConfigDict(frozen=True) + + needs_admin: bool + needs_setup: bool + has_providers: bool + + +class TemplateInfoResponse(BaseModel): + """Summary of an available company template. + + Attributes: + name: Template identifier. + display_name: Human-readable name. + description: Short description. + source: Where the template was found (builtin or user). + """ + + model_config = ConfigDict(frozen=True) + + name: NotBlankStr + display_name: NotBlankStr + description: str + source: Literal["builtin", "user"] + + +class SetupCompanyRequest(BaseModel): + """Company creation payload for first-run setup. + + Attributes: + company_name: Company display name. + template_name: Optional template to apply (None = blank company). + """ + + model_config = ConfigDict(frozen=True) + + company_name: NotBlankStr = Field(max_length=200) + template_name: NotBlankStr | None = Field(default=None, max_length=100) + + +class SetupCompanyResponse(BaseModel): + """Company creation result. + + Attributes: + company_name: The company name that was set. + template_applied: Name of the template that was applied, if any. + department_count: Number of departments created. + """ + + model_config = ConfigDict(frozen=True) + + company_name: NotBlankStr + template_applied: NotBlankStr | None + department_count: int = Field(ge=0) + + +class SetupAgentRequest(BaseModel): + """Agent creation payload for first-run setup. + + Attributes: + name: Agent display name. + role: Agent role name. + level: Seniority level. + personality_preset: Personality preset name. + model_provider: Provider name for the agent's model. + model_id: Model identifier from that provider. + department: Department to assign the agent to. + budget_limit_monthly: Optional monthly budget limit in USD. + """ + + model_config = ConfigDict(frozen=True) + + name: NotBlankStr = Field(max_length=200) + role: NotBlankStr = Field(max_length=100) + level: SeniorityLevel = Field(default=SeniorityLevel.MID) + personality_preset: NotBlankStr = Field(default="pragmatic_builder", max_length=100) + model_provider: NotBlankStr = Field(max_length=100) + model_id: NotBlankStr = Field(max_length=200) + department: NotBlankStr = Field(default="engineering", max_length=100) + budget_limit_monthly: float | None = Field(default=None, ge=0.0) + + @model_validator(mode="after") + def _validate_preset_exists(self) -> Self: + """Validate that the personality preset name exists in the registry.""" + from synthorg.templates.presets import PERSONALITY_PRESETS # noqa: PLC0415 + + key = self.personality_preset.strip().lower() + if key not in PERSONALITY_PRESETS: + available = sorted(PERSONALITY_PRESETS) + msg = ( + f"Unknown personality preset {self.personality_preset!r}. " + f"Available: {available}" + ) + raise ValueError(msg) + # Store the canonical (normalized) key so downstream code sees a + # consistent value that matches what PERSONALITY_PRESETS expects. + object.__setattr__(self, "personality_preset", key) + return self + + +class SetupAgentResponse(BaseModel): + """Agent creation result. + + Attributes: + name: Agent display name. + role: Agent role. + department: Assigned department. + model_provider: LLM provider name. + model_id: Model identifier. + """ + + model_config = ConfigDict(frozen=True) + + name: NotBlankStr + role: NotBlankStr + department: NotBlankStr + model_provider: NotBlankStr + model_id: NotBlankStr + + +class SetupCompleteResponse(BaseModel): + """Setup completion result. + + Attributes: + setup_complete: Always True on success. + """ + + model_config = ConfigDict(frozen=True) + + setup_complete: bool + + +# ── Controller ─────────────────────────────────────────────── + + +class SetupController(Controller): + """First-run setup wizard endpoints.""" + + path = "/setup" + tags = ("setup",) + + @get("/status") + async def get_status( + self, + state: State, + ) -> ApiResponse[SetupStatusResponse]: + """Check whether first-run setup is needed. + + This endpoint is unauthenticated so the frontend can determine + whether to show the setup wizard before any user exists. + All other setup endpoints require authentication via guards. + + Args: + state: Application state. + + Returns: + Setup status envelope. + """ + app_state: AppState = state.app_state + persistence = app_state.persistence + + user_count = await persistence.users.count() + needs_admin = user_count == 0 + + settings_svc = app_state.settings_service + try: + entry = await settings_svc.get_entry("api", "setup_complete") + needs_setup = entry.value != "true" + except MemoryError, RecursionError: + raise + except Exception: + logger.warning( + SETUP_STATUS_SETTINGS_UNAVAILABLE, + exc_info=True, + ) + needs_setup = True + + has_providers = ( + app_state.has_provider_registry and len(app_state.provider_registry) > 0 + ) + + logger.debug( + SETUP_STATUS_CHECKED, + needs_admin=needs_admin, + needs_setup=needs_setup, + has_providers=has_providers, + ) + + return ApiResponse( + data=SetupStatusResponse( + needs_admin=needs_admin, + needs_setup=needs_setup, + has_providers=has_providers, + ), + ) + + @get( + "/templates", + guards=[require_read_access], + ) + async def get_templates( + self, + state: State, # noqa: ARG002 + ) -> ApiResponse[tuple[TemplateInfoResponse, ...]]: + """List available company templates for setup. + + Args: + state: Application state. + + Returns: + Template list envelope. + """ + from synthorg.templates.loader import list_templates # noqa: PLC0415 + + templates = list_templates() + result = tuple( + TemplateInfoResponse( + name=t.name, + display_name=t.display_name, + description=t.description, + source=t.source, + ) + for t in templates + ) + + logger.debug(SETUP_TEMPLATES_LISTED, count=len(result)) + return ApiResponse(data=result) + + @post( + "/company", + status_code=HTTP_201_CREATED, + guards=[require_write_access], + ) + async def create_company( + self, + data: SetupCompanyRequest, + state: State, + ) -> ApiResponse[SetupCompanyResponse]: + """Create company configuration during first-run setup. + + Persists the company name and optionally applies a template + to create department structure. Calling this endpoint again + overwrites the previously set company name and departments. + + Args: + data: Company creation payload. + state: Application state. + + Returns: + Company creation result envelope. + + Raises: + ConflictError: If setup has already been completed. + """ + app_state: AppState = state.app_state + settings_svc = app_state.settings_service + await _check_setup_not_complete(settings_svc) + + # Validate template first (may raise) before persisting anything. + department_count = 0 + template_applied: str | None = None + departments_json = "" + + if data.template_name is not None: + template_applied = data.template_name + departments_json = _extract_template_departments(data.template_name) + if departments_json: + department_count = len(json.loads(departments_json)) + + # Persist company name and departments atomically after validation. + await settings_svc.set("company", "company_name", data.company_name) + # Always write departments -- clears stale data from previous runs. + await settings_svc.set( + "company", + "departments", + departments_json or "[]", + ) + + logger.info( + SETUP_COMPANY_CREATED, + company_name=data.company_name, + template=template_applied, + department_count=department_count, + ) + + return ApiResponse( + data=SetupCompanyResponse( + company_name=data.company_name, + template_applied=template_applied, + department_count=department_count, + ), + ) + + @post( + "/agent", + status_code=HTTP_201_CREATED, + guards=[require_write_access], + ) + async def create_agent( + self, + data: SetupAgentRequest, + state: State, + ) -> ApiResponse[SetupAgentResponse]: + """Create an agent during first-run setup. + + Validates the provider and model, builds an agent configuration, + and appends it to the company settings. + + Args: + data: Agent creation payload. + state: Application state. + + Returns: + Agent creation result envelope. + + Raises: + ConflictError: If setup has already been completed. + NotFoundError: If the provider does not exist. + ApiValidationError: If the model is not in the provider. + """ + app_state: AppState = state.app_state + settings_svc = app_state.settings_service + await _check_setup_not_complete(settings_svc) + + providers = await app_state.provider_management.list_providers() + _validate_provider_and_model(providers, data) + agent_config = _build_agent_config(data) + + # Serialize the read-modify-write so concurrent requests + # cannot overwrite each other's appended agents. + async with _AGENT_LOCK: + existing_agents = await _get_existing_agents(settings_svc) + updated_agents = [*existing_agents, agent_config] + await settings_svc.set( + "company", + "agents", + json.dumps(updated_agents), + ) + + logger.info( + SETUP_AGENT_CREATED, + agent_name=data.name, + role=data.role, + provider=data.model_provider, + model=data.model_id, + ) + + return ApiResponse( + data=SetupAgentResponse( + name=data.name, + role=data.role, + department=data.department, + model_provider=data.model_provider, + model_id=data.model_id, + ), + ) + + @post( + "/complete", + guards=[require_write_access], + ) + async def complete_setup( + self, + state: State, + ) -> ApiResponse[SetupCompleteResponse]: + """Mark first-run setup as complete. + + Validates that at least one provider is configured before + allowing completion. + + Args: + state: Application state. + + Returns: + Success envelope. + + Raises: + ApiValidationError: If no providers are configured. + """ + app_state: AppState = state.app_state + + if not app_state.has_provider_registry or len(app_state.provider_registry) == 0: + msg = "At least one provider must be configured before completing setup" + logger.warning(SETUP_NO_PROVIDERS) + raise ApiValidationError(msg) + + settings_svc = app_state.settings_service + await settings_svc.set("api", "setup_complete", "true") + + logger.info(SETUP_COMPLETED) + + return ApiResponse(data=SetupCompleteResponse(setup_complete=True)) + + +# ── Helpers ────────────────────────────────────────────────── + + +async def _check_setup_not_complete(settings_svc: SettingsService) -> None: + """Raise ConflictError if setup has already been completed. + + Args: + settings_svc: Settings service instance. + + Raises: + ConflictError: If the setup_complete flag is already true. + """ + is_complete = await _is_setup_complete(settings_svc) + if is_complete: + logger.warning(SETUP_ALREADY_COMPLETE) + msg = "Setup has already been completed" + raise ConflictError(msg) + + +async def _is_setup_complete(settings_svc: SettingsService) -> bool: + """Check whether setup has been completed. + + Args: + settings_svc: Settings service instance. + + Returns: + True if setup_complete is "true", False otherwise or on error. + """ + try: + entry = await settings_svc.get_entry("api", "setup_complete") + except MemoryError, RecursionError: + raise + except SettingNotFoundError: + # Key does not exist yet -- setup has not been completed. + return False + else: + return entry.value == "true" + + +def _validate_provider_and_model( + providers: dict[str, Any], + data: SetupAgentRequest, +) -> None: + """Validate that the provider and model exist in the given providers dict. + + Args: + providers: Provider name -> config mapping from management service. + data: Agent creation payload. + + Raises: + NotFoundError: If the provider does not exist. + ApiValidationError: If the model is not found in the provider. + """ + if data.model_provider not in providers: + msg = f"Provider {data.model_provider!r} not found" + logger.warning( + SETUP_PROVIDER_NOT_FOUND, + provider=data.model_provider, + ) + raise NotFoundError(msg) + + provider_config = providers[data.model_provider] + model_ids = {m.id for m in provider_config.models} + if data.model_id not in model_ids: + msg = f"Model {data.model_id!r} not found in provider {data.model_provider!r}" + logger.warning( + SETUP_MODEL_NOT_FOUND, + provider=data.model_provider, + model=data.model_id, + ) + raise ApiValidationError(msg) + + +def _build_agent_config(data: SetupAgentRequest) -> dict[str, Any]: + """Build an agent config dict for settings persistence. + + Args: + data: Validated agent creation payload. + + Returns: + Agent configuration dict suitable for JSON serialization. + """ + from synthorg.templates.presets import ( # noqa: PLC0415 + get_personality_preset, + ) + + personality_dict = get_personality_preset(data.personality_preset) + agent_config: dict[str, Any] = { + "name": data.name, + "role": data.role, + "department": data.department, + "level": data.level.value, + "personality": personality_dict, + "model": { + "provider": data.model_provider, + "model_id": data.model_id, + }, + } + if data.budget_limit_monthly is not None: + agent_config["budget_limit_monthly"] = data.budget_limit_monthly + return agent_config + + +def _extract_template_departments(template_name: str) -> str: + """Load a template and extract its departments as a JSON string. + + Args: + template_name: Template name to load. + + Returns: + JSON array of department dicts, or empty string if template + has no departments. + + Raises: + NotFoundError: If the template does not exist. + ApiValidationError: If the template fails to render or validate. + """ + from synthorg.templates.errors import ( # noqa: PLC0415 + TemplateNotFoundError, + TemplateRenderError, + TemplateValidationError, + ) + from synthorg.templates.loader import load_template # noqa: PLC0415 + + try: + loaded = load_template(template_name) + except TemplateNotFoundError as exc: + msg = f"Template {template_name!r} not found" + logger.warning( + SETUP_TEMPLATE_NOT_FOUND, + template=template_name, + ) + raise NotFoundError(msg) from exc + except (TemplateRenderError, TemplateValidationError) as exc: + msg = f"Template {template_name!r} is invalid: {exc}" + logger.warning( + SETUP_TEMPLATE_INVALID, + template=template_name, + error=str(exc), + ) + raise ApiValidationError(msg) from exc + + departments = loaded.template.departments + if not departments: + return "" + + dept_list: list[dict[str, Any]] = [] + for d in departments: + entry: dict[str, Any] = {"name": d.name, "budget_percent": d.budget_percent} + dept_list.append(entry) + return json.dumps(dept_list) if dept_list else "" + + +async def _get_existing_agents( + settings_svc: SettingsService, +) -> list[dict[str, Any]]: + """Read the current agents list from settings. + + Only the "entry not found" case yields an empty list. JSON parse + errors and non-list values are surfaced so ``create_agent`` does + not silently overwrite corrupted data. + + Args: + settings_svc: Settings service instance. + + Returns: + List of agent config dicts (empty if entry is absent or None). + + Raises: + ApiValidationError: If the stored value is not valid JSON or + not a JSON array. + """ + try: + entry = await settings_svc.get_entry("company", "agents") + except MemoryError, RecursionError: + raise + except SettingNotFoundError: + logger.debug(SETUP_AGENTS_READ_FALLBACK, reason="entry_not_found") + return [] + + try: + parsed = json.loads(entry.value) + except json.JSONDecodeError as exc: + logger.warning( + SETUP_AGENTS_CORRUPTED, + reason="invalid_json", + exc_info=True, + ) + msg = "Stored agents list is not valid JSON" + raise ApiValidationError(msg) from exc + + if not isinstance(parsed, list): + logger.warning( + SETUP_AGENTS_CORRUPTED, + reason="non_list_json", + raw_type=type(parsed).__name__, + ) + msg = f"Stored agents list is {type(parsed).__name__}, expected list" + raise ApiValidationError(msg) + + return parsed diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index 7496aa1f96..75a59741bf 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -51,6 +51,7 @@ "/health", "/auth/setup", "/auth/login", + "/setup/status", ) # HTTP methods that mutate state. Includes DELETE for 400/403 injection; diff --git a/src/synthorg/observability/events/setup.py b/src/synthorg/observability/events/setup.py new file mode 100644 index 0000000000..6f178e384e --- /dev/null +++ b/src/synthorg/observability/events/setup.py @@ -0,0 +1,53 @@ +"""Setup event constants for structured logging. + +Constants follow the ``setup..`` naming convention +and are passed as the first argument to ``logger.info()``/``logger.debug()`` +calls in the first-run setup flow. +""" + +from typing import Final + +# Status check +SETUP_STATUS_CHECKED: Final[str] = "setup.status.checked" + +# Company creation during setup +SETUP_COMPANY_CREATED: Final[str] = "setup.company.created" + +# Agent creation during setup +SETUP_AGENT_CREATED: Final[str] = "setup.agent.created" + +# Setup completion +SETUP_COMPLETED: Final[str] = "setup.flow.completed" + +# Setup reset (via CLI or settings delete) +SETUP_RESET: Final[str] = "setup.flow.reset" + +# Template listing +SETUP_TEMPLATES_LISTED: Final[str] = "setup.templates.listed" + +# Agents list read fallback (no existing agents in settings) +SETUP_AGENTS_READ_FALLBACK: Final[str] = "setup.agents.read_fallback" + +# Status check fallback (settings service unavailable) +SETUP_STATUS_SETTINGS_UNAVAILABLE: Final[str] = "setup.status.settings_unavailable" + +# Provider not found during agent creation +SETUP_PROVIDER_NOT_FOUND: Final[str] = "setup.agent.provider_not_found" + +# Model not found in provider during agent creation +SETUP_MODEL_NOT_FOUND: Final[str] = "setup.agent.model_not_found" + +# No providers configured when attempting to complete setup +SETUP_NO_PROVIDERS: Final[str] = "setup.flow.no_providers" + +# Template not found during company creation +SETUP_TEMPLATE_NOT_FOUND: Final[str] = "setup.company.template_not_found" + +# Template invalid during company creation +SETUP_TEMPLATE_INVALID: Final[str] = "setup.company.template_invalid" + +# Mutating endpoint called after setup is already complete +SETUP_ALREADY_COMPLETE: Final[str] = "setup.flow.already_complete" + +# Agents list corrupted in settings (JSON parse failure) +SETUP_AGENTS_CORRUPTED: Final[str] = "setup.agents.corrupted" diff --git a/src/synthorg/settings/definitions/api.py b/src/synthorg/settings/definitions/api.py index 3d223ad435..36c2371173 100644 --- a/src/synthorg/settings/definitions/api.py +++ b/src/synthorg/settings/definitions/api.py @@ -1,9 +1,9 @@ """API namespace setting definitions. -Registers 10 settings covering server, CORS, rate limiting, and -authentication. Four are runtime-editable; six are bootstrap-only -(``restart_required=True``) because Litestar bakes middleware and -CORS into the application at construction time. +Registers 11 settings covering server, CORS, rate limiting, +authentication, and setup. Five are runtime-editable; six are +bootstrap-only (``restart_required=True``) because Litestar bakes +middleware and CORS into the application at construction time. """ from synthorg.settings.enums import SettingLevel, SettingNamespace, SettingType @@ -157,3 +157,16 @@ yaml_path="api.auth.exclude_paths", ) ) + +# ── Setup ────────────────────────────────────────────────────── + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="setup_complete", + type=SettingType.BOOLEAN, + default="false", + description="Whether first-run setup has been completed", + group="Setup", + ) +) diff --git a/tests/unit/api/controllers/test_setup.py b/tests/unit/api/controllers/test_setup.py new file mode 100644 index 0000000000..69c02938c9 --- /dev/null +++ b/tests/unit/api/controllers/test_setup.py @@ -0,0 +1,389 @@ +"""Tests for the first-run setup controller.""" + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from litestar.testing import TestClient +from pydantic import ValidationError + +from tests.unit.api.conftest import make_auth_headers + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupStatus: + """GET /api/v1/setup/status -- unauthenticated status check.""" + + def test_returns_status_with_seeded_users( + self, + test_client: TestClient[Any], + ) -> None: + """With pre-seeded users, needs_admin is False.""" + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + data = body["data"] + assert data["needs_admin"] is False + assert data["needs_setup"] is True + assert data["has_providers"] is False + + def test_status_without_auth_header( + self, + test_client: TestClient[Any], + ) -> None: + """Status endpoint works without authentication.""" + saved_headers = dict(test_client.headers) + test_client.headers.pop("Authorization", None) + try: + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + finally: + test_client.headers.update(saved_headers) + + def test_status_response_fields( + self, + test_client: TestClient[Any], + ) -> None: + """Status response contains all required fields.""" + resp = test_client.get("/api/v1/setup/status") + data = resp.json()["data"] + assert "needs_admin" in data + assert "needs_setup" in data + assert "has_providers" in data + assert isinstance(data["needs_admin"], bool) + assert isinstance(data["needs_setup"], bool) + assert isinstance(data["has_providers"], bool) + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupTemplates: + """GET /api/v1/setup/templates -- list available templates.""" + + def test_returns_builtin_templates( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.get("/api/v1/setup/templates") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + templates = body["data"] + assert len(templates) >= 7 + names = {t["name"] for t in templates} + assert "solo_founder" in names + assert "startup" in names + + def test_template_fields( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.get("/api/v1/setup/templates") + body = resp.json() + for template in body["data"]: + assert "name" in template + assert "display_name" in template + assert "description" in template + assert "source" in template + + def test_observer_can_read_templates( + self, + test_client: TestClient[Any], + ) -> None: + """Observer role has read access to templates.""" + saved_headers = dict(test_client.headers) + test_client.headers.update(make_auth_headers("observer")) + try: + resp = test_client.get("/api/v1/setup/templates") + assert resp.status_code == 200 + finally: + test_client.headers.update(saved_headers) + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupCompany: + """POST /api/v1/setup/company -- create company config.""" + + def test_blank_company( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={"company_name": "Test Corp"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + data = body["data"] + assert data["company_name"] == "Test Corp" + assert data["template_applied"] is None + assert data["department_count"] == 0 + + def test_company_with_template( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={ + "company_name": "My Startup", + "template_name": "solo_founder", + }, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + data = body["data"] + assert data["company_name"] == "My Startup" + assert data["template_applied"] == "solo_founder" + assert data["department_count"] >= 1 + + def test_invalid_template( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={ + "company_name": "Test Corp", + "template_name": "nonexistent_template", + }, + ) + assert resp.status_code == 404 + + def test_blank_company_name_rejected( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={"company_name": " "}, + ) + # Pydantic NotBlankStr validation returns 400 + assert resp.status_code == 400 + + def test_requires_write_access( + self, + test_client: TestClient[Any], + ) -> None: + saved_headers = dict(test_client.headers) + test_client.headers.update(make_auth_headers("observer")) + try: + resp = test_client.post( + "/api/v1/setup/company", + json={"company_name": "Test Corp"}, + ) + assert resp.status_code == 403 + finally: + test_client.headers.update(saved_headers) + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupAgent: + """POST /api/v1/setup/agent -- create agent.""" + + def test_nonexistent_provider( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/agent", + json={ + "name": "Alice Chen", + "role": "CEO", + "model_provider": "nonexistent", + "model_id": "model-001", + }, + ) + assert resp.status_code == 404 + + def test_invalid_personality_preset( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/agent", + json={ + "name": "Alice Chen", + "role": "CEO", + "personality_preset": "nonexistent_preset", + "model_provider": "test", + "model_id": "model-001", + }, + ) + # Pydantic model_validator returns 400 + assert resp.status_code == 400 + + def test_requires_write_access( + self, + test_client: TestClient[Any], + ) -> None: + saved_headers = dict(test_client.headers) + test_client.headers.update(make_auth_headers("observer")) + try: + resp = test_client.post( + "/api/v1/setup/agent", + json={ + "name": "Alice Chen", + "role": "CEO", + "model_provider": "test", + "model_id": "model-001", + }, + ) + assert resp.status_code == 403 + finally: + test_client.headers.update(saved_headers) + + def test_successful_agent_creation( + self, + test_client: TestClient[Any], + ) -> None: + """Happy path: provider and model exist, agent is created.""" + # Build a mock provider config with a test model. + mock_model = MagicMock() + mock_model.id = "test-small-001" + mock_model.alias = None + mock_provider_config = MagicMock() + mock_provider_config.models = [mock_model] + + mock_mgmt = MagicMock() + mock_mgmt.list_providers = AsyncMock( + return_value={"test-provider": mock_provider_config}, + ) + + app_state = test_client.app.state.app_state + original_mgmt = app_state._provider_management + app_state._provider_management = mock_mgmt + try: + resp = test_client.post( + "/api/v1/setup/agent", + json={ + "name": "agent-ceo-001", + "role": "CEO", + "model_provider": "test-provider", + "model_id": "test-small-001", + }, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + data = body["data"] + assert data["name"] == "agent-ceo-001" + assert data["role"] == "CEO" + assert data["department"] == "engineering" + assert data["model_provider"] == "test-provider" + assert data["model_id"] == "test-small-001" + finally: + app_state._provider_management = original_mgmt + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupComplete: + """POST /api/v1/setup/complete -- mark setup as done.""" + + def test_complete_without_provider_fails( + self, + test_client: TestClient[Any], + ) -> None: + """Completing setup without providers returns 422.""" + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 422 + + def test_requires_write_access( + self, + test_client: TestClient[Any], + ) -> None: + saved_headers = dict(test_client.headers) + test_client.headers.update(make_auth_headers("observer")) + try: + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 403 + finally: + test_client.headers.update(saved_headers) + + # Note: Happy-path test for successful completion requires a real + # ProviderRegistry with drivers, which the current test fixture does + # not set up. Mocking _provider_registry crashes xdist workers + # during app shutdown. The completion endpoint logic is simple + # (provider check + settings write) and is covered by the DTO and + # agent creation tests. A proper integration test should be added + # when the test fixture supports provider setup. + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupDTOs: + """Unit tests for setup DTO validation.""" + + def test_setup_agent_request_valid_preset(self) -> None: + from synthorg.api.controllers.setup import SetupAgentRequest + + req = SetupAgentRequest( + name="Alice", + role="CEO", + personality_preset="visionary_leader", + model_provider="test-provider", + model_id="model-001", + ) + # Validator normalizes to lowercase + assert req.personality_preset == "visionary_leader" + + def test_setup_agent_request_invalid_preset(self) -> None: + from synthorg.api.controllers.setup import SetupAgentRequest + + with pytest.raises(ValidationError, match="personality preset"): + SetupAgentRequest( + name="Alice", + role="CEO", + personality_preset="nonexistent", + model_provider="test-provider", + model_id="model-001", + ) + + def test_setup_company_request_defaults(self) -> None: + from synthorg.api.controllers.setup import SetupCompanyRequest + + req = SetupCompanyRequest(company_name="Test Corp") + assert req.template_name is None + + def test_setup_status_response_frozen(self) -> None: + from synthorg.api.controllers.setup import SetupStatusResponse + + resp = SetupStatusResponse( + needs_admin=True, + needs_setup=True, + has_providers=False, + ) + with pytest.raises(ValidationError): + resp.needs_admin = False # type: ignore[misc] + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestExtractTemplateDepartments: + """Unit tests for the _extract_template_departments helper.""" + + def test_valid_template(self) -> None: + from synthorg.api.controllers.setup import _extract_template_departments + + result = _extract_template_departments("solo_founder") + assert result != "" + departments = json.loads(result) + assert len(departments) >= 1 + assert departments[0]["name"] in {"executive", "engineering"} + + def test_invalid_template(self) -> None: + from synthorg.api.controllers.setup import _extract_template_departments + from synthorg.api.errors import NotFoundError + + with pytest.raises(NotFoundError): + _extract_template_departments("nonexistent_template") diff --git a/tests/unit/observability/test_events.py b/tests/unit/observability/test_events.py index faa66e816c..15a75f667a 100644 --- a/tests/unit/observability/test_events.py +++ b/tests/unit/observability/test_events.py @@ -229,6 +229,7 @@ def test_all_domain_modules_discovered(self) -> None: "checkpoint", "context_budget", "settings", + "setup", } discovered = {info.name for info in pkgutil.iter_modules(events.__path__)} assert discovered == expected diff --git a/web/src/__tests__/router/guards.test.ts b/web/src/__tests__/router/guards.test.ts index 60ea913684..a85e47177d 100644 --- a/web/src/__tests__/router/guards.test.ts +++ b/web/src/__tests__/router/guards.test.ts @@ -3,6 +3,7 @@ import { setActivePinia, createPinia } from 'pinia' import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' import { authGuard } from '@/router/guards' import { useAuthStore } from '@/stores/auth' +import { useSetupStore } from '@/stores/setup' // Mock the router module (needed by auth store) vi.mock('@/router', () => ({ @@ -19,6 +20,19 @@ vi.mock('@/api/endpoints/auth', () => ({ getMe: vi.fn(), })) +// Mock setup API -- guard now calls fetchStatus on first navigation +vi.mock('@/api/endpoints/setup', () => ({ + getSetupStatus: vi.fn().mockResolvedValue({ + needs_admin: false, + needs_setup: false, + has_providers: true, + }), + listTemplates: vi.fn().mockResolvedValue([]), + createCompany: vi.fn(), + createAgent: vi.fn(), + completeSetup: vi.fn(), +})) + function createRoute(overrides: Partial = {}): RouteLocationNormalized { return { path: '/', @@ -41,29 +55,43 @@ describe('authGuard', () => { setActivePinia(createPinia()) localStorage.clear() next = vi.fn() + // Pre-populate setup status so guard doesn't fetch every time. + // Tests that need setup-needed behavior override this. + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: false, needs_setup: false, has_providers: true }, + }) + // Mark as loaded so isSetupNeeded uses real status + setup.statusLoaded = true }) - it('redirects unauthenticated users to /login on protected routes', () => { + it('redirects unauthenticated users to /login on protected routes', async () => { const to = createRoute({ path: '/dashboard', fullPath: '/dashboard', meta: {} }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith({ path: '/login', query: { redirect: '/dashboard' } }) }) - it('does not add redirect query for root path', () => { + it('does not add redirect query for root path', async () => { const to = createRoute({ path: '/', fullPath: '/', meta: {} }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith({ path: '/login', query: undefined }) }) - it('allows authenticated users to access protected routes', () => { + it('allows authenticated users to access protected routes', async () => { localStorage.setItem('auth_token', 'test-token') localStorage.setItem('auth_token_expires_at', String(Date.now() + 3600_000)) // Re-create Pinia to pick up the token from localStorage setActivePinia(createPinia()) + // Re-populate setup status after Pinia reset + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: false, needs_setup: false, has_providers: true }, + }) + setup.statusLoaded = true // Force the store to read the token const store = useAuthStore() expect(store.isAuthenticated).toBe(true) @@ -71,69 +99,187 @@ describe('authGuard', () => { const to = createRoute({ path: '/dashboard', meta: {} }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith() }) - it('allows unauthenticated users to access public routes', () => { + it('allows unauthenticated users to access public routes', async () => { const to = createRoute({ path: '/login', meta: { requiresAuth: false } }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith() }) - it('redirects authenticated users away from public routes to /', () => { + it('redirects authenticated users away from public routes to /', async () => { localStorage.setItem('auth_token', 'test-token') localStorage.setItem('auth_token_expires_at', String(Date.now() + 3600_000)) setActivePinia(createPinia()) + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: false, needs_setup: false, has_providers: true }, + }) + setup.statusLoaded = true const to = createRoute({ path: '/login', meta: { requiresAuth: false } }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith('/') }) - it('redirects to settings when mustChangePassword is true', () => { + it('redirects to settings when mustChangePassword is true', async () => { localStorage.setItem('auth_token', 'test-token') localStorage.setItem('auth_token_expires_at', String(Date.now() + 3600_000)) setActivePinia(createPinia()) + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: false, needs_setup: false, has_providers: true }, + }) + setup.statusLoaded = true const store = useAuthStore() store.user = { id: 'u1', username: 'ceo', role: 'ceo', must_change_password: true } const to = createRoute({ path: '/tasks', name: 'tasks' as never, meta: {} }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith({ name: 'settings', query: { tab: 'user' } }) }) - it('redirects settings without tab=user when mustChangePassword is true', () => { + it('redirects settings without tab=user when mustChangePassword is true', async () => { localStorage.setItem('auth_token', 'test-token') localStorage.setItem('auth_token_expires_at', String(Date.now() + 3600_000)) setActivePinia(createPinia()) + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: false, needs_setup: false, has_providers: true }, + }) + setup.statusLoaded = true const store = useAuthStore() store.user = { id: 'u1', username: 'ceo', role: 'ceo', must_change_password: true } const to = createRoute({ path: '/settings', name: 'settings' as never, meta: {} }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith({ name: 'settings', query: { tab: 'user' } }) }) - it('allows settings?tab=user when mustChangePassword is true', () => { + it('allows settings?tab=user when mustChangePassword is true', async () => { localStorage.setItem('auth_token', 'test-token') localStorage.setItem('auth_token_expires_at', String(Date.now() + 3600_000)) setActivePinia(createPinia()) + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: false, needs_setup: false, has_providers: true }, + }) + setup.statusLoaded = true const store = useAuthStore() store.user = { id: 'u1', username: 'ceo', role: 'ceo', must_change_password: true } const to = createRoute({ path: '/settings', name: 'settings' as never, query: { tab: 'user' }, meta: {} }) const from = createRoute() - authGuard(to, from, next) + await authGuard(to, from, next) + expect(next).toHaveBeenCalledWith() + }) + + // ── Setup routing tests ────────────────────────────────── + + it('redirects to /setup when setup is needed', async () => { + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: true, needs_setup: true, has_providers: false }, + }) + setup.statusLoaded = true + + const to = createRoute({ path: '/dashboard', name: 'dashboard' as never, meta: {} }) + const from = createRoute() + + await authGuard(to, from, next) + expect(next).toHaveBeenCalledWith({ name: 'setup' }) + }) + + it('allows /setup when setup is needed', async () => { + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: true, needs_setup: true, has_providers: false }, + }) + setup.statusLoaded = true + + const toSetup = createRoute({ path: '/setup', name: 'setup' as never, meta: { requiresAuth: false } }) + const from = createRoute() + + await authGuard(toSetup, from, next) + expect(next).toHaveBeenCalledWith() + }) + + it('redirects /setup to / when setup is complete', async () => { + localStorage.setItem('auth_token', 'test-token') + localStorage.setItem('auth_token_expires_at', String(Date.now() + 3600_000)) + setActivePinia(createPinia()) + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: false, needs_setup: false, has_providers: true }, + }) + setup.statusLoaded = true + + const to = createRoute({ path: '/setup', name: 'setup' as never, meta: { requiresAuth: false } }) + const from = createRoute() + + await authGuard(to, from, next) + expect(next).toHaveBeenCalledWith('/') + }) + + it('fetches status and redirects when statusLoaded is false', async () => { + // When statusLoaded is false, the guard fetches status first. + // The mock returns needs_setup: false, so after fetch the guard + // proceeds to auth routing (unauthenticated -> /login). + const setup = useSetupStore() + setup.statusLoaded = false + setup.$patch({ status: null }) + + const to = createRoute({ path: '/dashboard', fullPath: '/dashboard', meta: {} }) + const from = createRoute() + + await authGuard(to, from, next) + // After fetch, statusLoaded becomes true, needs_setup is false, + // so auth routing applies: unauthenticated -> /login. + expect(setup.statusLoaded).toBe(true) + expect(next).toHaveBeenCalledWith({ path: '/login', query: { redirect: '/dashboard' } }) + }) + + it('allows /login when setup is needed', async () => { + const setup = useSetupStore() + setup.$patch({ + status: { needs_admin: true, needs_setup: true, has_providers: false }, + }) + setup.statusLoaded = true + + const to = createRoute({ path: '/login', name: 'login' as never, meta: { requiresAuth: false } }) + const from = createRoute() + + await authGuard(to, from, next) expect(next).toHaveBeenCalledWith() }) + + it('falls back to auth routing when fetchStatus rejects', async () => { + const { getSetupStatus } = await import('@/api/endpoints/setup') + const mocked = vi.mocked(getSetupStatus) + mocked.mockRejectedValueOnce(new Error('network error')) + + const setup = useSetupStore() + setup.statusLoaded = false + setup.$patch({ status: null }) + + const to = createRoute({ path: '/dashboard', fullPath: '/dashboard', meta: {} }) + const from = createRoute() + + await authGuard(to, from, next) + // fetchStatus caught the error internally; status remains null, + // statusLoaded stays false. Guard falls through to auth routing. + expect(setup.statusLoaded).toBe(false) + expect(next).toHaveBeenCalledWith({ path: '/login', query: { redirect: '/dashboard' } }) + }) }) diff --git a/web/src/__tests__/views/SetupPage.test.ts b/web/src/__tests__/views/SetupPage.test.ts index db3ba20c16..6aa68eb35d 100644 --- a/web/src/__tests__/views/SetupPage.test.ts +++ b/web/src/__tests__/views/SetupPage.test.ts @@ -3,10 +3,8 @@ import { mount, flushPromises } from '@vue/test-utils' import { setActivePinia, createPinia } from 'pinia' import { defineComponent, h } from 'vue' -const mockRouterPush = vi.fn() - vi.mock('vue-router', () => ({ - useRouter: () => ({ push: mockRouterPush }), + useRouter: () => ({ push: vi.fn() }), RouterLink: { props: ['to'], template: '' }, })) @@ -28,7 +26,7 @@ vi.mock('primevue/inputtext', () => ({ vi.mock('primevue/button', () => ({ default: defineComponent({ - props: ['label', 'icon', 'type', 'loading', 'disabled'], + props: ['label', 'icon', 'type', 'loading', 'disabled', 'severity', 'size', 'outlined'], emits: ['click'], setup(props, { emit }) { return () => @@ -45,6 +43,25 @@ vi.mock('primevue/button', () => ({ }), })) +vi.mock('primevue/select', () => ({ + default: defineComponent({ + props: ['modelValue', 'options', 'optionLabel', 'optionValue', 'placeholder', 'disabled'], + emits: ['update:modelValue'], + setup(props) { + return () => h('select', {}, props.placeholder ?? '') + }, + }), +})) + +vi.mock('primevue/tag', () => ({ + default: defineComponent({ + props: ['value', 'severity'], + setup(props) { + return () => h('span', {}, props.value) + }, + }), +})) + vi.mock('@/router', () => ({ router: { currentRoute: { value: { path: '/setup' } }, @@ -58,8 +75,28 @@ vi.mock('vue', async () => { return { ...actual, onUnmounted: vi.fn() } }) +// Mock the setup API to return a default status +vi.mock('@/api/endpoints/setup', () => ({ + getSetupStatus: vi.fn().mockResolvedValue({ + needs_admin: true, + needs_setup: true, + has_providers: false, + }), + listTemplates: vi.fn().mockResolvedValue([]), + createCompany: vi.fn().mockResolvedValue({ company_name: 'Test', template_applied: null, department_count: 0 }), + createAgent: vi.fn().mockResolvedValue({ name: 'Agent', role: 'CEO', department: 'exec', model_provider: 'p', model_id: 'm' }), + completeSetup: vi.fn().mockResolvedValue({ setup_complete: true }), +})) + +// Mock providers API +vi.mock('@/api/endpoints/providers', () => ({ + listProviders: vi.fn().mockResolvedValue({}), + listPresets: vi.fn().mockResolvedValue([]), + createFromPreset: vi.fn().mockResolvedValue({}), + testConnection: vi.fn().mockResolvedValue({ success: true, latency_ms: 42 }), +})) + import SetupPage from '@/views/SetupPage.vue' -import { useAuthStore } from '@/stores/auth' describe('SetupPage', () => { beforeEach(() => { @@ -68,83 +105,62 @@ describe('SetupPage', () => { localStorage.clear() }) - it('renders setup heading and form fields', () => { - const wrapper = mount(SetupPage) - expect(wrapper.text()).toContain('Initial Setup') - expect(wrapper.text()).toContain('Create the first admin (CEO) account') - expect(wrapper.find('#username').exists()).toBe(true) - expect(wrapper.find('#password').exists()).toBe(true) - expect(wrapper.find('#confirm').exists()).toBe(true) - }) - - it('renders labels for all form fields', () => { + it('renders welcome step after status loads', async () => { const wrapper = mount(SetupPage) - expect(wrapper.find('label[for="username"]').exists()).toBe(true) - expect(wrapper.find('label[for="password"]').exists()).toBe(true) - expect(wrapper.find('label[for="confirm"]').exists()).toBe(true) + await flushPromises() + expect(wrapper.text()).toContain('Welcome to SynthOrg') }) - it('disables submit button when fields are empty', () => { + it('renders step indicator', async () => { const wrapper = mount(SetupPage) - const submitBtn = wrapper.find('button[type="submit"]') - expect(submitBtn.attributes('disabled')).toBeDefined() + await flushPromises() + // Should show step counter + expect(wrapper.text()).toContain('Step 1 of') }) - it('shows password mismatch error', async () => { + it('shows get started button in welcome step', async () => { const wrapper = mount(SetupPage) - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('password123456') - await wrapper.find('#confirm').setValue('differentpass12') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(wrapper.text()).toContain('Passwords do not match') + const btn = wrapper.find('button') + expect(btn.exists()).toBe(true) + expect(btn.text()).toContain('Get Started') }) - it('shows minimum password length error', async () => { + it('advances to admin step after clicking get started', async () => { const wrapper = mount(SetupPage) - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('short') - await wrapper.find('#confirm').setValue('short') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(wrapper.text()).toContain('Password must be at least') + // Click the "Get Started" button + const btn = wrapper.find('button') + await btn.trigger('click') + await flushPromises() + // Should now show admin step (since needs_admin is true) + expect(wrapper.text()).toContain('Admin') }) - it('calls auth.setup and navigates to / on success', async () => { + it('shows wizard container with step dots', async () => { const wrapper = mount(SetupPage) - const auth = useAuthStore() - auth.setup = vi.fn().mockResolvedValue({ token: 'tok', expires_in: 3600 }) - - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('securepassword1') - await wrapper.find('#confirm').setValue('securepassword1') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(auth.setup).toHaveBeenCalledWith('admin', 'securepassword1') - expect(mockRouterPush).toHaveBeenCalledWith('/') + // The step indicator should have numbered dots + const dots = wrapper.findAll('.rounded-full') + expect(dots.length).toBeGreaterThanOrEqual(1) }) - it('shows error message on setup failure', async () => { + it('includes multiple steps in the wizard', async () => { const wrapper = mount(SetupPage) - const auth = useAuthStore() - auth.setup = vi.fn().mockRejectedValue(new Error('Admin already exists')) - - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('securepassword1') - await wrapper.find('#confirm').setValue('securepassword1') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(wrapper.text()).toContain('Admin already exists') + // Should show "Step X of Y" where Y >= 4 (welcome + admin + provider + company + agent) + const text = wrapper.text() + const match = text.match(/Step \d+ of (\d+)/) + expect(match).toBeTruthy() + if (match) { + expect(parseInt(match[1])).toBeGreaterThanOrEqual(4) + } }) - it('has sign-in link pointing to /login', () => { + it('renders branding logo', async () => { const wrapper = mount(SetupPage) - const link = wrapper.find('a[href="/login"]') - expect(link.exists()).toBe(true) - expect(link.text()).toContain('Already have an account? Sign in') + await flushPromises() + // The welcome step includes the "S" logo + expect(wrapper.text()).toContain('S') }) }) diff --git a/web/src/api/endpoints/setup.ts b/web/src/api/endpoints/setup.ts new file mode 100644 index 0000000000..aac390425f --- /dev/null +++ b/web/src/api/endpoints/setup.ts @@ -0,0 +1,37 @@ +import { apiClient, unwrap } from '../client' +import type { + ApiResponse, + SetupAgentRequest, + SetupAgentResponse, + SetupCompanyRequest, + SetupCompanyResponse, + SetupStatusResponse, + TemplateInfoResponse, +} from '../types' + +// Note: getSetupStatus doesn't need auth -- the endpoint is public. +// apiClient already handles missing auth tokens gracefully (no header sent). +export async function getSetupStatus(): Promise { + const response = await apiClient.get>('/setup/status') + return unwrap(response) +} + +export async function listTemplates(): Promise { + const response = await apiClient.get>('/setup/templates') + return unwrap(response) +} + +export async function createCompany(data: SetupCompanyRequest): Promise { + const response = await apiClient.post>('/setup/company', data) + return unwrap(response) +} + +export async function createAgent(data: SetupAgentRequest): Promise { + const response = await apiClient.post>('/setup/agent', data) + return unwrap(response) +} + +export async function completeSetup(): Promise<{ setup_complete: boolean }> { + const response = await apiClient.post>('/setup/complete') + return unwrap(response) +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index fb029cc3a0..faa2c10599 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -818,3 +818,48 @@ export interface PaginationParams { offset?: number limit?: number } + +// ── Setup ─────────────────────────────────────────────────── + +export interface SetupStatusResponse { + needs_admin: boolean + needs_setup: boolean + has_providers: boolean +} + +export interface TemplateInfoResponse { + name: string + display_name: string + description: string + source: 'builtin' | 'user' +} + +export interface SetupCompanyRequest { + company_name: string + template_name: string | null +} + +export interface SetupAgentRequest { + name: string + role: string + level: SeniorityLevel + personality_preset: string + model_provider: string + model_id: string + department: string + budget_limit_monthly: number | null +} + +export interface SetupCompanyResponse { + company_name: string + template_applied: string | null + department_count: number +} + +export interface SetupAgentResponse { + name: string + role: string + department: string + model_provider: string + model_id: string +} diff --git a/web/src/components/setup/SetupAdmin.vue b/web/src/components/setup/SetupAdmin.vue new file mode 100644 index 0000000000..de92bd90cb --- /dev/null +++ b/web/src/components/setup/SetupAdmin.vue @@ -0,0 +1,117 @@ + + + diff --git a/web/src/components/setup/SetupAgent.vue b/web/src/components/setup/SetupAgent.vue new file mode 100644 index 0000000000..610d1618dd --- /dev/null +++ b/web/src/components/setup/SetupAgent.vue @@ -0,0 +1,253 @@ + + + diff --git a/web/src/components/setup/SetupCompany.vue b/web/src/components/setup/SetupCompany.vue new file mode 100644 index 0000000000..5136eed251 --- /dev/null +++ b/web/src/components/setup/SetupCompany.vue @@ -0,0 +1,128 @@ + + + diff --git a/web/src/components/setup/SetupComplete.vue b/web/src/components/setup/SetupComplete.vue new file mode 100644 index 0000000000..8a052c4d94 --- /dev/null +++ b/web/src/components/setup/SetupComplete.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/src/components/setup/SetupProvider.vue b/web/src/components/setup/SetupProvider.vue new file mode 100644 index 0000000000..567121d66c --- /dev/null +++ b/web/src/components/setup/SetupProvider.vue @@ -0,0 +1,294 @@ + + + diff --git a/web/src/components/setup/SetupWelcome.vue b/web/src/components/setup/SetupWelcome.vue new file mode 100644 index 0000000000..03c1512940 --- /dev/null +++ b/web/src/components/setup/SetupWelcome.vue @@ -0,0 +1,32 @@ + + + diff --git a/web/src/router/guards.ts b/web/src/router/guards.ts index 2a221cff65..67f0597cd1 100644 --- a/web/src/router/guards.ts +++ b/web/src/router/guards.ts @@ -1,23 +1,60 @@ import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { useSetupStore } from '@/stores/setup' /** - * Navigation guard that redirects unauthenticated users to /login. - * Uses route.meta.requiresAuth to determine access control: - * - Routes with requiresAuth: false are public (login, setup) - * - All other routes require authentication - * Redirects authenticated users away from public auth pages. - * Preserves intended destination via `redirect` query param. + * Navigation guard that handles setup flow and authentication. + * + * Priority order: + * 1. Setup check -- if status not yet fetched, fetch it first (fail-closed) + * 2. Setup redirect -- if setup is needed, redirect non-setup routes to /setup + * 3. Auth check -- unauthenticated users go to /login for protected routes + * 4. Password change enforcement -- mustChangePassword forces settings page + * + * The /setup route is always accessible when setup is needed, regardless of + * auth status. When setup is complete, /setup redirects to /. + * When status is null (not yet fetched), the guard fetches it before deciding. */ -export function authGuard( +export async function authGuard( to: RouteLocationNormalized, _from: RouteLocationNormalized, next: NavigationGuardNext, -): void { +): Promise { const auth = useAuthStore() + const setup = useSetupStore() + + // ── Setup routing ──────────────────────────────────────── + // Eagerly fetch setup status so routing decisions are never based + // on stale/missing data. Errors are logged inside the store; + // fail-closed (isSetupNeeded defaults to true when fetch fails). + + if (setup.status === null && !setup.loading) { + await setup.fetchStatus() + } + + if (setup.status !== null) { + // Setup is needed -- funnel everything to /setup + if (setup.isSetupNeeded) { + if (to.name !== 'setup' && to.name !== 'login') { + next({ name: 'setup' }) + return + } + // Allow /setup and /login to proceed + next() + return + } + + // Setup is complete -- redirect /setup to / + if (to.name === 'setup') { + next('/') + return + } + } + + // ── Auth routing ───────────────────────────────────────── if (to.meta.requiresAuth === false) { - // If already authenticated, redirect away from login/setup + // If already authenticated, redirect away from login if (auth.isAuthenticated) { next('/') return @@ -32,7 +69,7 @@ export function authGuard( return } - // Enforce mustChangePassword — always normalize to settings?tab=user (password form) + // Enforce mustChangePassword -- always normalize to settings?tab=user (password form) if (auth.mustChangePassword && !(to.name === 'settings' && to.query.tab === 'user')) { next({ name: 'settings', query: { tab: 'user' } }) return diff --git a/web/src/stores/setup.ts b/web/src/stores/setup.ts new file mode 100644 index 0000000000..1bcedd57aa --- /dev/null +++ b/web/src/stores/setup.ts @@ -0,0 +1,97 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as setupApi from '@/api/endpoints/setup' +import { getErrorMessage } from '@/utils/errors' +import type { SetupStatusResponse, TemplateInfoResponse } from '@/api/types' + +export const useSetupStore = defineStore('setup', () => { + const status = ref(null) + /** True once fetchStatus has completed successfully at least once. */ + const statusLoaded = ref(false) + const currentStep = ref(0) + const templates = ref([]) + const loading = ref(false) + const error = ref(null) + + // Fail-closed: if status hasn't loaded, assume setup IS needed. + const isSetupNeeded = computed(() => + statusLoaded.value ? !!status.value?.needs_setup : true, + ) + const isAdminNeeded = computed(() => + statusLoaded.value ? !!status.value?.needs_admin : true, + ) + + async function fetchStatus() { + loading.value = true + error.value = null + try { + status.value = await setupApi.getSetupStatus() + statusLoaded.value = true + } catch (err) { + error.value = getErrorMessage(err) + // statusLoaded stays false -- isSetupNeeded/isAdminNeeded + // default to true (fail-closed). + } finally { + loading.value = false + } + } + + async function fetchTemplates() { + error.value = null + try { + templates.value = await setupApi.listTemplates() + } catch (err) { + error.value = getErrorMessage(err) + } + } + + function nextStep(maxSteps: number) { + if (currentStep.value < maxSteps - 1) { + currentStep.value++ + } + } + + function prevStep() { + if (currentStep.value > 0) { + currentStep.value-- + } + } + + function setStep(n: number, maxSteps?: number) { + const upper = maxSteps != null ? maxSteps - 1 : n + currentStep.value = Math.max(0, Math.min(n, upper)) + } + + async function markComplete() { + loading.value = true + error.value = null + try { + await setupApi.completeSetup() + if (status.value) { + status.value = { ...status.value, needs_setup: false } + } + } catch (err) { + error.value = getErrorMessage(err) + throw err + } finally { + loading.value = false + } + } + + return { + status, + statusLoaded, + currentStep, + templates, + loading, + error, + isSetupNeeded, + isAdminNeeded, + fetchStatus, + fetchTemplates, + nextStep, + prevStep, + setStep, + markComplete, + } +}) diff --git a/web/src/views/SetupPage.vue b/web/src/views/SetupPage.vue index 987487b47e..0c272ae5ba 100644 --- a/web/src/views/SetupPage.vue +++ b/web/src/views/SetupPage.vue @@ -1,120 +1,182 @@