Improved user management (again)

This commit is contained in:
Xoconoch
2025-08-04 10:52:25 -06:00
parent 9ad19bbb22
commit 14b350b122
11 changed files with 779 additions and 85 deletions

View File

@@ -171,6 +171,69 @@ class UserManager:
logger.info(f"Updated role for user {username} to {role}")
return True, "User role updated successfully"
def change_password(self, username: str, current_password: str, new_password: str) -> tuple[bool, str]:
"""Change user password after validating current password"""
users = self.load_users()
if username not in users:
return False, "User not found"
user_data = users[username]
# Check if user is SSO user
if user_data.get("sso_provider"):
return False, f"Cannot change password for SSO user. Please change your password through {user_data['sso_provider']}."
# Check if user has a password hash
if not user_data.get("password_hash"):
return False, "Cannot change password for SSO user"
# Verify current password
if not self.verify_password(current_password, user_data["password_hash"]):
return False, "Current password is incorrect"
# Validate new password
if len(new_password) < 6:
return False, "New password must be at least 6 characters long"
if current_password == new_password:
return False, "New password must be different from current password"
# Update password
users[username]["password_hash"] = self.hash_password(new_password)
self.save_users(users)
logger.info(f"Password changed for user: {username}")
return True, "Password changed successfully"
def admin_reset_password(self, username: str, new_password: str) -> tuple[bool, str]:
"""Admin reset user password (no current password verification required)"""
users = self.load_users()
if username not in users:
return False, "User not found"
user_data = users[username]
# Check if user is SSO user
if user_data.get("sso_provider"):
return False, f"Cannot reset password for SSO user. User manages password through {user_data['sso_provider']}."
# Check if user has a password hash (should exist for non-SSO users)
if not user_data.get("password_hash"):
return False, "Cannot reset password for SSO user"
# Validate new password
if len(new_password) < 6:
return False, "New password must be at least 6 characters long"
# Update password
users[username]["password_hash"] = self.hash_password(new_password)
self.save_users(users)
logger.info(f"Password reset by admin for user: {username}")
return True, "Password reset successfully"
class TokenManager:
@staticmethod

View File

@@ -37,6 +37,17 @@ class RoleUpdateRequest(BaseModel):
role: str
class PasswordChangeRequest(BaseModel):
"""Request to change user password"""
current_password: str
new_password: str
class AdminPasswordResetRequest(BaseModel):
"""Request for admin to reset user password"""
new_password: str
class UserResponse(BaseModel):
username: str
email: Optional[str]
@@ -290,8 +301,7 @@ async def get_profile(current_user: User = Depends(require_auth)):
@router.put("/profile/password", response_model=MessageResponse)
async def change_password(
current_password: str,
new_password: str,
request: PasswordChangeRequest,
current_user: User = Depends(require_auth)
):
"""Change current user's password"""
@@ -301,27 +311,54 @@ async def change_password(
detail="Authentication is disabled"
)
# Verify current password
authenticated_user = user_manager.authenticate_user(
current_user.username,
current_password
success, message = user_manager.change_password(
username=current_user.username,
current_password=request.current_password,
new_password=request.new_password
)
if not authenticated_user:
if not success:
# Determine appropriate HTTP status code based on error message
if "Current password is incorrect" in message:
status_code = 401
elif "User not found" in message:
status_code = 404
else:
status_code = 400
raise HTTPException(status_code=status_code, detail=message)
return MessageResponse(message=message)
@router.put("/users/{username}/password", response_model=MessageResponse)
async def admin_reset_password(
username: str,
request: AdminPasswordResetRequest,
current_user: User = Depends(require_admin)
):
"""Admin reset user password (admin only)"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=401,
detail="Current password is incorrect"
status_code=400,
detail="Authentication is disabled"
)
# Update password (we need to load users, update, and save)
users = user_manager.load_users()
if current_user.username not in users:
raise HTTPException(status_code=404, detail="User not found")
success, message = user_manager.admin_reset_password(
username=username,
new_password=request.new_password
)
users[current_user.username]["password_hash"] = user_manager.hash_password(new_password)
user_manager.save_users(users)
if not success:
# Determine appropriate HTTP status code based on error message
if "User not found" in message:
status_code = 404
else:
status_code = 400
raise HTTPException(status_code=status_code, detail=message)
logger.info(f"Password changed for user: {current_user.username}")
return MessageResponse(message="Password changed successfully")
return MessageResponse(message=message)
# Note: SSO routes are included in the main app, not here to avoid circular imports

View File

@@ -19,6 +19,7 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [ssoRegistrationError, setSSORegistrationError] = useState(false);
// Initialize remember me checkbox with stored preference
useEffect(() => {
@@ -36,6 +37,32 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
}
}, [registrationEnabled, isLoginMode]);
// Handle URL parameters (e.g., SSO errors)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const errorParam = urlParams.get('error');
if (errorParam) {
const decodedError = decodeURIComponent(errorParam);
// Check if this is specifically a registration disabled error from SSO
if (decodedError.includes("Registration is disabled")) {
setSSORegistrationError(true);
}
// Show the error message
toast.error("Authentication Error", {
description: decodedError,
duration: 5000, // Show for 5 seconds
});
// Clean up the URL parameter
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('error');
window.history.replaceState({}, '', newUrl.toString());
}
}, []); // Run only once on component mount
// If auth is not enabled, don't show the login screen
if (!authEnabled) {
return null;
@@ -81,6 +108,7 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
}
setIsSubmitting(true);
setSSORegistrationError(false); // Clear SSO registration error when submitting
try {
if (isLoginMode) {
@@ -119,6 +147,10 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
if (typeof value === 'string' && errors[field]) {
setErrors(prev => ({ ...prev, [field]: "" }));
}
// Clear SSO registration error when user starts interacting with the form
if (typeof value === 'string' && ssoRegistrationError) {
setSSORegistrationError(false);
}
};
const toggleMode = () => {
@@ -129,6 +161,7 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
setIsLoginMode(!isLoginMode);
setErrors({});
setSSORegistrationError(false); // Clear SSO registration error when switching modes
setFormData({
username: "",
password: "",
@@ -311,6 +344,15 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
</div>
</div>
{/* Registration disabled notice for SSO */}
{ssoRegistrationError && (
<div className="mt-4 p-3 bg-surface-secondary dark:bg-surface-secondary-dark rounded-lg border border-border dark:border-border-dark">
<p className="text-sm text-content-secondary dark:text-content-secondary-dark text-center">
Only existing users can sign in with SSO
</p>
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-3">
{ssoProviders.map((provider) => (
<button

View File

@@ -1,10 +1,12 @@
import { useState, useRef, useEffect } from "react";
import { useAuth } from "@/contexts/auth-context";
import { useNavigate } from "@tanstack/react-router";
export function UserMenu() {
const { user, logout, authEnabled, isRemembered } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
// Don't render if auth is disabled or user is not logged in
if (!authEnabled || !user) {
@@ -34,6 +36,11 @@ export function UserMenu() {
}
};
const handleProfileSettings = () => {
navigate({ to: "/config", search: { tab: "profile" } });
setIsOpen(false);
};
const sessionType = isRemembered();
return (
@@ -88,6 +95,12 @@ export function UserMenu() {
</div>
<div className="p-2">
<button
onClick={handleProfileSettings}
className="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark transition-colors"
>
Profile Settings
</button>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark transition-colors"

View File

@@ -0,0 +1,267 @@
import { useState } from "react";
import { useAuth } from "@/contexts/auth-context";
import { authApiClient } from "@/lib/api-client";
import { toast } from "sonner";
export function ProfileTab() {
const { user } = useAuth();
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validatePasswordForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!passwordForm.currentPassword) {
newErrors.currentPassword = "Current password is required";
}
if (!passwordForm.newPassword) {
newErrors.newPassword = "New password is required";
} else if (passwordForm.newPassword.length < 6) {
newErrors.newPassword = "New password must be at least 6 characters";
}
if (!passwordForm.confirmPassword) {
newErrors.confirmPassword = "Please confirm your new password";
} else if (passwordForm.newPassword !== passwordForm.confirmPassword) {
newErrors.confirmPassword = "Passwords do not match";
}
if (passwordForm.currentPassword === passwordForm.newPassword) {
newErrors.newPassword = "New password must be different from current password";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
if (!validatePasswordForm()) {
return;
}
try {
setIsChangingPassword(true);
await authApiClient.changePassword(
passwordForm.currentPassword,
passwordForm.newPassword
);
// Reset form on success
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
setErrors({});
} catch (error: any) {
console.error("Password change failed:", error);
// The API client will show the toast error, but we might want to handle specific field errors
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
if (detail.includes("Current password is incorrect")) {
setErrors({ currentPassword: "Current password is incorrect" });
} else if (detail.includes("New password must be")) {
setErrors({ newPassword: detail });
}
}
} finally {
setIsChangingPassword(false);
}
};
const handleInputChange = (field: string, value: string) => {
setPasswordForm(prev => ({
...prev,
[field]: value
}));
// Clear error for this field when user starts typing
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ""
}));
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-semibold text-content-primary dark:text-content-primary-dark mb-4">
Profile Settings
</h2>
<p className="text-content-muted dark:text-content-muted-dark">
Manage your profile information and security settings.
</p>
</div>
{/* User Information */}
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-lg p-6">
<h3 className="text-lg font-medium text-content-primary dark:text-content-primary-dark mb-4">
Account Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Username
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.username}
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Email
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.email || "Not provided"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Role
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.role === "admin" ? "Administrator" : "User"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Account Type
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.is_sso_user ? `SSO (${user.sso_provider})` : "Local Account"}
</p>
</div>
</div>
</div>
{/* Password Change Section - Only show for non-SSO users */}
{user && !user.is_sso_user && (
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-lg p-6">
<h3 className="text-lg font-medium text-content-primary dark:text-content-primary-dark mb-4">
Change Password
</h3>
<p className="text-content-muted dark:text-content-muted-dark mb-6">
Update your password to keep your account secure.
</p>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-2">
Current Password
</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={(e) => handleInputChange("currentPassword", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.currentPassword
? "border-error text-error-text bg-error-muted"
: "border-border dark:border-border-dark bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark focus:border-primary focus:ring-1 focus:ring-primary"
}`}
placeholder="Enter your current password"
disabled={isChangingPassword}
/>
{errors.currentPassword && (
<p className="text-error-text text-sm mt-1">{errors.currentPassword}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-2">
New Password
</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => handleInputChange("newPassword", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.newPassword
? "border-error text-error-text bg-error-muted"
: "border-border dark:border-border-dark bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark focus:border-primary focus:ring-1 focus:ring-primary"
}`}
placeholder="Enter your new password"
disabled={isChangingPassword}
/>
{errors.newPassword && (
<p className="text-error-text text-sm mt-1">{errors.newPassword}</p>
)}
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">
Must be at least 6 characters long
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-2">
Confirm New Password
</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.confirmPassword
? "border-error text-error-text bg-error-muted"
: "border-border dark:border-border-dark bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark focus:border-primary focus:ring-1 focus:ring-primary"
}`}
placeholder="Confirm your new password"
disabled={isChangingPassword}
/>
{errors.confirmPassword && (
<p className="text-error-text text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isChangingPassword}
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"
>
{isChangingPassword ? "Changing Password..." : "Change Password"}
</button>
<button
type="button"
onClick={() => {
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
setErrors({});
}}
disabled={isChangingPassword}
className="px-4 py-2 bg-surface-accent dark:bg-surface-accent-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* SSO User Notice */}
{user?.is_sso_user && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<h3 className="text-lg font-medium text-blue-900 dark:text-blue-100 mb-2">
SSO Account
</h3>
<p className="text-blue-800 dark:text-blue-200">
Your account is managed by {user.sso_provider}. To change your password,
please use your {user.sso_provider} account settings.
</p>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@ 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";
import type { User, CreateUserRequest, AdminPasswordResetRequest } from "@/types/auth";
export function UserManagementTab() {
const { user: currentUser } = useAuth();
@@ -17,6 +17,14 @@ export function UserManagementTab() {
role: "user"
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Password reset state
const [showPasswordResetModal, setShowPasswordResetModal] = useState(false);
const [passwordResetUser, setPasswordResetUser] = useState<string>("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [passwordErrors, setPasswordErrors] = useState<Record<string, string>>({});
useEffect(() => {
loadUsers();
@@ -123,6 +131,59 @@ export function UserManagementTab() {
}
};
const openPasswordResetModal = (username: string) => {
setPasswordResetUser(username);
setNewPassword("");
setConfirmPassword("");
setPasswordErrors({});
setShowPasswordResetModal(true);
};
const closePasswordResetModal = () => {
setShowPasswordResetModal(false);
setPasswordResetUser("");
setNewPassword("");
setConfirmPassword("");
setPasswordErrors({});
};
const validatePasswordReset = (): boolean => {
const errors: Record<string, string> = {};
if (!newPassword) {
errors.newPassword = "New password is required";
} else if (newPassword.length < 6) {
errors.newPassword = "Password must be at least 6 characters long";
}
if (!confirmPassword) {
errors.confirmPassword = "Please confirm the password";
} else if (newPassword !== confirmPassword) {
errors.confirmPassword = "Passwords do not match";
}
setPasswordErrors(errors);
return Object.keys(errors).length === 0;
};
const handlePasswordReset = async (e: React.FormEvent) => {
e.preventDefault();
if (!validatePasswordReset()) {
return;
}
try {
setIsResettingPassword(true);
await authApiClient.adminResetPassword(passwordResetUser, newPassword);
closePasswordResetModal();
} catch (error) {
console.error("Failed to reset password:", error);
} finally {
setIsResettingPassword(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@@ -281,6 +342,11 @@ export function UserManagementTab() {
{user.username === currentUser?.username && (
<span className="ml-2 text-xs text-primary">(You)</span>
)}
{user.is_sso_user && (
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
SSO ({user.sso_provider})
</span>
)}
</p>
{user.email && (
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
@@ -302,6 +368,17 @@ export function UserManagementTab() {
<option value="admin">Admin</option>
</select>
{/* Only show reset password for non-SSO users */}
{!user.is_sso_user && (
<button
onClick={() => openPasswordResetModal(user.username)}
disabled={user.username === currentUser?.username}
className="px-3 py-1 text-sm text-content-primary dark:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset Password
</button>
)}
<button
onClick={() => handleDeleteUser(user.username)}
disabled={user.username === currentUser?.username}
@@ -315,6 +392,104 @@ export function UserManagementTab() {
</div>
)}
</div>
{/* Password Reset Modal */}
{showPasswordResetModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-surface dark:bg-surface-dark rounded-xl border border-border dark:border-border-dark shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h3 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-4">
Reset Password for {passwordResetUser}
</h3>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark mb-6">
Enter a new password for this user. The user will need to use this password to log in.
</p>
<form onSubmit={handlePasswordReset} className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
if (passwordErrors.newPassword) {
setPasswordErrors(prev => ({ ...prev, newPassword: "" }));
}
}}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
passwordErrors.newPassword
? "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 new password"
disabled={isResettingPassword}
/>
{passwordErrors.newPassword && (
<p className="mt-1 text-sm text-error">{passwordErrors.newPassword}</p>
)}
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">
Must be at least 6 characters long
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (passwordErrors.confirmPassword) {
setPasswordErrors(prev => ({ ...prev, confirmPassword: "" }));
}
}}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
passwordErrors.confirmPassword
? "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="Confirm new password"
disabled={isResettingPassword}
/>
{passwordErrors.confirmPassword && (
<p className="mt-1 text-sm text-error">{passwordErrors.confirmPassword}</p>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={closePasswordResetModal}
disabled={isResettingPassword}
className="px-4 py-2 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isResettingPassword}
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"
>
{isResettingPassword ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Resetting...
</>
) : (
"Reset Password"
)}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -83,6 +83,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);
@@ -117,6 +119,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);
@@ -147,6 +151,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);
@@ -206,6 +212,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);

View File

@@ -8,7 +8,8 @@ import type {
AuthStatusResponse,
User,
CreateUserRequest,
SSOStatusResponse
SSOStatusResponse,
AdminPasswordResetRequest
} from "@/types/auth";
class AuthApiClient {
@@ -301,6 +302,18 @@ class AuthApiClient {
return response.data;
}
async adminResetPassword(username: string, newPassword: string): Promise<{ message: string }> {
const response = await this.apiClient.put(`/auth/users/${username}/password`, {
new_password: newPassword,
});
toast.success("Password Reset", {
description: `Password for ${username} has been reset successfully.`,
});
return response.data;
}
// SSO methods
async getSSOStatus(): Promise<SSOStatusResponse> {
const response = await this.apiClient.get<SSOStatusResponse>("/auth/sso/status");

View File

@@ -73,6 +73,11 @@ const configRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/config",
component: Config,
validateSearch: (search: Record<string, unknown>): { tab?: string } => {
return {
tab: typeof search.tab === "string" ? search.tab : undefined,
};
},
});
const playlistRoute = createRoute({

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useSearch } from "@tanstack/react-router";
import { GeneralTab } from "../components/config/GeneralTab";
import { DownloadsTab } from "../components/config/DownloadsTab";
import { FormattingTab } from "../components/config/FormattingTab";
@@ -6,23 +7,69 @@ 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 { ProfileTab } from "../components/config/ProfileTab";
import { useSettings } from "../contexts/settings-context";
import { useAuth } from "../contexts/auth-context";
import { LoginScreen } from "../components/auth/LoginScreen";
const ConfigComponent = () => {
const [activeTab, setActiveTab] = useState("general");
const { tab } = useSearch({ from: "/config" });
const { user, isAuthenticated, authEnabled, isLoading: authLoading } = useAuth();
// 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");
// Determine initial tab based on URL parameter, user role, and auth state
const getInitialTab = () => {
if (tab) {
return tab; // Use URL parameter if provided
}
}, [authEnabled, activeTab]);
if (authEnabled && isAuthenticated && user?.role !== "admin") {
return "profile"; // Non-admin users default to profile
}
return "general"; // Admin users and non-auth mode default to general
};
const [activeTab, setActiveTab] = useState(getInitialTab());
const userHasManuallyChangedTab = useRef(false);
// Update active tab when URL parameter changes
useEffect(() => {
if (tab) {
setActiveTab(tab);
userHasManuallyChangedTab.current = false; // Reset manual flag when URL changes
}
}, [tab]);
// Handle tab clicks - track that user manually changed tab
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
userHasManuallyChangedTab.current = true;
};
// Reset to appropriate tab based on auth state and user role (only when tab becomes invalid)
useEffect(() => {
// Check if current tab is invalid for current user
const isInvalidTab = () => {
if (!authEnabled && (activeTab === "user-management" || activeTab === "profile")) {
return true;
}
if (authEnabled && user?.role !== "admin" && ["user-management", "general", "downloads", "formatting", "accounts", "watch", "server"].includes(activeTab)) {
return true;
}
return false;
};
// Only auto-redirect if tab is invalid OR if user hasn't manually changed tabs and no URL param
if (isInvalidTab() || (!userHasManuallyChangedTab.current && !tab)) {
if (!authEnabled || user?.role === "admin") {
setActiveTab("general");
} else {
setActiveTab("profile");
}
userHasManuallyChangedTab.current = false; // Reset after programmatic change
}
}, [authEnabled, user?.role, activeTab, tab]);
// Show loading while authentication is being checked
if (authLoading) {
@@ -48,29 +95,20 @@ const ConfigComponent = () => {
);
}
// Check for admin role if authentication is enabled
if (authEnabled && isAuthenticated && user?.role !== "admin") {
return (
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8">
<div className="text-center py-12">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark mb-4">Access Denied</h1>
<p className="text-content-muted dark:text-content-muted-dark">
You need administrator privileges to access configuration settings.
</p>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-2">
Current role: <span className="font-medium">{user?.role || 'user'}</span>
</p>
</div>
</div>
);
}
// Regular users can access profile tab, but not other config tabs
const isAdmin = user?.role === "admin";
const canAccessAdminTabs = !authEnabled || isAdmin;
const renderTabContent = () => {
// User management doesn't need config data
// User management and profile don't need config data
if (activeTab === "user-management") {
return <UserManagementTab />;
}
if (activeTab === "profile") {
return <ProfileTab />;
}
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>;
@@ -95,8 +133,14 @@ const ConfigComponent = () => {
return (
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8 space-y-8">
<div className="space-y-2">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Configuration</h1>
<p className="text-content-muted dark:text-content-muted-dark">Manage application settings and services.</p>
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">
{authEnabled && !isAdmin ? "Profile Settings" : "Configuration"}
</h1>
<p className="text-content-muted dark:text-content-muted-dark">
{authEnabled && !isAdmin
? "Manage your profile and account settings."
: "Manage application settings and services."}
</p>
{authEnabled && user && (
<p className="text-sm text-content-muted dark:text-content-muted-dark">
Logged in as: <span className="font-medium">{user.username}</span> ({user.role})
@@ -107,45 +151,62 @@ const ConfigComponent = () => {
<div className="flex flex-col lg:flex-row gap-6 lg:gap-10">
<aside className="w-full lg:w-1/4">
<nav className="flex flex-row lg:flex-col overflow-x-auto lg:overflow-x-visible space-x-2 lg:space-x-0 lg:space-y-2 pb-2 lg:pb-0">
<button
onClick={() => setActiveTab("general")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "general" ? "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"}`}
>
General
</button>
<button
onClick={() => setActiveTab("downloads")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "downloads" ? "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"}`}
>
Downloads
</button>
<button
onClick={() => setActiveTab("formatting")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "formatting" ? "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"}`}
>
Formatting
</button>
<button
onClick={() => setActiveTab("accounts")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "accounts" ? "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"}`}
>
Accounts
</button>
<button
onClick={() => setActiveTab("watch")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "watch" ? "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"}`}
>
Watch
</button>
<button
onClick={() => setActiveTab("server")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "server" ? "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"}`}
>
Server
</button>
{authEnabled && (
{/* Profile tab - available to all authenticated users */}
{authEnabled && isAuthenticated && (
<button
onClick={() => setActiveTab("user-management")}
onClick={() => handleTabChange("profile")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "profile" ? "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"}`}
>
Profile
</button>
)}
{/* Admin-only tabs */}
{canAccessAdminTabs && (
<>
<button
onClick={() => handleTabChange("general")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "general" ? "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"}`}
>
General
</button>
<button
onClick={() => handleTabChange("downloads")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "downloads" ? "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"}`}
>
Downloads
</button>
<button
onClick={() => handleTabChange("formatting")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "formatting" ? "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"}`}
>
Formatting
</button>
<button
onClick={() => handleTabChange("accounts")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "accounts" ? "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"}`}
>
Accounts
</button>
<button
onClick={() => handleTabChange("watch")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "watch" ? "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"}`}
>
Watch
</button>
<button
onClick={() => handleTabChange("server")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "server" ? "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"}`}
>
Server
</button>
</>
)}
{/* User Management tab - admin only */}
{authEnabled && isAdmin && (
<button
onClick={() => handleTabChange("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

View File

@@ -45,6 +45,7 @@ export interface SSOProvider {
export interface SSOStatusResponse {
sso_enabled: boolean;
providers: SSOProvider[];
registration_enabled: boolean;
}
export interface CreateUserRequest {
@@ -54,6 +55,15 @@ export interface CreateUserRequest {
role: "user" | "admin";
}
export interface PasswordChangeRequest {
current_password: string;
new_password: string;
}
export interface AdminPasswordResetRequest {
new_password: string;
}
export interface AuthContextType {
// State
user: User | null;