feat: add staff user creation functionality and enhance role badge display

This commit is contained in:
Thibault Pouch
2026-03-02 09:50:38 +01:00
parent 3e0eeafac3
commit eb2a2b7d6e

View File

@@ -1,8 +1,35 @@
import { useState, useMemo, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getToken } from '../../contexts/AuthContext';
import { formatDate } from '../../utils/format';
import type { User, UserRole } from '../../types';
function RoleBadge({ role, isAdmin }: { role: UserRole; isAdmin: boolean }) {
if (isAdmin) {
return (
<span style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}>
<span className="badge badge-open">{role}</span>
<span
style={{
fontFamily: 'var(--font-mono)',
background: 'rgba(217,119,6,0.15)',
border: '1px solid rgba(217,119,6,0.4)',
color: 'var(--color-amber)',
fontSize: '0.6rem',
padding: '0.05rem 0.35rem',
letterSpacing: '0.08em',
borderRadius: '3px',
}}
>
ADMIN
</span>
</span>
);
}
const cls = role === 'dev' ? 'badge-open' : role === 'com' ? 'badge-medium' : 'badge-closed';
return <span className={`badge ${cls}`}>{role}</span>;
}
export default function IntranetUsers() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
@@ -10,6 +37,15 @@ export default function IntranetUsers() {
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'promote' | 'ban' | 'unban' } | null>(null);
// Create staff form
const [showCreateForm, setShowCreateForm] = useState(false);
const [createUsername, setCreateUsername] = useState('');
const [createEmail, setCreateEmail] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createRole, setCreateRole] = useState<'dev' | 'com'>('dev');
const [createError, setCreateError] = useState('');
const [creating, setCreating] = useState(false);
const filtered = useMemo(() => {
return users.filter((u) => {
const matchSearch = !search.trim() || u.username.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
@@ -28,13 +64,54 @@ export default function IntranetUsers() {
setConfirmAction(null);
}, []);
const handleCreateStaff = useCallback(async () => {
setCreateError('');
if (!createUsername.trim()) { setCreateError('Username is required.'); return; }
if (!createEmail.trim()) { setCreateError('Email is required.'); return; }
if (createPassword.length < 6) { setCreateError('Password must be at least 6 characters.'); return; }
setCreating(true);
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify({ username: createUsername.trim(), email: createEmail.trim(), password: createPassword, role: createRole }),
});
const data = await res.json();
if (!res.ok) {
setCreateError(data.error ?? 'Failed to create user.');
return;
}
setUsers((prev) => [...prev, data as User]);
setShowCreateForm(false);
setCreateUsername('');
setCreateEmail('');
setCreatePassword('');
setCreateRole('dev');
} catch {
setCreateError('Cannot reach the server.');
} finally {
setCreating(false);
}
}, [createUsername, createEmail, createPassword, createRole]);
return (
<div>
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / USER MANAGEMENT
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>USERS</h1>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', margin: 0 }}>USERS</h1>
{currentUser?.isAdmin && (
<button className="btn-terminal btn-amber" onClick={() => { setShowCreateForm(true); setCreateError(''); }}>
+ Create Staff User
</button>
)}
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
@@ -55,6 +132,87 @@ export default function IntranetUsers() {
</div>
</div>
{/* Create staff user modal */}
{showCreateForm && (
<div
style={{ background: 'rgba(0,0,0,0.4)', position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setShowCreateForm(false)}
>
<div
style={{ background: 'var(--color-surface)', border: '2px solid var(--color-amber)', padding: '2rem', maxWidth: '420px', width: '90%', borderRadius: '8px' }}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-amber)', marginBottom: '1.5rem', fontSize: '1.1rem', letterSpacing: '0.08em' }}>
CREATE STAFF USER
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>USERNAME</label>
<input
className="input-terminal"
type="text"
placeholder="username"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
style={{ fontSize: '0.85rem' }}
autoFocus
/>
</div>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>EMAIL</label>
<input
className="input-terminal"
type="email"
placeholder="staff@crowmate.dev"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
style={{ fontSize: '0.85rem' }}
/>
</div>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>PASSWORD</label>
<input
className="input-terminal"
type="password"
placeholder="Min. 6 characters"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
style={{ fontSize: '0.85rem' }}
/>
</div>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>ROLE</label>
<select
className="input-terminal"
value={createRole}
onChange={(e) => setCreateRole(e.target.value as 'dev' | 'com')}
style={{ fontSize: '0.85rem' }}
>
<option value="dev">Dev full staff access</option>
<option value="com">Com community staff</option>
</select>
</div>
</div>
{createError && (
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginTop: '0.85rem', padding: '0.5rem 0.75rem', background: 'rgba(220,38,38,0.06)', border: '1px solid rgba(220,38,38,0.2)', borderRadius: '4px' }}>
{createError}
</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem' }}>
<button className="btn-terminal btn-amber" onClick={handleCreateStaff} disabled={creating} style={{ opacity: creating ? 0.6 : 1 }}>
{creating ? 'Creating...' : '> Create'}
</button>
<button className="btn-terminal" onClick={() => setShowCreateForm(false)} disabled={creating}>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Confirm dialog */}
{confirmAction && (
<div style={{ background: 'rgba(0,0,0,0.3)', position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
@@ -108,13 +266,10 @@ export default function IntranetUsers() {
>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text)', fontWeight: isSelf ? 'bold' : 'normal' }}>
{u.username} {isSelf && <span style={{ color: 'var(--color-amber)', fontSize: '0.65rem' }}>(you)</span>}
{u.isAdmin && <span style={{ color: 'var(--color-green)', fontSize: '0.62rem', marginLeft: '0.3rem' }}>[admin]</span>}
</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)' }}>{u.email}</td>
<td style={{ padding: '0.7rem 0.75rem' }}>
<span className={`badge ${u.role === 'dev' ? 'badge-open' : u.role === 'com' ? 'badge-medium' : 'badge-closed'}`}>
{u.role}
</span>
<RoleBadge role={u.role} isAdmin={u.isAdmin} />
</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>{formatDate(u.createdAt)}</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>0</td>