Add user authentication system with API key management (#50)
- Add User, Session, AuthSettings models with bcrypt password hashing - Add auth endpoints: login, logout, change-password, me - Add API key CRUD: create (orch_xxx format), list, revoke - Add admin user management: list, create, update, reset-password - Create default admin user on startup (admin/admin) - Add frontend: Login page, API Keys page, Admin Users page - Add AuthContext for session state management - Add user menu to Layout header with login/logout/settings - Add 15 integration tests for auth system - Add migration 006_auth_tables.sql
This commit is contained in:
529
frontend/src/pages/AdminUsersPage.tsx
Normal file
529
frontend/src/pages/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { listUsers, createUser, updateUser, resetUserPassword } from '../api';
|
||||
import { AdminUser } from '../types';
|
||||
import './AdminUsersPage.css';
|
||||
|
||||
function AdminUsersPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [createEmail, setCreateEmail] = useState('');
|
||||
const [createIsAdmin, setCreateIsAdmin] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [resetPasswordUsername, setResetPasswordUsername] = useState<string | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const [togglingUser, setTogglingUser] = useState<string | null>(null);
|
||||
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/login', { state: { from: '/admin/users' } });
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.is_admin) {
|
||||
loadUsers();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (successMessage) {
|
||||
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [successMessage]);
|
||||
|
||||
async function loadUsers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listUsers();
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!createUsername.trim()) {
|
||||
setCreateError('Username is required');
|
||||
return;
|
||||
}
|
||||
if (!createPassword.trim()) {
|
||||
setCreateError('Password is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await createUser({
|
||||
username: createUsername.trim(),
|
||||
password: createPassword,
|
||||
email: createEmail.trim() || undefined,
|
||||
is_admin: createIsAdmin,
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateEmail('');
|
||||
setCreateIsAdmin(false);
|
||||
setSuccessMessage('User created successfully');
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAdmin(targetUser: AdminUser) {
|
||||
setTogglingUser(targetUser.username);
|
||||
try {
|
||||
await updateUser(targetUser.username, { is_admin: !targetUser.is_admin });
|
||||
setSuccessMessage(`${targetUser.username} is ${!targetUser.is_admin ? 'now' : 'no longer'} an admin`);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||||
} finally {
|
||||
setTogglingUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(targetUser: AdminUser) {
|
||||
setTogglingUser(targetUser.username);
|
||||
try {
|
||||
await updateUser(targetUser.username, { is_active: !targetUser.is_active });
|
||||
setSuccessMessage(`${targetUser.username} has been ${!targetUser.is_active ? 'enabled' : 'disabled'}`);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||||
} finally {
|
||||
setTogglingUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!resetPasswordUsername || !newPassword.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResetting(true);
|
||||
try {
|
||||
await resetUserPassword(resetPasswordUsername, newPassword);
|
||||
setResetPasswordUsername(null);
|
||||
setNewPassword('');
|
||||
setSuccessMessage(`Password reset for ${resetPasswordUsername}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reset password');
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Never';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<div className="admin-users-loading">
|
||||
<div className="admin-users-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.is_admin) {
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<div className="admin-users-access-denied">
|
||||
<div className="admin-users-access-denied-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Access Denied</h2>
|
||||
<p>You do not have permission to access this page. Admin privileges are required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<div className="admin-users-header">
|
||||
<div className="admin-users-header-content">
|
||||
<h1>User Management</h1>
|
||||
<p className="admin-users-subtitle">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="admin-users-create-button"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={showCreateForm}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="admin-users-success">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="admin-users-error">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="admin-users-error-dismiss">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && (
|
||||
<div className="admin-users-create-form-card">
|
||||
<div className="admin-users-create-form-header">
|
||||
<h2>Create New User</h2>
|
||||
<button
|
||||
className="admin-users-create-form-close"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateEmail('');
|
||||
setCreateIsAdmin(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div className="admin-users-create-error">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="admin-users-create-form">
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
autoFocus
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="email">Email (optional)</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={createEmail}
|
||||
onChange={(e) => setCreateEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-group admin-users-checkbox-group">
|
||||
<label className="admin-users-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createIsAdmin}
|
||||
onChange={(e) => setCreateIsAdmin(e.target.checked)}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<span className="admin-users-checkbox-custom"></span>
|
||||
Grant admin privileges
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-users-cancel-button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateEmail('');
|
||||
setCreateIsAdmin(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-users-submit-button"
|
||||
disabled={isCreating || !createUsername.trim() || !createPassword.trim()}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<span className="admin-users-button-spinner"></span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create User'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetPasswordUsername && (
|
||||
<div className="admin-users-reset-password-card">
|
||||
<div className="admin-users-reset-password-header">
|
||||
<h2>Reset Password</h2>
|
||||
<button
|
||||
className="admin-users-create-form-close"
|
||||
onClick={() => {
|
||||
setResetPasswordUsername(null);
|
||||
setNewPassword('');
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="admin-users-reset-password-info">
|
||||
Set a new password for <strong>{resetPasswordUsername}</strong>
|
||||
</p>
|
||||
<form onSubmit={handleResetPassword} className="admin-users-reset-password-form">
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
autoFocus
|
||||
disabled={isResetting}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-users-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-users-cancel-button"
|
||||
onClick={() => {
|
||||
setResetPasswordUsername(null);
|
||||
setNewPassword('');
|
||||
}}
|
||||
disabled={isResetting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-users-submit-button"
|
||||
disabled={isResetting || !newPassword.trim()}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<span className="admin-users-button-spinner"></span>
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-users-list-container">
|
||||
{loading ? (
|
||||
<div className="admin-users-list-loading">
|
||||
<div className="admin-users-spinner"></div>
|
||||
<span>Loading users...</span>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="admin-users-empty">
|
||||
<div className="admin-users-empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No Users</h3>
|
||||
<p>Create a user to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-users-list">
|
||||
<div className="admin-users-list-header">
|
||||
<span className="admin-users-col-user">User</span>
|
||||
<span className="admin-users-col-status">Status</span>
|
||||
<span className="admin-users-col-created">Created</span>
|
||||
<span className="admin-users-col-login">Last Login</span>
|
||||
<span className="admin-users-col-actions">Actions</span>
|
||||
</div>
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className={`admin-users-list-item ${!u.is_active ? 'admin-users-inactive' : ''}`}>
|
||||
<div className="admin-users-col-user">
|
||||
<div className="admin-users-item-avatar">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="admin-users-item-info">
|
||||
<div className="admin-users-item-username">
|
||||
{u.username}
|
||||
{u.is_admin && <span className="admin-users-admin-badge">Admin</span>}
|
||||
</div>
|
||||
{u.email && (
|
||||
<div className="admin-users-item-email">{u.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-users-col-status">
|
||||
<span className={`admin-users-status-badge ${u.is_active ? 'active' : 'inactive'}`}>
|
||||
{u.is_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-users-col-created">
|
||||
{formatDate(u.created_at)}
|
||||
</div>
|
||||
<div className="admin-users-col-login">
|
||||
{formatDate(u.last_login)}
|
||||
</div>
|
||||
<div className="admin-users-col-actions">
|
||||
<div className="admin-users-actions-menu">
|
||||
<button
|
||||
className="admin-users-action-button"
|
||||
onClick={() => handleToggleAdmin(u)}
|
||||
disabled={togglingUser === u.username || u.username === user.username}
|
||||
title={u.is_admin ? 'Remove admin' : 'Make admin'}
|
||||
>
|
||||
{togglingUser === u.username ? (
|
||||
<span className="admin-users-action-spinner"></span>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
)}
|
||||
{u.is_admin ? 'Revoke' : 'Admin'}
|
||||
</button>
|
||||
<button
|
||||
className="admin-users-action-button"
|
||||
onClick={() => handleToggleActive(u)}
|
||||
disabled={togglingUser === u.username || u.username === user.username}
|
||||
title={u.is_active ? 'Disable user' : 'Enable user'}
|
||||
>
|
||||
{togglingUser === u.username ? (
|
||||
<span className="admin-users-action-spinner"></span>
|
||||
) : u.is_active ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
)}
|
||||
{u.is_active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
className="admin-users-action-button"
|
||||
onClick={() => setResetPasswordUsername(u.username)}
|
||||
disabled={togglingUser === u.username}
|
||||
title="Reset password"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminUsersPage;
|
||||
Reference in New Issue
Block a user