Improved user management, added no-registration mode

This commit is contained in:
Xoconoch
2025-08-04 08:32:50 -06:00
parent 6ab603d90a
commit 1da75a3fbc
11 changed files with 660 additions and 28 deletions

View File

@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
# Configuration
AUTH_ENABLED = os.getenv("ENABLE_AUTH", "false").lower() in ("true", "1", "yes", "on")
DISABLE_REGISTRATION = os.getenv("DISABLE_REGISTRATION", "false").lower() in ("true", "1", "yes", "on")
JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = int(os.getenv("JWT_EXPIRATION_HOURS", "24"))

View File

@@ -4,7 +4,7 @@ from pydantic import BaseModel
from typing import Optional, List
import logging
from . import AUTH_ENABLED, user_manager, token_manager, User
from . import AUTH_ENABLED, DISABLE_REGISTRATION, user_manager, token_manager, User
logger = logging.getLogger(__name__)
@@ -24,6 +24,19 @@ class RegisterRequest(BaseModel):
email: Optional[str] = None
class CreateUserRequest(BaseModel):
"""Admin-only request to create users when registration is disabled"""
username: str
password: str
email: Optional[str] = None
role: str = "user"
class RoleUpdateRequest(BaseModel):
"""Request to update user role"""
role: str
class UserResponse(BaseModel):
username: str
email: Optional[str]
@@ -46,6 +59,7 @@ class AuthStatusResponse(BaseModel):
auth_enabled: bool
authenticated: bool = False
user: Optional[UserResponse] = None
registration_enabled: bool = True
# Dependency to get current user
@@ -101,7 +115,8 @@ async def auth_status(current_user: Optional[User] = Depends(get_current_user)):
return AuthStatusResponse(
auth_enabled=AUTH_ENABLED,
authenticated=current_user is not None,
user=UserResponse(**current_user.to_public_dict()) if current_user else None
user=UserResponse(**current_user.to_public_dict()) if current_user else None,
registration_enabled=AUTH_ENABLED and not DISABLE_REGISTRATION
)
@@ -138,6 +153,12 @@ async def register(request: RegisterRequest):
detail="Authentication is disabled"
)
if DISABLE_REGISTRATION:
raise HTTPException(
status_code=403,
detail="Public registration is disabled. Contact an administrator to create an account."
)
# Check if this is the first user (should be admin)
existing_users = user_manager.list_users()
role = "admin" if len(existing_users) == 0 else "user"
@@ -188,11 +209,11 @@ async def delete_user(username: str, current_user: User = Depends(require_admin)
@router.put("/users/{username}/role", response_model=MessageResponse)
async def update_user_role(
username: str,
role: str,
request: RoleUpdateRequest,
current_user: User = Depends(require_admin)
):
"""Update user role (admin only)"""
if role not in ["user", "admin"]:
if request.role not in ["user", "admin"]:
raise HTTPException(
status_code=400,
detail="Role must be 'user' or 'admin'"
@@ -204,13 +225,42 @@ async def update_user_role(
detail="Cannot change your own role"
)
success, message = user_manager.update_user_role(username, role)
success, message = user_manager.update_user_role(username, request.role)
if not success:
raise HTTPException(status_code=404, detail=message)
return MessageResponse(message=message)
@router.post("/users/create", response_model=MessageResponse)
async def create_user_admin(request: CreateUserRequest, current_user: User = Depends(require_admin)):
"""Create a new user (admin only) - for use when registration is disabled"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
# Validate role
if request.role not in ["user", "admin"]:
raise HTTPException(
status_code=400,
detail="Role must be 'user' or 'admin'"
)
success, message = user_manager.create_user(
username=request.username,
password=request.password,
email=request.email,
role=request.role
)
if not success:
raise HTTPException(status_code=400, detail=message)
return MessageResponse(message=message)
# Profile endpoints
@router.get("/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(require_auth)):

View File

@@ -3,8 +3,9 @@ from fastapi.responses import JSONResponse
import json
import logging
import os
from typing import Any
from typing import Any, Optional, List
from pathlib import Path
from pydantic import BaseModel
# Import the centralized config getters that handle file creation and defaults
from routes.utils.celery_config import (
@@ -20,12 +21,36 @@ from routes.utils.watch.manager import (
# Import authentication dependencies
from routes.auth.middleware import require_admin_from_state, User
from routes.auth import user_manager, AUTH_ENABLED, DISABLE_REGISTRATION
logger = logging.getLogger(__name__)
router = APIRouter()
# User management models for config interface
class CreateUserConfigRequest(BaseModel):
"""User creation request for config interface"""
username: str
password: str
email: Optional[str] = None
role: str = "user"
class UserConfigResponse(BaseModel):
"""User response for config interface"""
username: str
email: Optional[str]
role: str
created_at: str
last_login: Optional[str]
class MessageConfigResponse(BaseModel):
"""Message response for config interface"""
message: str
# Flag for config change notifications
config_changed = False
last_config: dict[str, Any] = {}
@@ -390,3 +415,131 @@ async def update_watch_config(request: Request, current_user: User = Depends(req
status_code=500,
detail={"error": "Failed to update watch configuration", "details": str(e)}
)
# User management endpoints for config interface
@router.get("/auth/status")
async def get_auth_status_config(current_user: User = Depends(require_admin_from_state)):
"""Get authentication system status for config interface"""
return {
"auth_enabled": AUTH_ENABLED,
"registration_disabled": DISABLE_REGISTRATION,
"current_user": {
"username": current_user.username,
"role": current_user.role
} if current_user else None
}
@router.get("/users", response_model=List[UserConfigResponse])
async def list_users_config(current_user: User = Depends(require_admin_from_state)):
"""List all users for config interface"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail={"error": "Authentication is disabled"}
)
users = user_manager.list_users()
return [UserConfigResponse(**user.to_public_dict()) for user in users]
@router.post("/users", response_model=MessageConfigResponse)
async def create_user_config(request: CreateUserConfigRequest, current_user: User = Depends(require_admin_from_state)):
"""Create a new user through config interface"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail={"error": "Authentication is disabled"}
)
# Validate role
if request.role not in ["user", "admin"]:
raise HTTPException(
status_code=400,
detail={"error": "Role must be 'user' or 'admin'"}
)
success, message = user_manager.create_user(
username=request.username,
password=request.password,
email=request.email,
role=request.role
)
if not success:
raise HTTPException(
status_code=400,
detail={"error": message}
)
return MessageConfigResponse(message=message)
@router.delete("/users/{username}", response_model=MessageConfigResponse)
async def delete_user_config(username: str, current_user: User = Depends(require_admin_from_state)):
"""Delete a user through config interface"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail={"error": "Authentication is disabled"}
)
if username == current_user.username:
raise HTTPException(
status_code=400,
detail={"error": "Cannot delete your own account"}
)
success, message = user_manager.delete_user(username)
if not success:
raise HTTPException(
status_code=404,
detail={"error": message}
)
return MessageConfigResponse(message=message)
@router.put("/users/{username}/role", response_model=MessageConfigResponse)
async def update_user_role_config(
username: str,
request: Request,
current_user: User = Depends(require_admin_from_state)
):
"""Update user role through config interface"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail={"error": "Authentication is disabled"}
)
try:
data = await request.json()
role = data.get("role")
except:
raise HTTPException(
status_code=400,
detail={"error": "Invalid request body"}
)
if role not in ["user", "admin"]:
raise HTTPException(
status_code=400,
detail={"error": "Role must be 'user' or 'admin'"}
)
if username == current_user.username:
raise HTTPException(
status_code=400,
detail={"error": "Cannot change your own role"}
)
success, message = user_manager.update_user_role(username, role)
if not success:
raise HTTPException(
status_code=404,
detail={"error": message}
)
return MessageConfigResponse(message=message)

View File

@@ -8,7 +8,7 @@ interface LoginScreenProps {
}
export function LoginScreen({ onSuccess }: LoginScreenProps) {
const { login, register, isLoading, authEnabled, isRemembered } = useAuth();
const { login, register, isLoading, authEnabled, registrationEnabled, isRemembered } = useAuth();
const [isLoginMode, setIsLoginMode] = useState(true);
const [formData, setFormData] = useState({
username: "",
@@ -28,6 +28,14 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
}));
}, [isRemembered]);
// Force login mode if registration is disabled
useEffect(() => {
if (!registrationEnabled && !isLoginMode) {
setIsLoginMode(true);
setErrors({});
}
}, [registrationEnabled, isLoginMode]);
// If auth is not enabled, don't show the login screen
if (!authEnabled) {
return null;
@@ -114,6 +122,11 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
};
const toggleMode = () => {
// Don't allow toggling to registration if it's disabled
if (!registrationEnabled && isLoginMode) {
return;
}
setIsLoginMode(!isLoginMode);
setErrors({});
setFormData({
@@ -287,14 +300,20 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
<div className="mt-6 text-center">
<p className="text-content-secondary dark:text-content-secondary-dark">
{isLoginMode ? "Don't have an account? " : "Already have an account? "}
<button
type="button"
onClick={toggleMode}
disabled={isSubmitting || isLoading}
className="text-primary hover:text-primary-hover font-medium transition-colors disabled:opacity-50"
>
{isLoginMode ? "Create one" : "Sign in"}
</button>
{registrationEnabled ? (
<button
type="button"
onClick={toggleMode}
disabled={isSubmitting || isLoading}
className="text-primary hover:text-primary-hover font-medium transition-colors disabled:opacity-50"
>
{isLoginMode ? "Create one" : "Sign in"}
</button>
) : (
<span className="text-content-muted dark:text-content-muted-dark">
Registration is currently disabled. Please contact the administrator.
</span>
)}
</p>
</div>
</div>

View File

@@ -0,0 +1,320 @@
import { useState, useEffect } from "react";
import { useAuth } from "@/contexts/auth-context";
import { authApiClient } from "@/lib/api-client";
import { toast } from "sonner";
import type { User, CreateUserRequest } from "@/types/auth";
export function UserManagementTab() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createForm, setCreateForm] = useState<CreateUserRequest>({
username: "",
password: "",
email: "",
role: "user"
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setIsLoading(true);
const userList = await authApiClient.listUsers();
setUsers(userList);
} catch (error) {
console.error("Failed to load users:", error);
toast.error("Failed to load users");
} finally {
setIsLoading(false);
}
};
const validateCreateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!createForm.username.trim()) {
newErrors.username = "Username is required";
} else if (createForm.username.length < 3) {
newErrors.username = "Username must be at least 3 characters";
}
if (!createForm.password) {
newErrors.password = "Password is required";
} else if (createForm.password.length < 6) {
newErrors.password = "Password must be at least 6 characters";
}
if (createForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(createForm.email)) {
newErrors.email = "Please enter a valid email address";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateCreateForm()) {
return;
}
try {
setIsCreating(true);
await authApiClient.createUser({
...createForm,
email: createForm.email?.trim() || undefined,
});
// Reset form and reload users
setCreateForm({ username: "", password: "", email: "", role: "user" });
setShowCreateForm(false);
setErrors({});
await loadUsers();
} catch (error) {
console.error("Failed to create user:", error);
} finally {
setIsCreating(false);
}
};
const handleDeleteUser = async (username: string) => {
if (username === currentUser?.username) {
toast.error("Cannot delete your own account");
return;
}
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
try {
await authApiClient.deleteUser(username);
await loadUsers();
} catch (error) {
console.error("Failed to delete user:", error);
}
};
const handleRoleChange = async (username: string, newRole: "user" | "admin") => {
if (username === currentUser?.username) {
toast.error("Cannot change your own role");
return;
}
try {
await authApiClient.updateUserRole(username, newRole);
await loadUsers();
} catch (error) {
console.error("Failed to update user role:", error);
}
};
const handleInputChange = (field: keyof CreateUserRequest, value: string) => {
setCreateForm(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: "" }));
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-content-primary dark:text-content-primary-dark">
User Management
</h3>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
Manage user accounts and permissions
</p>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors"
>
{showCreateForm ? "Cancel" : "Create User"}
</button>
</div>
{/* Create User Form */}
{showCreateForm && (
<div className="bg-surface-secondary dark:bg-surface-secondary-dark rounded-lg p-6 border border-border dark:border-border-dark">
<h4 className="text-md font-medium text-content-primary dark:text-content-primary-dark mb-4">
Create New User
</h4>
<form onSubmit={handleCreateUser} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Username *
</label>
<input
type="text"
value={createForm.username}
onChange={(e) => handleInputChange("username", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.username
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter username"
disabled={isCreating}
/>
{errors.username && (
<p className="mt-1 text-sm text-error">{errors.username}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Email
</label>
<input
type="email"
value={createForm.email}
onChange={(e) => handleInputChange("email", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.email
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter email (optional)"
disabled={isCreating}
/>
{errors.email && (
<p className="mt-1 text-sm text-error">{errors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Password *
</label>
<input
type="password"
value={createForm.password}
onChange={(e) => handleInputChange("password", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.password
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter password"
disabled={isCreating}
/>
{errors.password && (
<p className="mt-1 text-sm text-error">{errors.password}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Role *
</label>
<select
value={createForm.role}
onChange={(e) => handleInputChange("role", e.target.value as "user" | "admin")}
className="w-full px-3 py-2 rounded-lg border border-input-border dark:border-input-border-dark focus:border-primary bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20"
disabled={isCreating}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isCreating}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isCreating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating...
</>
) : (
"Create User"
)}
</button>
</div>
</form>
</div>
)}
{/* Users List */}
<div className="bg-surface dark:bg-surface-dark rounded-lg border border-border dark:border-border-dark overflow-hidden">
<div className="px-6 py-4 border-b border-border dark:border-border-dark">
<h4 className="text-md font-medium text-content-primary dark:text-content-primary-dark">
Users ({users.length})
</h4>
</div>
{users.length === 0 ? (
<div className="px-6 py-8 text-center text-content-secondary dark:text-content-secondary-dark">
No users found
</div>
) : (
<div className="divide-y divide-border dark:divide-border-dark">
{users.map((user) => (
<div key={user.username} className="px-6 py-4 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<div>
<p className="font-medium text-content-primary dark:text-content-primary-dark">
{user.username}
{user.username === currentUser?.username && (
<span className="ml-2 text-xs text-primary">(You)</span>
)}
</p>
{user.email && (
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
{user.email}
</p>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.username, e.target.value as "user" | "admin")}
disabled={user.username === currentUser?.username}
className="px-3 py-1 text-sm rounded-lg border border-input-border dark:border-input-border-dark bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark disabled:opacity-50"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onClick={() => handleDeleteUser(user.username)}
disabled={user.username === currentUser?.username}
className="px-3 py-1 text-sm text-error hover:bg-error-muted rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -17,6 +17,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authEnabled, setAuthEnabled] = useState(false);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
// Guard to prevent multiple simultaneous initializations
@@ -49,6 +50,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
if (tokenValidation.isValid && tokenValidation.userData) {
// Token is valid and we have user data
setAuthEnabled(tokenValidation.userData.auth_enabled);
setRegistrationEnabled(tokenValidation.userData.registration_enabled);
if (tokenValidation.userData.authenticated && tokenValidation.userData.user) {
setUser(tokenValidation.userData.user);
console.log("Session restored for user:", tokenValidation.userData.user.username);
@@ -68,6 +70,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
console.log("Checking auth status...");
const status = await authApiClient.checkAuthStatus();
setAuthEnabled(status.auth_enabled);
setRegistrationEnabled(status.registration_enabled);
if (!status.auth_enabled) {
console.log("Authentication is disabled");
@@ -114,6 +117,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
const status = await authApiClient.checkAuthStatus();
setAuthEnabled(status.auth_enabled);
setRegistrationEnabled(status.registration_enabled);
if (status.auth_enabled && status.authenticated && status.user) {
setUser(status.user);
@@ -222,6 +226,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
isAuthenticated,
isLoading,
authEnabled,
registrationEnabled,
// Actions
login,

View File

@@ -11,8 +11,10 @@ import {
import { toast } from "sonner";
import { v4 as uuidv4 } from "uuid";
import type { CallbackObject } from "@/types/callbacks";
import { useAuth } from "@/contexts/auth-context";
export function QueueProvider({ children }: { children: ReactNode }) {
const { isLoading, authEnabled, isAuthenticated } = useAuth();
const [items, setItems] = useState<QueueItem[]>([]);
const [isVisible, setIsVisible] = useState(false);
const [totalTasks, setTotalTasks] = useState(0);
@@ -355,7 +357,12 @@ export function QueueProvider({ children }: { children: ReactNode }) {
};
eventSource.onerror = (error) => {
console.error("SSE connection error:", error);
// Use appropriate logging level - first attempt failures are common and expected
if (reconnectAttempts.current === 0) {
console.log("SSE initial connection failed, will retry shortly...");
} else {
console.warn("SSE connection error:", error);
}
// Check if this might be an auth error by testing if we still have a valid token
const token = authApiClient.getToken();
@@ -372,12 +379,22 @@ export function QueueProvider({ children }: { children: ReactNode }) {
if (reconnectAttempts.current < maxReconnectAttempts) {
reconnectAttempts.current++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current - 1), 30000);
// Use shorter delays for faster recovery, especially on first attempts
const baseDelay = reconnectAttempts.current === 1 ? 100 : 1000;
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current - 1), 15000);
console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current}/${maxReconnectAttempts})`);
if (reconnectAttempts.current === 1) {
console.log("SSE: Retrying connection shortly...");
} else {
console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current}/${maxReconnectAttempts})`);
}
reconnectTimeoutRef.current = window.setTimeout(() => {
console.log("SSE: Attempting to reconnect...");
if (reconnectAttempts.current === 1) {
console.log("SSE: Attempting reconnection...");
} else {
console.log("SSE: Attempting to reconnect...");
}
connectSSE();
}, delay);
} else {
@@ -387,8 +404,11 @@ export function QueueProvider({ children }: { children: ReactNode }) {
};
} catch (error) {
console.error("Failed to create SSE connection:", error);
toast.error("Failed to establish connection");
console.log("Initial SSE connection setup failed, will retry:", error);
// Don't show toast for initial connection failures since they often recover quickly
if (reconnectAttempts.current > 0) {
toast.error("Failed to establish connection");
}
}
}, [createQueueItemFromTask, scheduleRemoval, startHealthCheck]);
@@ -444,8 +464,20 @@ export function QueueProvider({ children }: { children: ReactNode }) {
// Note: SSE connection state is managed through the initialize effect and restartSSE method
// The auth context should call restartSSE() when login/logout occurs
// Initialize queue on mount
// Initialize queue on mount - but only after authentication is ready
useEffect(() => {
// Don't initialize if still loading auth state
if (isLoading) {
console.log("QueueProvider: Waiting for auth initialization...");
return;
}
// Don't initialize if auth is enabled but user is not authenticated
if (authEnabled && !isAuthenticated) {
console.log("QueueProvider: Auth required but user not authenticated, skipping initialization");
return;
}
const initializeQueue = async () => {
try {
const response = await authApiClient.client.get(`/prgs/list?page=1&limit=${pageSize}`);
@@ -469,13 +501,17 @@ export function QueueProvider({ children }: { children: ReactNode }) {
(total_tasks || 0);
setTotalTasks(calculatedTotal);
connectSSE();
// Add a small delay before connecting SSE to give server time to be ready
setTimeout(() => {
connectSSE();
}, 1000);
} catch (error) {
console.error("Failed to initialize queue:", error);
toast.error("Could not load queue");
}
};
console.log("QueueProvider: Auth ready, initializing queue...");
initializeQueue();
return () => {
@@ -484,7 +520,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
Object.values(removalTimers.current).forEach(clearTimeout);
removalTimers.current = {};
};
}, [connectSSE, disconnectSSE, createQueueItemFromTask, stopHealthCheck]);
}, [isLoading, authEnabled, isAuthenticated, connectSSE, disconnectSSE, createQueueItemFromTask, stopHealthCheck]);
// Queue actions
const addItem = useCallback(async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {

View File

@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
import { authApiClient } from "../lib/api-client";
import { SettingsContext, type AppSettings } from "./settings-context";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./auth-context";
// --- Case Conversion Utility ---
// This is added here to simplify the fix and avoid module resolution issues.
@@ -127,19 +128,25 @@ const fetchSettings = async (): Promise<FlatAppSettings> => {
};
export function SettingsProvider({ children }: { children: ReactNode }) {
const { isLoading, authEnabled, isAuthenticated, user } = useAuth();
// Only fetch settings when auth is ready and user is admin (or auth is disabled)
const shouldFetchSettings = !isLoading && (!authEnabled || (isAuthenticated && user?.role === "admin"));
const {
data: settings,
isLoading,
isLoading: isSettingsLoading,
isError,
} = useQuery({
queryKey: ["config"],
queryFn: fetchSettings,
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
enabled: shouldFetchSettings, // Only run query when auth is ready and user is admin
});
// Use default settings on error to prevent app crash
const value = { settings: isError ? defaultSettings : settings || null, isLoading };
const value = { settings: isError ? defaultSettings : settings || null, isLoading: isSettingsLoading };
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}

View File

@@ -6,7 +6,8 @@ import type {
RegisterRequest,
LoginResponse,
AuthStatusResponse,
User
User,
CreateUserRequest
} from "@/types/auth";
class AuthApiClient {
@@ -289,6 +290,16 @@ class AuthApiClient {
return response.data;
}
async createUser(userData: CreateUserRequest): Promise<{ message: string }> {
const response = await this.apiClient.post("/auth/users/create", userData);
toast.success("User Created", {
description: `User ${userData.username} created successfully.`,
});
return response.data;
}
// Expose the underlying axios instance for other API calls
get client() {
return this.apiClient;

View File

@@ -1,10 +1,11 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { GeneralTab } from "../components/config/GeneralTab";
import { DownloadsTab } from "../components/config/DownloadsTab";
import { FormattingTab } from "../components/config/FormattingTab";
import { AccountsTab } from "../components/config/AccountsTab";
import { WatchTab } from "../components/config/WatchTab";
import { ServerTab } from "../components/config/ServerTab";
import { UserManagementTab } from "../components/config/UserManagementTab";
import { useSettings } from "../contexts/settings-context";
import { useAuth } from "../contexts/auth-context";
import { LoginScreen } from "../components/auth/LoginScreen";
@@ -16,6 +17,13 @@ const ConfigComponent = () => {
// Get settings from the context instead of fetching here
const { settings: config, isLoading } = useSettings();
// Reset to general tab if user is on user-management but auth is disabled
useEffect(() => {
if (!authEnabled && activeTab === "user-management") {
setActiveTab("general");
}
}, [authEnabled, activeTab]);
// Show loading while authentication is being checked
if (authLoading) {
return (
@@ -58,6 +66,11 @@ const ConfigComponent = () => {
}
const renderTabContent = () => {
// User management doesn't need config data
if (activeTab === "user-management") {
return <UserManagementTab />;
}
if (isLoading) return <div className="text-center py-12"><p className="text-content-muted dark:text-content-muted-dark">Loading configuration...</p></div>;
if (!config) return <div className="text-center py-12"><p className="text-error-text bg-error-muted p-4 rounded-lg">Error loading configuration.</p></div>;
@@ -130,6 +143,14 @@ const ConfigComponent = () => {
>
Server
</button>
{authEnabled && (
<button
onClick={() => setActiveTab("user-management")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "user-management" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
>
User Management
</button>
)}
</nav>
</aside>

View File

@@ -28,6 +28,14 @@ export interface AuthStatusResponse {
auth_enabled: boolean;
authenticated: boolean;
user?: User;
registration_enabled: boolean;
}
export interface CreateUserRequest {
username: string;
password: string;
email?: string;
role: "user" | "admin";
}
export interface AuthContextType {
@@ -36,6 +44,7 @@ export interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
authEnabled: boolean;
registrationEnabled: boolean;
// Actions
login: (credentials: LoginRequest, rememberMe?: boolean) => Promise<void>;