Improved user management, added no-registration mode
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
320
spotizerr-ui/src/components/config/UserManagementTab.tsx
Normal file
320
spotizerr-ui/src/components/config/UserManagementTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user