feat: implement admin user management page with user listing, creation, deletion, and password reset functionality
This commit is contained in:
@@ -328,6 +328,46 @@ pub async fn delete_user(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to reset a user's password.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetPasswordRequest {
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/users/:id/reset-password - Reset a user's password (admin only).
|
||||||
|
pub async fn reset_password(
|
||||||
|
State(db): State<DatabaseConnection>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Json(req): Json<ResetPasswordRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
// Find the user
|
||||||
|
let existing = user::Entity::find_by_id(id)
|
||||||
|
.one(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
let password_hash = crate::auth::hash_password(&req.new_password)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Password hashing failed: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let mut active: user::ActiveModel = existing.into();
|
||||||
|
active.password_hash = Set(password_hash);
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
|
||||||
|
active
|
||||||
|
.update(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/roles - List all roles.
|
/// GET /api/roles - List all roles.
|
||||||
pub async fn list_roles(
|
pub async fn list_roles(
|
||||||
State(db): State<DatabaseConnection>,
|
State(db): State<DatabaseConnection>,
|
||||||
@@ -355,3 +395,4 @@ pub struct RoleResponse {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router {
|
|||||||
.route("/api/users/{id}", get(handlers::users::get_user)
|
.route("/api/users/{id}", get(handlers::users::get_user)
|
||||||
.put(handlers::users::update_user)
|
.put(handlers::users::update_user)
|
||||||
.delete(handlers::users::delete_user))
|
.delete(handlers::users::delete_user))
|
||||||
|
.route("/api/users/{id}/reset-password", post(handlers::users::reset_password))
|
||||||
// Entries API
|
// Entries API
|
||||||
.route("/api/entries", post(handlers::entries::create_entry))
|
.route("/api/entries", post(handlers::entries::create_entry))
|
||||||
.route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries))
|
.route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LoginPage } from './pages/Login'
|
|||||||
import { SignupPage } from './pages/Signup'
|
import { SignupPage } from './pages/Signup'
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
import { ProfilePage } from './pages/Profile'
|
import { ProfilePage } from './pages/Profile'
|
||||||
|
import { AdminPage } from './pages/Admin'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -11,6 +12,7 @@ function App() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -323,3 +323,67 @@ select.input {
|
|||||||
background-position: right 12px center;
|
background-position: right 12px center;
|
||||||
padding-right: 36px;
|
padding-right: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--indicator-critical);
|
||||||
|
border: 1px solid var(--indicator-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--indicator-critical) 10%, transparent);
|
||||||
|
}
|
||||||
432
frontend/src/pages/Admin.tsx
Normal file
432
frontend/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
name: string | null
|
||||||
|
role: string
|
||||||
|
height_cm: number | null
|
||||||
|
blood_type: string | null
|
||||||
|
birthdate: string | null
|
||||||
|
smoking: boolean | null
|
||||||
|
alcohol: boolean | null
|
||||||
|
diet: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewUser {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
role_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin username from config (cannot be deleted or have password reset)
|
||||||
|
const CONFIG_ADMIN_USERNAME = 'admin'
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [currentUser, setCurrentUser] = useState<{ role: string } | null>(null)
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
|
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
// Create user form
|
||||||
|
const [newUser, setNewUser] = useState<NewUser>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role_name: 'user',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if admin
|
||||||
|
fetch('/api/auth/me', { credentials: 'include' })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.user) {
|
||||||
|
navigate('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.user.role !== 'admin') {
|
||||||
|
navigate('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentUser(data.user)
|
||||||
|
loadUsers()
|
||||||
|
})
|
||||||
|
.catch(() => navigate('/login'))
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUsers(data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to load users' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(newUser),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'User created successfully' })
|
||||||
|
setShowCreateModal(false)
|
||||||
|
setNewUser({ username: '', password: '', role_name: 'user' })
|
||||||
|
loadUsers()
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to create user' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Network error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteUser = async (id: number, username: string) => {
|
||||||
|
if (!confirm(`Delete user "${username}"?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'User deleted' })
|
||||||
|
loadUsers()
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to delete user' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Network error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is the config admin (cannot delete or reset password)
|
||||||
|
const isConfigAdmin = (user: User) => user.username === CONFIG_ADMIN_USERNAME
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser || currentUser.role !== 'admin') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 'var(--space-lg)', maxWidth: '1000px', margin: '0 auto' }}>
|
||||||
|
<header style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
|
<Link to="/" className="text-secondary text-sm">← Back to Dashboard</Link>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'var(--space-sm)' }}>
|
||||||
|
<h1>User Management</h1>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowCreateModal(true)}>
|
||||||
|
+ New User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={message.type === 'success' ? 'success-message' : 'error-message'}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="card">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(user => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>{user.id}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{user.name || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`indicator indicator-${user.role === 'admin' ? 'warning' : 'info'}`}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-secondary text-sm">{new Date(user.created_at).toLocaleDateString()}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setEditingUser(user)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setResetPasswordUser(user)}
|
||||||
|
disabled={isConfigAdmin(user)}
|
||||||
|
title={isConfigAdmin(user) ? 'Admin password is set in config' : 'Reset password'}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => handleDeleteUser(user.id, user.username)}
|
||||||
|
disabled={isConfigAdmin(user)}
|
||||||
|
title={isConfigAdmin(user) ? 'Cannot delete config admin' : 'Delete user'}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
|
||||||
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2>Create New User</h2>
|
||||||
|
<form onSubmit={handleCreateUser}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={newUser.username}
|
||||||
|
onChange={e => setNewUser({ ...newUser, username: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="role">Role</label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
className="input"
|
||||||
|
value={newUser.role_name}
|
||||||
|
onChange={e => setNewUser({ ...newUser, role_name: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
|
||||||
|
<button type="submit" className="btn btn-primary">Create</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateModal(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
{editingUser && (
|
||||||
|
<EditUserModal
|
||||||
|
user={editingUser}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onSave={() => { setEditingUser(null); loadUsers(); }}
|
||||||
|
setMessage={setMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
{resetPasswordUser && (
|
||||||
|
<ResetPasswordModal
|
||||||
|
user={resetPasswordUser}
|
||||||
|
onClose={() => setResetPasswordUser(null)}
|
||||||
|
onSave={() => { setResetPasswordUser(null); }}
|
||||||
|
setMessage={setMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditUserModalProps {
|
||||||
|
user: User
|
||||||
|
onClose: () => void
|
||||||
|
onSave: () => void
|
||||||
|
setMessage: (msg: { type: 'success' | 'error'; text: string } | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditUserModal({ user, onClose, onSave, setMessage }: EditUserModalProps) {
|
||||||
|
const [name, setName] = useState(user.name || '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${user.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name: name || null }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'User updated' })
|
||||||
|
onSave()
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to update user' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Network error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2>Edit User: {user.username}</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="edit-name">Display Name</label>
|
||||||
|
<input
|
||||||
|
id="edit-name"
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Full name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResetPasswordModalProps {
|
||||||
|
user: User
|
||||||
|
onClose: () => void
|
||||||
|
onSave: () => void
|
||||||
|
setMessage: (msg: { type: 'success' | 'error'; text: string } | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordModal({ user, onClose, onSave, setMessage }: ResetPasswordModalProps) {
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (newPassword.length < 4) {
|
||||||
|
setError('Password must be at least 4 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${user.id}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ new_password: newPassword }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: `Password reset for ${user.username}` })
|
||||||
|
onSave()
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to reset password' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Network error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2>Reset Password: {user.username}</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="new-password">New Password</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="confirm-password">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -93,6 +93,18 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Admin</h3>
|
||||||
|
<p className="text-secondary text-sm" style={{ marginBottom: 'var(--space-md)' }}>
|
||||||
|
Manage users and system settings
|
||||||
|
</p>
|
||||||
|
<Link to="/admin" className="btn btn-secondary">
|
||||||
|
Manage Users
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-secondary text-sm">
|
<p className="text-secondary text-sm">
|
||||||
Dashboard coming soon...
|
Dashboard coming soon...
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user