Improved user management (again)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
267
spotizerr-ui/src/components/config/ProfileTab.tsx
Normal file
267
spotizerr-ui/src/components/config/ProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user