diff --git a/.gitignore b/.gitignore
index cff918569..f5eb2dfd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,6 @@ logs/
*.log*
bambutrack.log.*
firmware/
+
+# Node modules
+node_modules/
\ No newline at end of file
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
index 733b9ac94..7b24d558f 100644
--- a/backend/app/api/routes/auth.py
+++ b/backend/app/api/routes/auth.py
@@ -42,6 +42,26 @@ async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
# Note: Don't commit here - let get_db handle it or commit explicitly in the route
+async def is_setup_completed(db: AsyncSession) -> bool:
+ """Check if setup has been completed."""
+ result = await db.execute(select(Settings).where(Settings.key == "setup_completed"))
+ setting = result.scalar_one_or_none()
+ return setting and setting.value.lower() == "true"
+
+
+async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
+ """Set setup completed status."""
+ from sqlalchemy import func
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+
+ stmt = sqlite_insert(Settings).values(key="setup_completed", value="true" if completed else "false")
+ stmt = stmt.on_conflict_do_update(
+ index_elements=["key"], set_={"value": "true" if completed else "false", "updated_at": func.now()}
+ )
+ await db.execute(stmt)
+ # Note: Don't commit here - let get_db handle it or commit explicitly in the route
+
+
@router.post("/setup", response_model=SetupResponse)
async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
"""First-time setup: enable/disable authentication and create admin user."""
@@ -70,48 +90,61 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
admin_created = False
if request.auth_enabled:
- if not request.admin_username or not request.admin_password:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Admin username and password are required when enabling authentication",
- )
+ # Check if admin users already exist
+ admin_users_result = await db.execute(select(User).where(User.role == "admin"))
+ existing_admin_users = list(admin_users_result.scalars().all())
+ has_admin_users = len(existing_admin_users) > 0
- # Check if admin already exists
- existing_admin = await get_user_by_username(db, request.admin_username)
- if existing_admin:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Admin user already exists",
- )
+ if has_admin_users:
+ # Admin users already exist, just enable auth (don't create new admin)
+ logger.info(f"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin")
+ admin_created = False
+ else:
+ # No admin users exist, require admin credentials to create first admin
+ if not request.admin_username or not request.admin_password:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Admin username and password are required when enabling authentication (no admin users exist)",
+ )
- # Create admin user FIRST (before enabling auth)
- try:
- logger.info(f"Creating admin user: {request.admin_username}")
- admin_user = User(
- username=request.admin_username,
- password_hash=get_password_hash(request.admin_password),
- role="admin",
- is_active=True,
- )
- db.add(admin_user)
- logger.info(f"Admin user added to session: {request.admin_username}")
- admin_created = True
- except Exception as e:
- await db.rollback()
- logger.error(f"Failed to create admin user: {e}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to create admin user: {str(e)}",
- )
+ # Check if username already exists (shouldn't happen if no admin users exist, but check anyway)
+ existing_user = await get_user_by_username(db, request.admin_username)
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="User with this username already exists",
+ )
- # Set auth enabled and commit everything together
+ # Create admin user FIRST (before enabling auth)
+ try:
+ logger.info(f"Creating admin user: {request.admin_username}")
+ admin_user = User(
+ username=request.admin_username,
+ password_hash=get_password_hash(request.admin_password),
+ role="admin",
+ is_active=True,
+ )
+ db.add(admin_user)
+ logger.info(f"Admin user added to session: {request.admin_username}")
+ admin_created = True
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Failed to create admin user: {e}", exc_info=True)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Failed to create admin user: {str(e)}",
+ )
+
+ # Set auth enabled and mark setup as completed
await set_auth_enabled(db, request.auth_enabled)
+ await set_setup_completed(db, True)
await db.commit()
if admin_created:
await db.refresh(admin_user)
logger.info(f"Admin user created successfully: {admin_user.id}")
+ logger.info(f"Setup completed: auth_enabled={request.auth_enabled}, admin_created={admin_created}")
return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
except HTTPException:
raise
@@ -128,7 +161,10 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
async def get_auth_status(db: AsyncSession = Depends(get_db)):
"""Get authentication status (public endpoint)."""
auth_enabled = await is_auth_enabled(db)
- return {"auth_enabled": auth_enabled, "requires_setup": not auth_enabled}
+ setup_completed = await is_setup_completed(db)
+ # Only require setup if it hasn't been completed yet
+ requires_setup = not setup_completed
+ return {"auth_enabled": auth_enabled, "requires_setup": requires_setup}
@router.post("/disable", response_model=dict)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 48cc0f158..06efded93 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -82,6 +82,9 @@ function SetupRoute({ children }: { children: React.ReactNode }) {
return
Loading...
;
}
+ // If auth is already enabled, redirect to login
+ // Otherwise, allow access to setup page (even if setup was completed before)
+ // This allows users to enable auth later if they skipped it during initial setup
if (authEnabled) {
return ;
}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
index 9429dad9b..1fd1ac1db 100644
--- a/frontend/src/contexts/AuthContext.tsx
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -1,10 +1,11 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
+import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { api, getAuthToken, setAuthToken } from '../api/client';
import type { UserResponse } from '../api/client';
interface AuthContextType {
user: UserResponse | null;
authEnabled: boolean;
+ requiresSetup: boolean;
loading: boolean;
login: (username: string, password: string) => Promise;
logout: () => void;
@@ -17,12 +18,15 @@ const AuthContext = createContext(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [authEnabled, setAuthEnabled] = useState(false);
+ const [requiresSetup, setRequiresSetup] = useState(false);
const [loading, setLoading] = useState(true);
+ const hasRedirectedRef = useRef(false);
const checkAuthStatus = async () => {
try {
const status = await api.getAuthStatus();
setAuthEnabled(status.auth_enabled);
+ setRequiresSetup(status.requires_setup);
if (status.auth_enabled) {
const token = getAuthToken();
@@ -41,10 +45,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} else {
// Auth not enabled, allow access
setUser(null);
- // Check if setup is needed
- if (status.requires_setup && window.location.pathname !== '/setup') {
- window.location.href = '/setup';
- }
}
} catch (error) {
console.error('Failed to check auth status:', error);
@@ -56,9 +56,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
useEffect(() => {
+ // Check auth status on mount
checkAuthStatus();
}, []);
+ // Separate effect to handle redirect only when setup is required
+ useEffect(() => {
+ // Only redirect if setup is truly required (first time setup)
+ // Don't redirect if user manually navigated to /setup or is on camera page
+ if (!loading && requiresSetup && !authEnabled) {
+ const currentPath = window.location.pathname;
+ // Only redirect if not already on setup page or camera page, and haven't redirected yet
+ if (currentPath !== '/setup' && !currentPath.startsWith('/camera/') && !hasRedirectedRef.current) {
+ hasRedirectedRef.current = true;
+ window.location.href = '/setup';
+ }
+ } else if (!requiresSetup) {
+ // Reset redirect flag when setup is no longer required
+ hasRedirectedRef.current = false;
+ }
+ }, [loading, requiresSetup, authEnabled]);
+
const login = async (username: string, password: string) => {
const response = await api.login({ username, password });
setAuthToken(response.access_token);
@@ -95,6 +113,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
value={{
user,
authEnabled,
+ requiresSetup,
loading,
login,
logout,
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index d9a0adec6..98e624ef8 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -2996,7 +2996,11 @@ export function SettingsPage() {