feat: add staff user creation functionality and enhance role badge display
This commit is contained in:
@@ -1,8 +1,35 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { getToken } from '../../contexts/AuthContext';
|
||||||
import { formatDate } from '../../utils/format';
|
import { formatDate } from '../../utils/format';
|
||||||
import type { User, UserRole } from '../../types';
|
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() {
|
export default function IntranetUsers() {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
@@ -10,6 +37,15 @@ export default function IntranetUsers() {
|
|||||||
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
|
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
|
||||||
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'promote' | 'ban' | 'unban' } | null>(null);
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
return users.filter((u) => {
|
return users.filter((u) => {
|
||||||
const matchSearch = !search.trim() || u.username.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
|
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);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: '1.75rem' }}>
|
<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' }}>
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||||
INTRANET / USER MANAGEMENT
|
INTRANET / USER MANAGEMENT
|
||||||
</div>
|
</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 */}
|
{/* Filters */}
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
@@ -55,6 +132,87 @@ export default function IntranetUsers() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Confirm dialog */}
|
||||||
{confirmAction && (
|
{confirmAction && (
|
||||||
<div style={{ background: 'rgba(0,0,0,0.3)', position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
<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' }}>
|
<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.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>
|
||||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)' }}>{u.email}</td>
|
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)' }}>{u.email}</td>
|
||||||
<td style={{ padding: '0.7rem 0.75rem' }}>
|
<td style={{ padding: '0.7rem 0.75rem' }}>
|
||||||
<span className={`badge ${u.role === 'dev' ? 'badge-open' : u.role === 'com' ? 'badge-medium' : 'badge-closed'}`}>
|
<RoleBadge role={u.role} isAdmin={u.isAdmin} />
|
||||||
{u.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
</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-text-muted)', whiteSpace: 'nowrap' }}>{formatDate(u.createdAt)}</td>
|
||||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>0</td>
|
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>0</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user