492 lines
18 KiB
TypeScript
492 lines
18 KiB
TypeScript
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;
|
|
birthday?: string; // ISO date string
|
|
is_admin: boolean;
|
|
is_active: boolean;
|
|
}
|
|
|
|
interface UpdateProfileData {
|
|
email?: string;
|
|
full_name?: string;
|
|
discord_id?: string;
|
|
profile_picture?: string;
|
|
birthday?: string;
|
|
password?: string;
|
|
}
|
|
|
|
interface AdminUpdateData extends UpdateProfileData {
|
|
is_admin?: boolean;
|
|
is_active?: boolean;
|
|
}
|
|
|
|
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 [selectedUser, setSelectedUser] = useState<UserProfile | null>(null);
|
|
const [editFormData, setEditFormData] = useState<AdminUpdateData>({});
|
|
|
|
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 || '',
|
|
birthday: response.data.birthday || '',
|
|
});
|
|
} 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('');
|
|
|
|
if (formData.password && formData.password !== confirmPassword) {
|
|
setError('Passwords do not match');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const updateData: UpdateProfileData = {};
|
|
|
|
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.birthday !== profile?.birthday) updateData.birthday = formData.birthday;
|
|
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: AdminUpdateData) => {
|
|
try {
|
|
await api.put(`/api/v1/auth/users/${userId}`, updateData);
|
|
setSuccess('User updated successfully!');
|
|
setSelectedUser(null);
|
|
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 }));
|
|
};
|
|
|
|
const openEditModal = (u: UserProfile) => {
|
|
setSelectedUser(u);
|
|
setEditFormData({
|
|
email: u.email,
|
|
full_name: u.full_name,
|
|
discord_id: u.discord_id || '',
|
|
profile_picture: u.profile_picture || '',
|
|
birthday: u.birthday || '',
|
|
is_admin: u.is_admin,
|
|
is_active: u.is_active,
|
|
});
|
|
};
|
|
|
|
const handleEditFormChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value, type, checked } = e.target;
|
|
setEditFormData(prev => ({
|
|
...prev,
|
|
[name]: type === 'checkbox' ? checked : value
|
|
}));
|
|
};
|
|
|
|
const submitUserEdit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (selectedUser) {
|
|
await handleAdminUpdateUser(selectedUser.id, editFormData);
|
|
}
|
|
};
|
|
|
|
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="birthday" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Birthday <span className="text-gray-500">(for chore scheduling)</span>
|
|
</label>
|
|
<input
|
|
id="birthday"
|
|
name="birthday"
|
|
type="date"
|
|
value={formData.birthday || ''}
|
|
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"
|
|
/>
|
|
<p className="text-sm text-gray-500 mt-1">Get a break from chores on your special day!</p>
|
|
</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} {u.is_admin && <span className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded ml-2">Admin</span>}
|
|
</h3>
|
|
<p className="text-sm text-gray-500">@{u.username}</p>
|
|
<p className="text-sm text-gray-500">{u.email}</p>
|
|
{u.birthday && (
|
|
<p className="text-sm text-gray-500">🎂 {new Date(u.birthday).toLocaleDateString()}</p>
|
|
)}
|
|
{u.discord_id && (
|
|
<p className="text-sm text-gray-500">Discord: {u.discord_id}</p>
|
|
)}
|
|
{!u.is_active && (
|
|
<span className="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">Locked</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{u.id !== user.id && (
|
|
<>
|
|
<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>
|
|
<button
|
|
onClick={() => openEditModal(u)}
|
|
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>
|
|
)}
|
|
|
|
{/* Edit User Modal */}
|
|
{selectedUser && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
|
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
|
Edit User: {selectedUser.full_name}
|
|
</h2>
|
|
|
|
<form onSubmit={submitUserEdit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Full Name
|
|
</label>
|
|
<input
|
|
name="full_name"
|
|
type="text"
|
|
value={editFormData.full_name || ''}
|
|
onChange={handleEditFormChange}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Email
|
|
</label>
|
|
<input
|
|
name="email"
|
|
type="email"
|
|
value={editFormData.email || ''}
|
|
onChange={handleEditFormChange}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Birthday
|
|
</label>
|
|
<input
|
|
name="birthday"
|
|
type="date"
|
|
value={editFormData.birthday || ''}
|
|
onChange={handleEditFormChange}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Discord ID
|
|
</label>
|
|
<input
|
|
name="discord_id"
|
|
type="text"
|
|
value={editFormData.discord_id || ''}
|
|
onChange={handleEditFormChange}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Profile Picture URL
|
|
</label>
|
|
<input
|
|
name="profile_picture"
|
|
type="url"
|
|
value={editFormData.profile_picture || ''}
|
|
onChange={handleEditFormChange}
|
|
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 text-gray-900"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<label className="flex items-center">
|
|
<input
|
|
name="is_admin"
|
|
type="checkbox"
|
|
checked={editFormData.is_admin || false}
|
|
onChange={handleEditFormChange}
|
|
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
<span className="ml-2 text-sm text-gray-700">Admin</span>
|
|
</label>
|
|
|
|
<label className="flex items-center">
|
|
<input
|
|
name="is_active"
|
|
type="checkbox"
|
|
checked={editFormData.is_active || false}
|
|
onChange={handleEditFormChange}
|
|
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
<span className="ml-2 text-sm text-gray-700">Active</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-4">
|
|
<button
|
|
type="submit"
|
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Save Changes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedUser(null)}
|
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Settings;
|