diff --git a/routes/auth/__init__.py b/routes/auth/__init__.py index 720be20..2698788 100644 --- a/routes/auth/__init__.py +++ b/routes/auth/__init__.py @@ -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 diff --git a/routes/auth/auth.py b/routes/auth/auth.py index 038ebfc..3c350b4 100644 --- a/routes/auth/auth.py +++ b/routes/auth/auth.py @@ -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 \ No newline at end of file diff --git a/spotizerr-ui/src/components/auth/LoginScreen.tsx b/spotizerr-ui/src/components/auth/LoginScreen.tsx index c38786a..786f41f 100644 --- a/spotizerr-ui/src/components/auth/LoginScreen.tsx +++ b/spotizerr-ui/src/components/auth/LoginScreen.tsx @@ -19,6 +19,7 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) { }); const [errors, setErrors] = useState>({}); 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) { + {/* Registration disabled notice for SSO */} + {ssoRegistrationError && ( +
+

+ Only existing users can sign in with SSO +

+
+ )} +
{ssoProviders.map((provider) => (
+ + +
+ + + )} + + {/* SSO User Notice */} + {user?.is_sso_user && ( +
+

+ SSO Account +

+

+ Your account is managed by {user.sso_provider}. To change your password, + please use your {user.sso_provider} account settings. +

+
+ )} + + ); +} \ No newline at end of file diff --git a/spotizerr-ui/src/components/config/UserManagementTab.tsx b/spotizerr-ui/src/components/config/UserManagementTab.tsx index 9623cef..f6b76df 100644 --- a/spotizerr-ui/src/components/config/UserManagementTab.tsx +++ b/spotizerr-ui/src/components/config/UserManagementTab.tsx @@ -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>({}); + + // Password reset state + const [showPasswordResetModal, setShowPasswordResetModal] = useState(false); + const [passwordResetUser, setPasswordResetUser] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isResettingPassword, setIsResettingPassword] = useState(false); + const [passwordErrors, setPasswordErrors] = useState>({}); 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 = {}; + + 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 (
@@ -281,6 +342,11 @@ export function UserManagementTab() { {user.username === currentUser?.username && ( (You) )} + {user.is_sso_user && ( + + SSO ({user.sso_provider}) + + )}

{user.email && (

@@ -302,6 +368,17 @@ export function UserManagementTab() { + {/* Only show reset password for non-SSO users */} + {!user.is_sso_user && ( + + )} +

)} + + {/* Password Reset Modal */} + {showPasswordResetModal && ( +
+
+
+

+ Reset Password for {passwordResetUser} +

+

+ Enter a new password for this user. The user will need to use this password to log in. +

+ +
+
+ + { + 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 && ( +

{passwordErrors.newPassword}

+ )} +

+ Must be at least 6 characters long +

+
+ +
+ + { + 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 && ( +

{passwordErrors.confirmPassword}

+ )} +
+ +
+ + +
+
+
+
+
+ )} ); } \ No newline at end of file diff --git a/spotizerr-ui/src/contexts/AuthProvider.tsx b/spotizerr-ui/src/contexts/AuthProvider.tsx index 36bfd62..d192084 100644 --- a/spotizerr-ui/src/contexts/AuthProvider.tsx +++ b/spotizerr-ui/src/contexts/AuthProvider.tsx @@ -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([]); diff --git a/spotizerr-ui/src/lib/api-client.ts b/spotizerr-ui/src/lib/api-client.ts index ad45e6e..203897a 100644 --- a/spotizerr-ui/src/lib/api-client.ts +++ b/spotizerr-ui/src/lib/api-client.ts @@ -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 { const response = await this.apiClient.get("/auth/sso/status"); diff --git a/spotizerr-ui/src/router.tsx b/spotizerr-ui/src/router.tsx index 4050e3c..212971c 100644 --- a/spotizerr-ui/src/router.tsx +++ b/spotizerr-ui/src/router.tsx @@ -73,6 +73,11 @@ const configRoute = createRoute({ getParentRoute: () => rootRoute, path: "/config", component: Config, + validateSearch: (search: Record): { tab?: string } => { + return { + tab: typeof search.tab === "string" ? search.tab : undefined, + }; + }, }); const playlistRoute = createRoute({ diff --git a/spotizerr-ui/src/routes/config.tsx b/spotizerr-ui/src/routes/config.tsx index f713cc3..4b148bd 100644 --- a/spotizerr-ui/src/routes/config.tsx +++ b/spotizerr-ui/src/routes/config.tsx @@ -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 ( -
-
-

Access Denied

-

- You need administrator privileges to access configuration settings. -

-

- Current role: {user?.role || 'user'} -

-
-
- ); - } + // 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 ; } + if (activeTab === "profile") { + return ; + } + if (isLoading) return

Loading configuration...

; if (!config) return

Error loading configuration.

; @@ -95,8 +133,14 @@ const ConfigComponent = () => { return (
-

Configuration

-

Manage application settings and services.

+

+ {authEnabled && !isAdmin ? "Profile Settings" : "Configuration"} +

+

+ {authEnabled && !isAdmin + ? "Manage your profile and account settings." + : "Manage application settings and services."} +

{authEnabled && user && (

Logged in as: {user.username} ({user.role}) @@ -107,45 +151,62 @@ const ConfigComponent = () => {