Skip to content

Commit 753ed01

Browse files
feat: add setup wizard for initial system configuration (#60)
* feat(api): add setup wizard endpoints for initial configuration Add backend support for first-time system initialization: - GET /setup/status - check if system is initialized - POST /setup/initialize - create first admin user The system is considered initialized when at least one admin user exists. This allows new installations to configure admin credentials through the UI. * feat(portal): add setup wizard page for initial configuration Add modern setup wizard UI with: - Admin email and password fields with validation - Confirm password verification - Optional admin name field - Success animation and redirect to login - Responsive design matching existing UI patterns * feat(portal): add SetupGuard component for route protection Add wrapper component that: - Checks setup status on mount - Redirects to /setup if system is not initialized - Shows loading state while checking - Falls back to initialized=true if API is unreachable * feat(portal): integrate setup wizard into routing - Add /setup route for the setup wizard page - Wrap /login and authenticated routes with SetupGuard - Ensures users are redirected to setup on first access * docs(docker): add SKIP_SETUP configuration option - Add SKIP_SETUP env var documentation to .env.example - Add SKIP_SETUP and TRON_SECRETS_KEY to docker-compose.prod.yaml - Allows automated deployments to skip the setup wizard --------- Co-authored-by: rafaelrsantosti <11065120+rafaelrsantosti@users.noreply.github.com>
1 parent 8f56bf3 commit 753ed01

12 files changed

Lines changed: 561 additions & 2 deletions

File tree

api/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from app.webapps.api.webapp_handlers import router as webapps_router
2727
from app.workers.api.worker_handlers import router as workers_router
2828
from app.cron.api.cron_handlers import router as crons_router
29+
from app.setup.api.setup_handlers import router as setup_router
2930

3031
# Version is injected at build time via APP_VERSION environment variable
3132
APP_VERSION = os.getenv("APP_VERSION", "dev")
@@ -101,6 +102,7 @@
101102
app.include_router(webapps_router)
102103
app.include_router(workers_router)
103104
app.include_router(crons_router)
105+
app.include_router(setup_router)
104106

105107
# Legacy routers removed - all features migrated to new structure
106108

api/app/setup/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Setup module for initial system configuration."""

api/app/setup/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Setup API module."""

api/app/setup/api/setup_dto.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""DTOs for setup endpoints."""
2+
3+
from pydantic import BaseModel, EmailStr, Field
4+
5+
6+
class SetupStatus(BaseModel):
7+
"""Response for setup status check."""
8+
9+
initialized: bool
10+
message: str
11+
12+
13+
class SetupInitialize(BaseModel):
14+
"""Request to initialize the system."""
15+
16+
admin_email: EmailStr = Field(..., description="Admin user email")
17+
admin_password: str = Field(..., min_length=6, description="Admin user password")
18+
admin_name: str = Field(default="Administrator", description="Admin user full name")
19+
20+
21+
class SetupInitializeResponse(BaseModel):
22+
"""Response after successful initialization."""
23+
24+
success: bool
25+
message: str
26+
admin_email: str
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Setup API handlers."""
2+
3+
from fastapi import APIRouter, Depends, HTTPException
4+
from sqlalchemy.orm import Session
5+
6+
from app.shared.database.database import get_db
7+
from app.setup.core.setup_service import SetupService
8+
from app.setup.api.setup_dto import (
9+
SetupStatus,
10+
SetupInitialize,
11+
SetupInitializeResponse,
12+
)
13+
14+
router = APIRouter(prefix="/setup", tags=["Setup"])
15+
16+
17+
def get_setup_service(db: Session = Depends(get_db)) -> SetupService:
18+
"""Dependency to get SetupService instance."""
19+
return SetupService(db)
20+
21+
22+
@router.get("/status", response_model=SetupStatus)
23+
def get_setup_status(service: SetupService = Depends(get_setup_service)):
24+
"""
25+
Check if the system has been initialized.
26+
27+
Returns:
28+
SetupStatus with initialized flag and message
29+
"""
30+
initialized = service.is_initialized()
31+
skip_setup = service.should_skip_setup()
32+
33+
if skip_setup and not initialized:
34+
return SetupStatus(
35+
initialized=False,
36+
message="Setup skipped via SKIP_SETUP environment variable",
37+
)
38+
39+
if initialized:
40+
return SetupStatus(
41+
initialized=True,
42+
message="System is ready",
43+
)
44+
45+
return SetupStatus(
46+
initialized=False,
47+
message="System requires initial setup",
48+
)
49+
50+
51+
@router.post("/initialize", response_model=SetupInitializeResponse)
52+
def initialize_setup(
53+
data: SetupInitialize,
54+
service: SetupService = Depends(get_setup_service),
55+
):
56+
"""
57+
Initialize the system with the first admin user.
58+
59+
This endpoint can only be called once, when no admin users exist.
60+
61+
Args:
62+
data: SetupInitialize with admin credentials
63+
64+
Returns:
65+
SetupInitializeResponse on success
66+
67+
Raises:
68+
HTTPException 400: If system is already initialized
69+
"""
70+
if service.is_initialized():
71+
raise HTTPException(
72+
status_code=400,
73+
detail="System is already initialized. Cannot run setup again.",
74+
)
75+
76+
try:
77+
admin_user = service.initialize(
78+
admin_email=data.admin_email,
79+
admin_password=data.admin_password,
80+
admin_name=data.admin_name,
81+
)
82+
83+
return SetupInitializeResponse(
84+
success=True,
85+
message="System initialized successfully! You can now login with your admin credentials.",
86+
admin_email=admin_user.email,
87+
)
88+
except ValueError as e:
89+
raise HTTPException(status_code=400, detail=str(e))
90+
except Exception as e:
91+
raise HTTPException(
92+
status_code=500, detail=f"Failed to initialize system: {str(e)}"
93+
)

api/app/setup/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Setup core module."""
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Setup service for initial system configuration."""
2+
3+
import os
4+
from sqlalchemy.orm import Session
5+
6+
from app.users.infra.user_model import User, UserRole
7+
from app.auth.core.auth_service import AuthService
8+
9+
10+
class SetupService:
11+
"""Business logic for system setup."""
12+
13+
def __init__(self, db: Session):
14+
self.db = db
15+
self.auth_service = AuthService()
16+
17+
def is_initialized(self) -> bool:
18+
"""Check if the system has been initialized (has at least one admin user)."""
19+
admin_count = (
20+
self.db.query(User).filter(User.role == UserRole.ADMIN.value).count()
21+
)
22+
return admin_count > 0
23+
24+
def should_skip_setup(self) -> bool:
25+
"""Check if setup should be skipped (for dev environments)."""
26+
return os.getenv("SKIP_SETUP", "false").lower() == "true"
27+
28+
def initialize(
29+
self,
30+
admin_email: str,
31+
admin_password: str,
32+
admin_name: str = "Administrator",
33+
) -> User:
34+
"""
35+
Initialize the system with the first admin user.
36+
37+
Args:
38+
admin_email: Email for the admin user
39+
admin_password: Password for the admin user
40+
admin_name: Full name for the admin user
41+
42+
Returns:
43+
The created admin user
44+
45+
Raises:
46+
ValueError: If the system is already initialized
47+
"""
48+
if self.is_initialized():
49+
raise ValueError("System is already initialized")
50+
51+
# Create admin user
52+
hashed_password = self.auth_service.get_password_hash(admin_password)
53+
admin_user = User(
54+
email=admin_email,
55+
hashed_password=hashed_password,
56+
full_name=admin_name,
57+
role=UserRole.ADMIN.value,
58+
is_active=True,
59+
)
60+
61+
self.db.add(admin_user)
62+
self.db.commit()
63+
self.db.refresh(admin_user)
64+
65+
return admin_user

docker/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ CORS_ORIGINS=https://your-domain.com
3535
# API URL for Portal (use /api when behind nginx proxy)
3636
API_URL=/api
3737

38+
# =============================================================================
39+
# Initial Setup
40+
# =============================================================================
41+
42+
# Skip the initial setup wizard (use for automated deployments)
43+
# If true, you must create the admin user through other means (e.g., scripts)
44+
# Default: false (setup wizard required on first access)
45+
SKIP_SETUP=false
46+
3847
# =============================================================================
3948
# Secrets Encryption
4049
# =============================================================================

docker/docker-compose.prod.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ services:
8989
CORS_ALLOW_CREDENTIALS: "true"
9090
CORS_ALLOW_METHODS: "GET,POST,PUT,DELETE,OPTIONS"
9191
CORS_ALLOW_HEADERS: "Content-Type,Authorization,Accept,Origin,X-Requested-With"
92+
SKIP_SETUP: ${SKIP_SETUP:-false}
93+
TRON_SECRETS_KEY: ${TRON_SECRETS_KEY:-}
9294
expose:
9395
- "8000"
9496
depends_on:

portal/src/App.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Routes, Route } from 'react-router-dom'
22
import { AuthProvider } from './contexts/AuthContext'
33
import { ProtectedRoute, AdminRoute, Layout } from './shared/components'
4+
import SetupGuard from './components/SetupGuard'
45
import Home from './pages/home'
56
import Login from './pages/Login'
7+
import Setup from './pages/setup/Setup'
68
import Clusters from './pages/clusters/Clusters'
79
import Environments from './pages/environments/Environments'
810
import Applications from './pages/applications/Applications'
@@ -21,8 +23,9 @@ function App() {
2123
return (
2224
<AuthProvider>
2325
<Routes>
24-
<Route path="/login" element={<Login />} />
25-
<Route path="/" element={<Layout />}>
26+
<Route path="/setup" element={<Setup />} />
27+
<Route path="/login" element={<SetupGuard><Login /></SetupGuard>} />
28+
<Route path="/" element={<SetupGuard><Layout /></SetupGuard>}>
2629
<Route index element={<ProtectedRoute><Home /></ProtectedRoute>} />
2730
<Route path="clusters" element={<ProtectedRoute><AdminRoute><Clusters /></AdminRoute></ProtectedRoute>} />
2831
<Route path="environments" element={<ProtectedRoute><AdminRoute><Environments /></AdminRoute></ProtectedRoute>} />

0 commit comments

Comments
 (0)