Improved user management, added no-registration mode
This commit is contained in:
@@ -8,7 +8,7 @@ interface LoginScreenProps {
|
||||
}
|
||||
|
||||
export function LoginScreen({ onSuccess }: LoginScreenProps) {
|
||||
const { login, register, isLoading, authEnabled, isRemembered } = useAuth();
|
||||
const { login, register, isLoading, authEnabled, registrationEnabled, isRemembered } = useAuth();
|
||||
const [isLoginMode, setIsLoginMode] = useState(true);
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
@@ -28,6 +28,14 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
|
||||
}));
|
||||
}, [isRemembered]);
|
||||
|
||||
// Force login mode if registration is disabled
|
||||
useEffect(() => {
|
||||
if (!registrationEnabled && !isLoginMode) {
|
||||
setIsLoginMode(true);
|
||||
setErrors({});
|
||||
}
|
||||
}, [registrationEnabled, isLoginMode]);
|
||||
|
||||
// If auth is not enabled, don't show the login screen
|
||||
if (!authEnabled) {
|
||||
return null;
|
||||
@@ -114,6 +122,11 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
// Don't allow toggling to registration if it's disabled
|
||||
if (!registrationEnabled && isLoginMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoginMode(!isLoginMode);
|
||||
setErrors({});
|
||||
setFormData({
|
||||
@@ -287,14 +300,20 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-content-secondary dark:text-content-secondary-dark">
|
||||
{isLoginMode ? "Don't have an account? " : "Already have an account? "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="text-primary hover:text-primary-hover font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoginMode ? "Create one" : "Sign in"}
|
||||
</button>
|
||||
{registrationEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="text-primary hover:text-primary-hover font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoginMode ? "Create one" : "Sign in"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-content-muted dark:text-content-muted-dark">
|
||||
Registration is currently disabled. Please contact the administrator.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
320
spotizerr-ui/src/components/config/UserManagementTab.tsx
Normal file
320
spotizerr-ui/src/components/config/UserManagementTab.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { authApiClient } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import type { User, CreateUserRequest } from "@/types/auth";
|
||||
|
||||
export function UserManagementTab() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<CreateUserRequest>({
|
||||
username: "",
|
||||
password: "",
|
||||
email: "",
|
||||
role: "user"
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const userList = await authApiClient.listUsers();
|
||||
setUsers(userList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
toast.error("Failed to load users");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateCreateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!createForm.username.trim()) {
|
||||
newErrors.username = "Username is required";
|
||||
} else if (createForm.username.length < 3) {
|
||||
newErrors.username = "Username must be at least 3 characters";
|
||||
}
|
||||
|
||||
if (!createForm.password) {
|
||||
newErrors.password = "Password is required";
|
||||
} else if (createForm.password.length < 6) {
|
||||
newErrors.password = "Password must be at least 6 characters";
|
||||
}
|
||||
|
||||
if (createForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(createForm.email)) {
|
||||
newErrors.email = "Please enter a valid email address";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateCreateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await authApiClient.createUser({
|
||||
...createForm,
|
||||
email: createForm.email?.trim() || undefined,
|
||||
});
|
||||
|
||||
// Reset form and reload users
|
||||
setCreateForm({ username: "", password: "", email: "", role: "user" });
|
||||
setShowCreateForm(false);
|
||||
setErrors({});
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error("Failed to create user:", error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
if (username === currentUser?.username) {
|
||||
toast.error("Cannot delete your own account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authApiClient.deleteUser(username);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete user:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (username: string, newRole: "user" | "admin") => {
|
||||
if (username === currentUser?.username) {
|
||||
toast.error("Cannot change your own role");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authApiClient.updateUserRole(username, newRole);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error("Failed to update user role:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof CreateUserRequest, value: string) => {
|
||||
setCreateForm(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-content-primary dark:text-content-primary-dark">
|
||||
User Management
|
||||
</h3>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{showCreateForm ? "Cancel" : "Create User"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create User Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-surface-secondary dark:bg-surface-secondary-dark rounded-lg p-6 border border-border dark:border-border-dark">
|
||||
<h4 className="text-md font-medium text-content-primary dark:text-content-primary-dark mb-4">
|
||||
Create New User
|
||||
</h4>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.username}
|
||||
onChange={(e) => handleInputChange("username", e.target.value)}
|
||||
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
|
||||
errors.username
|
||||
? "border-error focus:border-error"
|
||||
: "border-input-border dark:border-input-border-dark focus:border-primary"
|
||||
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
|
||||
placeholder="Enter username"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="mt-1 text-sm text-error">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
|
||||
errors.email
|
||||
? "border-error focus:border-error"
|
||||
: "border-input-border dark:border-input-border-dark focus:border-primary"
|
||||
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
|
||||
placeholder="Enter email (optional)"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-error">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
|
||||
errors.password
|
||||
? "border-error focus:border-error"
|
||||
: "border-input-border dark:border-input-border-dark focus:border-primary"
|
||||
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
|
||||
placeholder="Enter password"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-error">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={createForm.role}
|
||||
onChange={(e) => handleInputChange("role", e.target.value as "user" | "admin")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-input-border dark:border-input-border-dark focus:border-primary bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create User"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users List */}
|
||||
<div className="bg-surface dark:bg-surface-dark rounded-lg border border-border dark:border-border-dark overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border dark:border-border-dark">
|
||||
<h4 className="text-md font-medium text-content-primary dark:text-content-primary-dark">
|
||||
Users ({users.length})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-content-secondary dark:text-content-secondary-dark">
|
||||
No users found
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border dark:divide-border-dark">
|
||||
{users.map((user) => (
|
||||
<div key={user.username} className="px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-content-primary dark:text-content-primary-dark">
|
||||
{user.username}
|
||||
{user.username === currentUser?.username && (
|
||||
<span className="ml-2 text-xs text-primary">(You)</span>
|
||||
)}
|
||||
</p>
|
||||
{user.email && (
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(user.username, e.target.value as "user" | "admin")}
|
||||
disabled={user.username === currentUser?.username}
|
||||
className="px-3 py-1 text-sm rounded-lg border border-input-border dark:border-input-border-dark bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark disabled:opacity-50"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.username)}
|
||||
disabled={user.username === currentUser?.username}
|
||||
className="px-3 py-1 text-sm text-error hover:bg-error-muted rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [authEnabled, setAuthEnabled] = useState(false);
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Guard to prevent multiple simultaneous initializations
|
||||
@@ -49,6 +50,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
if (tokenValidation.isValid && tokenValidation.userData) {
|
||||
// Token is valid and we have user data
|
||||
setAuthEnabled(tokenValidation.userData.auth_enabled);
|
||||
setRegistrationEnabled(tokenValidation.userData.registration_enabled);
|
||||
if (tokenValidation.userData.authenticated && tokenValidation.userData.user) {
|
||||
setUser(tokenValidation.userData.user);
|
||||
console.log("Session restored for user:", tokenValidation.userData.user.username);
|
||||
@@ -68,6 +70,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
console.log("Checking auth status...");
|
||||
const status = await authApiClient.checkAuthStatus();
|
||||
setAuthEnabled(status.auth_enabled);
|
||||
setRegistrationEnabled(status.registration_enabled);
|
||||
|
||||
if (!status.auth_enabled) {
|
||||
console.log("Authentication is disabled");
|
||||
@@ -114,6 +117,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const status = await authApiClient.checkAuthStatus();
|
||||
|
||||
setAuthEnabled(status.auth_enabled);
|
||||
setRegistrationEnabled(status.registration_enabled);
|
||||
|
||||
if (status.auth_enabled && status.authenticated && status.user) {
|
||||
setUser(status.user);
|
||||
@@ -222,6 +226,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
authEnabled,
|
||||
registrationEnabled,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { CallbackObject } from "@/types/callbacks";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
const { isLoading, authEnabled, isAuthenticated } = useAuth();
|
||||
const [items, setItems] = useState<QueueItem[]>([]);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [totalTasks, setTotalTasks] = useState(0);
|
||||
@@ -355,7 +357,12 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("SSE connection error:", error);
|
||||
// Use appropriate logging level - first attempt failures are common and expected
|
||||
if (reconnectAttempts.current === 0) {
|
||||
console.log("SSE initial connection failed, will retry shortly...");
|
||||
} else {
|
||||
console.warn("SSE connection error:", error);
|
||||
}
|
||||
|
||||
// Check if this might be an auth error by testing if we still have a valid token
|
||||
const token = authApiClient.getToken();
|
||||
@@ -372,12 +379,22 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
if (reconnectAttempts.current < maxReconnectAttempts) {
|
||||
reconnectAttempts.current++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current - 1), 30000);
|
||||
// Use shorter delays for faster recovery, especially on first attempts
|
||||
const baseDelay = reconnectAttempts.current === 1 ? 100 : 1000;
|
||||
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current - 1), 15000);
|
||||
|
||||
console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current}/${maxReconnectAttempts})`);
|
||||
if (reconnectAttempts.current === 1) {
|
||||
console.log("SSE: Retrying connection shortly...");
|
||||
} else {
|
||||
console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current}/${maxReconnectAttempts})`);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
console.log("SSE: Attempting to reconnect...");
|
||||
if (reconnectAttempts.current === 1) {
|
||||
console.log("SSE: Attempting reconnection...");
|
||||
} else {
|
||||
console.log("SSE: Attempting to reconnect...");
|
||||
}
|
||||
connectSSE();
|
||||
}, delay);
|
||||
} else {
|
||||
@@ -387,8 +404,11 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to create SSE connection:", error);
|
||||
toast.error("Failed to establish connection");
|
||||
console.log("Initial SSE connection setup failed, will retry:", error);
|
||||
// Don't show toast for initial connection failures since they often recover quickly
|
||||
if (reconnectAttempts.current > 0) {
|
||||
toast.error("Failed to establish connection");
|
||||
}
|
||||
}
|
||||
}, [createQueueItemFromTask, scheduleRemoval, startHealthCheck]);
|
||||
|
||||
@@ -444,8 +464,20 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
// Note: SSE connection state is managed through the initialize effect and restartSSE method
|
||||
// The auth context should call restartSSE() when login/logout occurs
|
||||
|
||||
// Initialize queue on mount
|
||||
// Initialize queue on mount - but only after authentication is ready
|
||||
useEffect(() => {
|
||||
// Don't initialize if still loading auth state
|
||||
if (isLoading) {
|
||||
console.log("QueueProvider: Waiting for auth initialization...");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't initialize if auth is enabled but user is not authenticated
|
||||
if (authEnabled && !isAuthenticated) {
|
||||
console.log("QueueProvider: Auth required but user not authenticated, skipping initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
const initializeQueue = async () => {
|
||||
try {
|
||||
const response = await authApiClient.client.get(`/prgs/list?page=1&limit=${pageSize}`);
|
||||
@@ -469,13 +501,17 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
(total_tasks || 0);
|
||||
setTotalTasks(calculatedTotal);
|
||||
|
||||
connectSSE();
|
||||
// Add a small delay before connecting SSE to give server time to be ready
|
||||
setTimeout(() => {
|
||||
connectSSE();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize queue:", error);
|
||||
toast.error("Could not load queue");
|
||||
}
|
||||
};
|
||||
|
||||
console.log("QueueProvider: Auth ready, initializing queue...");
|
||||
initializeQueue();
|
||||
|
||||
return () => {
|
||||
@@ -484,7 +520,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
Object.values(removalTimers.current).forEach(clearTimeout);
|
||||
removalTimers.current = {};
|
||||
};
|
||||
}, [connectSSE, disconnectSSE, createQueueItemFromTask, stopHealthCheck]);
|
||||
}, [isLoading, authEnabled, isAuthenticated, connectSSE, disconnectSSE, createQueueItemFromTask, stopHealthCheck]);
|
||||
|
||||
// Queue actions
|
||||
const addItem = useCallback(async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
||||
import { authApiClient } from "../lib/api-client";
|
||||
import { SettingsContext, type AppSettings } from "./settings-context";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "./auth-context";
|
||||
|
||||
// --- Case Conversion Utility ---
|
||||
// This is added here to simplify the fix and avoid module resolution issues.
|
||||
@@ -127,19 +128,25 @@ const fetchSettings = async (): Promise<FlatAppSettings> => {
|
||||
};
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const { isLoading, authEnabled, isAuthenticated, user } = useAuth();
|
||||
|
||||
// Only fetch settings when auth is ready and user is admin (or auth is disabled)
|
||||
const shouldFetchSettings = !isLoading && (!authEnabled || (isAuthenticated && user?.role === "admin"));
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isLoading: isSettingsLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: shouldFetchSettings, // Only run query when auth is ready and user is admin
|
||||
});
|
||||
|
||||
// Use default settings on error to prevent app crash
|
||||
const value = { settings: isError ? defaultSettings : settings || null, isLoading };
|
||||
const value = { settings: isError ? defaultSettings : settings || null, isLoading: isSettingsLoading };
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
RegisterRequest,
|
||||
LoginResponse,
|
||||
AuthStatusResponse,
|
||||
User
|
||||
User,
|
||||
CreateUserRequest
|
||||
} from "@/types/auth";
|
||||
|
||||
class AuthApiClient {
|
||||
@@ -289,6 +290,16 @@ class AuthApiClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createUser(userData: CreateUserRequest): Promise<{ message: string }> {
|
||||
const response = await this.apiClient.post("/auth/users/create", userData);
|
||||
|
||||
toast.success("User Created", {
|
||||
description: `User ${userData.username} created successfully.`,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Expose the underlying axios instance for other API calls
|
||||
get client() {
|
||||
return this.apiClient;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { GeneralTab } from "../components/config/GeneralTab";
|
||||
import { DownloadsTab } from "../components/config/DownloadsTab";
|
||||
import { FormattingTab } from "../components/config/FormattingTab";
|
||||
import { AccountsTab } from "../components/config/AccountsTab";
|
||||
import { WatchTab } from "../components/config/WatchTab";
|
||||
import { ServerTab } from "../components/config/ServerTab";
|
||||
import { UserManagementTab } from "../components/config/UserManagementTab";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import { useAuth } from "../contexts/auth-context";
|
||||
import { LoginScreen } from "../components/auth/LoginScreen";
|
||||
@@ -16,6 +17,13 @@ const ConfigComponent = () => {
|
||||
// Get settings from the context instead of fetching here
|
||||
const { settings: config, isLoading } = useSettings();
|
||||
|
||||
// Reset to general tab if user is on user-management but auth is disabled
|
||||
useEffect(() => {
|
||||
if (!authEnabled && activeTab === "user-management") {
|
||||
setActiveTab("general");
|
||||
}
|
||||
}, [authEnabled, activeTab]);
|
||||
|
||||
// Show loading while authentication is being checked
|
||||
if (authLoading) {
|
||||
return (
|
||||
@@ -58,6 +66,11 @@ const ConfigComponent = () => {
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
// User management doesn't need config data
|
||||
if (activeTab === "user-management") {
|
||||
return <UserManagementTab />;
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="text-center py-12"><p className="text-content-muted dark:text-content-muted-dark">Loading configuration...</p></div>;
|
||||
if (!config) return <div className="text-center py-12"><p className="text-error-text bg-error-muted p-4 rounded-lg">Error loading configuration.</p></div>;
|
||||
|
||||
@@ -130,6 +143,14 @@ const ConfigComponent = () => {
|
||||
>
|
||||
Server
|
||||
</button>
|
||||
{authEnabled && (
|
||||
<button
|
||||
onClick={() => setActiveTab("user-management")}
|
||||
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "user-management" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
|
||||
>
|
||||
User Management
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ export interface AuthStatusResponse {
|
||||
auth_enabled: boolean;
|
||||
authenticated: boolean;
|
||||
user?: User;
|
||||
registration_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
role: "user" | "admin";
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
@@ -36,6 +44,7 @@ export interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
authEnabled: boolean;
|
||||
registrationEnabled: boolean;
|
||||
|
||||
// Actions
|
||||
login: (credentials: LoginRequest, rememberMe?: boolean) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user