Create Settings page with profile editing and admin user management
This commit is contained in:
309
frontend/src/pages/Settings.tsx
Normal file
309
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import api from '../api/axios';
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
discord_id?: string;
|
||||||
|
profile_picture?: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateProfileData {
|
||||||
|
email?: string;
|
||||||
|
full_name?: string;
|
||||||
|
discord_id?: string;
|
||||||
|
profile_picture?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [formData, setFormData] = useState<UpdateProfileData>({});
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [allUsers, setAllUsers] = useState<UserProfile[]>([]);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfile();
|
||||||
|
if (user?.is_admin) {
|
||||||
|
loadAllUsers();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<UserProfile>('/api/v1/auth/me');
|
||||||
|
setProfile(response.data);
|
||||||
|
setFormData({
|
||||||
|
email: response.data.email,
|
||||||
|
full_name: response.data.full_name,
|
||||||
|
discord_id: response.data.discord_id || '',
|
||||||
|
profile_picture: response.data.profile_picture || '',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load profile:', err);
|
||||||
|
setError('Failed to load profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<UserProfile[]>('/api/v1/users');
|
||||||
|
setAllUsers(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load users:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
// Validate passwords match if changing password
|
||||||
|
if (formData.password && formData.password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updateData: UpdateProfileData = {};
|
||||||
|
|
||||||
|
// Only include changed fields
|
||||||
|
if (formData.email !== profile?.email) updateData.email = formData.email;
|
||||||
|
if (formData.full_name !== profile?.full_name) updateData.full_name = formData.full_name;
|
||||||
|
if (formData.discord_id !== profile?.discord_id) updateData.discord_id = formData.discord_id;
|
||||||
|
if (formData.profile_picture !== profile?.profile_picture) updateData.profile_picture = formData.profile_picture;
|
||||||
|
if (formData.password) updateData.password = formData.password;
|
||||||
|
|
||||||
|
await api.put('/api/v1/auth/me', updateData);
|
||||||
|
setSuccess('Profile updated successfully!');
|
||||||
|
setFormData({ ...formData, password: '' });
|
||||||
|
setConfirmPassword('');
|
||||||
|
loadProfile();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to update profile');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminUpdateUser = async (userId: number, updateData: Partial<UpdateProfileData & { is_active: boolean; is_admin: boolean }>) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/api/v1/auth/users/${userId}`, updateData);
|
||||||
|
setSuccess('User updated successfully!');
|
||||||
|
loadAllUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to update user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return <div className="text-center py-8">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
|
||||||
|
|
||||||
|
{/* Personal Profile Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mb-4">My Profile</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-4">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profile.username}
|
||||||
|
disabled
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Username cannot be changed</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="full_name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="full_name"
|
||||||
|
name="full_name"
|
||||||
|
type="text"
|
||||||
|
value={formData.full_name || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="discord_id" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Discord ID <span className="text-gray-500">(for notifications)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="discord_id"
|
||||||
|
name="discord_id"
|
||||||
|
type="text"
|
||||||
|
value={formData.discord_id || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g., YourDiscordName#1234"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="profile_picture" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Profile Picture URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="profile_picture"
|
||||||
|
name="profile_picture"
|
||||||
|
type="url"
|
||||||
|
value={formData.profile_picture || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="https://example.com/avatar.jpg"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-4 mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Change Password</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Leave blank to keep current password"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Section */}
|
||||||
|
{user?.is_admin && (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||||
|
User Management <span className="text-sm font-normal text-gray-500">(Admin)</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{allUsers.map((u) => (
|
||||||
|
<div key={u.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{u.full_name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">@{u.username}</p>
|
||||||
|
<p className="text-sm text-gray-500">{u.email}</p>
|
||||||
|
{u.discord_id && (
|
||||||
|
<p className="text-sm text-gray-500">Discord: {u.discord_id}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdminUpdateUser(u.id, { is_active: !u.is_active })}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${
|
||||||
|
u.is_active
|
||||||
|
? 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200'
|
||||||
|
: 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.is_active ? 'Lock' : 'Unlock'}
|
||||||
|
</button>
|
||||||
|
{u.id !== user.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedUserId(u.id)}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-800 hover:bg-blue-200 rounded"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
Reference in New Issue
Block a user