Create Settings page with profile editing and admin user management

This commit is contained in:
2026-01-28 17:26:59 +11:00
parent aede5167d8
commit c9d98f8a32

View 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;