Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.default
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ COMPOSE_PROJECT_NAME=chronicle
# ==========================================
# AUTHENTICATION & SECURITY
# ==========================================
# Run ./quick-start.sh to generate secure credentials
# REQUIRED: Run ./quick-start.sh to generate a secure AUTH_SECRET_KEY
# This is needed for JWT token signing and must be set before backend starts
AUTH_SECRET_KEY=

# Admin account setup (two options):
# Option 1: Set ADMIN_PASSWORD here for automatic admin creation on startup (good for Docker/CI)
# Option 2: Leave ADMIN_PASSWORD empty to create admin via web UI setup screen at /setup
ADMIN_NAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=
Expand Down
6 changes: 3 additions & 3 deletions backends/advanced/compose/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ services:
- MYCELIA_URL=${MYCELIA_URL:-http://mycelia-backend:5173}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/readiness"]
interval: 30s
timeout: 30s
interval: 5s
timeout: 10s
retries: 5
start_period: 5s
start_period: 10s
restart: unless-stopped
networks:
- chronicle-network
Expand Down
2 changes: 0 additions & 2 deletions backends/advanced/config/config.defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ wizard_completed: false
# Authentication Configuration
auth:
secret_key: '' # Auto-generated on first run if empty
admin_name: admin
admin_email: admin@example.com
admin_password_hash: '' # Set via wizard or environment variable

# Speech Detection Settings
Expand Down
17 changes: 14 additions & 3 deletions backends/advanced/src/advanced_omi_backend/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import advanced_omi_backend.settings_manager as settings_manager_module
from advanced_omi_backend.auth import (
bearer_backend,
check_admin_exists,
cookie_backend,
create_admin_user_if_needed,
current_superuser,
Expand Down Expand Up @@ -100,11 +101,21 @@ async def lifespan(app: FastAPI):
application_logger.error(f"Failed to initialize settings manager: {e}")
raise

# Create admin user if needed
# Create admin user if needed (supports both env var and web UI setup)
try:
await create_admin_user_if_needed()
import os
if os.getenv("ADMIN_PASSWORD"):
# Backward compatibility: Use environment variable for auto-creation
await create_admin_user_if_needed()
else:
# Web UI setup mode: Check if admin exists
admin_exists = await check_admin_exists()
if not admin_exists:
application_logger.info("⚠️ No admin user exists - setup via web UI required at /setup")
else:
application_logger.info("✅ Admin user already exists (setup completed)")
except Exception as e:
application_logger.error(f"Failed to create admin user: {e}")
application_logger.error(f"Failed to check/create admin user: {e}")
# Don't raise here as this is not critical for startup

# Sync admin user with Mycelia OAuth (if using Mycelia memory provider)
Expand Down
23 changes: 21 additions & 2 deletions backends/advanced/src/advanced_omi_backend/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ def _verify_configured(var_name: str, *, optional: bool = False) -> Optional[str
SECRET_KEY = _verify_configured("AUTH_SECRET_KEY")
COOKIE_SECURE = _verify_configured("COOKIE_SECURE", optional=True) == "true"

# Admin user configuration
ADMIN_PASSWORD = _verify_configured("ADMIN_PASSWORD")
# Admin user configuration (optional - can use web UI setup if not set)
ADMIN_PASSWORD = _verify_configured("ADMIN_PASSWORD", optional=True)
ADMIN_EMAIL = _verify_configured("ADMIN_EMAIL", optional=True) or "admin@example.com"


Expand Down Expand Up @@ -200,6 +200,25 @@ def get_accessible_user_ids(user: User) -> list[str] | None:
return [str(user.id)] # Can only access own data


async def check_admin_exists() -> bool:
"""
Check if any admin user exists in the database.

Returns:
True if at least one superuser exists, False otherwise
"""
try:
# Import User model here to avoid circular import
from advanced_omi_backend.users import User

# Query for any user with is_superuser=True
admin = await User.find_one({"is_superuser": True})
return admin is not None
except Exception as e:
logger.error(f"Failed to check admin existence: {e}")
raise


async def create_admin_user_if_needed():
"""Create admin user during startup if it doesn't exist and credentials are provided."""
if not ADMIN_PASSWORD:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
user_router,
)
from .modules.health_routes import router as health_router
from .modules.setup_routes import router as setup_router

logger = logging.getLogger(__name__)
audio_logger = logging.getLogger("audio_processing")
Expand All @@ -29,6 +30,7 @@
router = APIRouter(prefix="/api", tags=["api"])

# Include all sub-routers
router.include_router(setup_router) # Setup routes (public, no auth required)
router.include_router(audio_router)
router.include_router(user_router)
router.include_router(chat_router)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
Setup routes for first-time admin account creation.

Provides public endpoints for checking setup status and creating the initial admin user.
"""

import logging
from typing import Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr, Field, field_validator

from advanced_omi_backend.auth import check_admin_exists, get_user_manager
from advanced_omi_backend.users import UserCreate, get_user_db

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/setup", tags=["setup"])


class SetupStatusResponse(BaseModel):
"""Response model for setup status check."""
requires_setup: bool = Field(..., description="Whether initial admin setup is required")


class AdminCreateRequest(BaseModel):
"""Request model for creating the first admin user."""
display_name: str = Field(..., min_length=1, description="Administrator's display name")
email: EmailStr = Field(..., description="Administrator's email address")
password: str = Field(..., min_length=8, description="Administrator's password (minimum 8 characters)")
confirm_password: str = Field(..., description="Password confirmation")

@field_validator('confirm_password')
@classmethod
def passwords_match(cls, v: str, info) -> str:
"""Validate that password and confirm_password match."""
if 'password' in info.data and v != info.data['password']:
raise ValueError('Passwords do not match')
return v


class AdminCreateResponse(BaseModel):
"""Response model for successful admin creation."""
message: str
user_id: str
email: str


@router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status():
"""
Check if initial admin setup is required.

Public endpoint (no authentication required).
Returns whether an admin user already exists.
"""
try:
admin_exists = await check_admin_exists()
return SetupStatusResponse(requires_setup=not admin_exists)
except Exception as e:
logger.error(f"Failed to check setup status: {e}")
raise HTTPException(status_code=500, detail="Failed to check setup status")


@router.post("/create-admin", response_model=AdminCreateResponse, status_code=201)
async def create_admin(request: AdminCreateRequest):
"""
Create the first admin user.

Public endpoint (no authentication required).
Can only be used once - fails if an admin already exists.

Args:
request: Admin creation request with display_name, email, password, and confirm_password

Returns:
AdminCreateResponse with success message and user details

Raises:
409 Conflict: Admin already exists
400 Bad Request: Validation errors
"""
try:
# Atomic check: verify no admin exists before proceeding
admin_exists = await check_admin_exists()
if admin_exists:
logger.warning("Attempted to create admin when one already exists")
raise HTTPException(
status_code=409,
detail="Admin user already exists. Setup has already been completed."
)

# Get user database and manager
user_db_gen = get_user_db()
user_db = await user_db_gen.__anext__()
user_manager_gen = get_user_manager(user_db)
user_manager = await user_manager_gen.__anext__()

# Create admin user with UserManager (handles password hashing)
admin_create = UserCreate(
email=request.email,
password=request.password,
is_superuser=True,
is_verified=True,
display_name=request.display_name,
)

admin_user = await user_manager.create(admin_create)

logger.info(
f"✅ Created admin user via web setup: {admin_user.user_id} ({admin_user.email})"
)

return AdminCreateResponse(
message="Admin user created successfully",
user_id=str(admin_user.id),
email=admin_user.email
)

except HTTPException:
# Re-raise HTTP exceptions (like 409 Conflict)
raise
except Exception as e:
logger.error(f"Failed to create admin user: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail="Failed to create admin user. Please try again."
)
2 changes: 2 additions & 0 deletions backends/advanced/webui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ThemeProvider } from './contexts/ThemeContext'
import { RecordingProvider } from './contexts/RecordingContext'
import Layout from './components/layout/Layout'
import LoginPage from './pages/LoginPage'
import SetupPage from './pages/SetupPage'
import Chat from './pages/Chat'
import ConversationsRouter from './pages/ConversationsRouter'
import MemoriesRouter from './pages/MemoriesRouter'
Expand Down Expand Up @@ -31,6 +32,7 @@ function App() {
<RecordingProvider>
<Router basename={basename} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={
<ProtectedRoute>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface ProtectedRouteProps {
}

export default function ProtectedRoute({ children, adminOnly = false }: ProtectedRouteProps) {
const { user, token, isLoading, isAdmin } = useAuth()
const { user, token, isLoading, isAdmin, setupRequired } = useAuth()

if (isLoading) {
return (
Expand All @@ -18,6 +18,11 @@ export default function ProtectedRoute({ children, adminOnly = false }: Protecte
)
}

// Redirect to setup if required
if (setupRequired === true) {
return <Navigate to="/setup" replace />
}

if (!token || !user) {
return <Navigate to="/login" replace />
}
Expand Down
28 changes: 25 additions & 3 deletions backends/advanced/webui/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { authApi } from '../services/api'
import { authApi, setupApi } from '../services/api'
import { getStorageKey } from '../utils/storage'

interface User {
Expand All @@ -18,6 +18,8 @@ interface AuthContextType {
logout: () => void
isLoading: boolean
isAdmin: boolean
setupRequired: boolean | null // null = checking, true/false = determined
checkSetupStatus: () => Promise<boolean>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)
Expand All @@ -26,16 +28,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(localStorage.getItem(getStorageKey('token')))
const [isLoading, setIsLoading] = useState(true)
const [setupRequired, setSetupRequired] = useState<boolean | null>(null)

// Check if user is admin
const isAdmin = user?.is_superuser || false

// Function to check setup status
const checkSetupStatus = async (): Promise<boolean> => {
try {
const response = await setupApi.getSetupStatus()
const required = response.data.requires_setup
setSetupRequired(required)
return required
} catch (error) {
console.error('❌ AuthContext: Failed to check setup status:', error)
setSetupRequired(false) // Assume setup not required on error to avoid blocking login
return false
}
}

useEffect(() => {
const initAuth = async () => {
console.log('🔐 AuthContext: Initializing authentication...')

// First, check setup status
console.log('🔐 AuthContext: Checking setup status...')
await checkSetupStatus()

const savedToken = localStorage.getItem(getStorageKey('token'))
console.log('🔐 AuthContext: Saved token exists:', !!savedToken)

if (savedToken) {
try {
console.log('🔐 AuthContext: Verifying token with API call...')
Expand Down Expand Up @@ -108,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}

return (
<AuthContext.Provider value={{ user, token, login, logout, isLoading, isAdmin }}>
<AuthContext.Provider value={{ user, token, login, logout, isLoading, isAdmin, setupRequired, checkSetupStatus }}>
{children}
</AuthContext.Provider>
)
Expand Down
7 changes: 6 additions & 1 deletion backends/advanced/webui/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')

const { user, login } = useAuth()
const { user, login, setupRequired } = useAuth()

// Redirect to setup if required
if (setupRequired === true) {
return <Navigate to="/setup" replace />
}

// Redirect if already logged in
if (user) {
Expand Down
Loading
Loading