Implement Issue #1: User Accounts & Profiles
- Admin-only account creation (no self-registration); invite-token flow
replaces the public /auth/register endpoint
- New volunteer fields: phone, is_trainee, operational_roles,
notification_preference, admin_notes, last_login, completed_shifts
- Role-scoped profile editing: volunteers update name/phone only;
admins update all fields including notes and trainee flag
- /auth/activate endpoint for invite-token-based account activation
- /api/v1/volunteers/{id}/invite for admin to resend invite links
- last_login recorded on each successful authentication
Tests:
- Go: handler tests (auth rules, create, activate, update scoping) via
Storer/AuthServicer interfaces and fake store; auth unit tests for
HashPassword, IssueToken, and Parse
- Frontend: RTL tests for Activate, Profile, and Volunteers pages
- Fixed CRA 5 + React Router v7 Jest compatibility (moduleNameMapper +
TextEncoder polyfill)
- Replaced stale CRA App.test.tsx placeholder with real tests
CI:
- .gitea/workflows/ci.yml runs go vet, go test, tsc, and npm test on
every push and pull request
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,172 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, Volunteer } from '../api';
|
||||
import { api, AdminVolunteer, CreateVolunteerInput, OPERATIONAL_ROLES } from '../api';
|
||||
|
||||
const EMPTY_FORM: CreateVolunteerInput = {
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'volunteer',
|
||||
is_trainee: false,
|
||||
phone: '',
|
||||
operational_roles: '',
|
||||
};
|
||||
|
||||
export default function Volunteers() {
|
||||
const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
|
||||
const [volunteers, setVolunteers] = useState<AdminVolunteer[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState<CreateVolunteerInput>(EMPTY_FORM);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [inviteLink, setInviteLink] = useState<{ name: string; token: string } | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [editNotes, setEditNotes] = useState<{ id: number; notes: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listVolunteers().then(setVolunteers).catch(() => setError('Could not load volunteers.'));
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function handleToggleActive(v: Volunteer) {
|
||||
function load() {
|
||||
api.listVolunteers()
|
||||
.then(vs => setVolunteers(vs as AdminVolunteer[]))
|
||||
.catch(() => setError('Could not load volunteers.'));
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
const av = await api.createVolunteer(form);
|
||||
setVolunteers(prev => [...prev, av]);
|
||||
setShowCreate(false);
|
||||
setForm(EMPTY_FORM);
|
||||
if (av.invite_token) {
|
||||
setInviteLink({ name: av.name, token: av.invite_token });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(v: AdminVolunteer) {
|
||||
try {
|
||||
const updated = await api.updateVolunteer(v.id, { active: !v.active });
|
||||
setVolunteers(prev => prev.map(vol => vol.id === v.id ? updated : vol));
|
||||
setVolunteers(prev => prev.map(vol => vol.id === v.id ? { ...vol, ...updated } : vol));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePromoteTrainee(v: AdminVolunteer) {
|
||||
try {
|
||||
const updated = await api.updateVolunteer(v.id, { is_trainee: false });
|
||||
setVolunteers(prev => prev.map(vol => vol.id === v.id ? { ...vol, ...updated } : vol));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveNotes(id: number, notes: string) {
|
||||
try {
|
||||
await api.updateVolunteer(id, { admin_notes: notes });
|
||||
setVolunteers(prev => prev.map(v => v.id === id ? { ...v, admin_notes: notes } : v));
|
||||
setEditNotes(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendInvite(v: AdminVolunteer) {
|
||||
try {
|
||||
const { invite_token } = await api.resendInvite(v.id);
|
||||
setInviteLink({ name: v.name, token: invite_token });
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpsRole(role: string) {
|
||||
const current = form.operational_roles ? form.operational_roles.split(',').filter(Boolean) : [];
|
||||
const next = current.includes(role)
|
||||
? current.filter(r => r !== role)
|
||||
: [...current, role];
|
||||
setForm(f => ({ ...f, operational_roles: next.join(',') }));
|
||||
}
|
||||
|
||||
const inviteUrl = inviteLink
|
||||
? `${window.location.origin}/activate?token=${inviteLink.token}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Volunteers</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2>Volunteers</h2>
|
||||
<button onClick={() => setShowCreate(s => !s)}>
|
||||
{showCreate ? 'Cancel' : '+ Add Volunteer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{inviteLink && (
|
||||
<div className="notice">
|
||||
<strong>Invite link for {inviteLink.name}:</strong>
|
||||
<br />
|
||||
<code style={{ wordBreak: 'break-all' }}>{inviteUrl}</code>
|
||||
<br />
|
||||
<button className="btn-small" onClick={() => { navigator.clipboard.writeText(inviteUrl); }}>
|
||||
Copy
|
||||
</button>
|
||||
<button className="btn-small" style={{ marginLeft: 8 }} onClick={() => setInviteLink(null)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<form className="card" onSubmit={handleCreate}>
|
||||
<h3>New Volunteer</h3>
|
||||
<label>
|
||||
Name *
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
|
||||
</label>
|
||||
<label>
|
||||
Email *
|
||||
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} required />
|
||||
</label>
|
||||
<label>
|
||||
Phone
|
||||
<input value={form.phone ?? ''} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
Account type
|
||||
<select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value as 'admin' | 'volunteer' }))}>
|
||||
<option value="volunteer">Volunteer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={form.is_trainee ?? false}
|
||||
onChange={e => setForm(f => ({ ...f, is_trainee: e.target.checked }))} />
|
||||
{' '}Trainee (blocks open shift claiming)
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Operational roles</legend>
|
||||
{OPERATIONAL_ROLES.map(r => (
|
||||
<label key={r} style={{ display: 'block' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(form.operational_roles ?? '').split(',').includes(r)}
|
||||
onChange={() => toggleOpsRole(r)}
|
||||
/>
|
||||
{' '}{r}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<button type="submit" disabled={creating}>{creating ? 'Creating…' : 'Create & Send Invite'}</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{volunteers.length === 0 ? (
|
||||
<p>No volunteers found.</p>
|
||||
) : (
|
||||
@@ -31,23 +176,74 @@ export default function Volunteers() {
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Op. Roles</th>
|
||||
<th>Shifts</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{volunteers.map(v => (
|
||||
<tr key={v.id}>
|
||||
<td>{v.name}</td>
|
||||
<td>{v.email}</td>
|
||||
<td>{v.role}</td>
|
||||
<td>{v.active ? 'Active' : 'Inactive'}</td>
|
||||
<td>
|
||||
<button className="btn-small" onClick={() => handleToggleActive(v)}>
|
||||
{v.active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<React.Fragment key={v.id}>
|
||||
<tr>
|
||||
<td>
|
||||
{v.active ? v.name : `${v.name} (inactive)`}
|
||||
{v.is_trainee && <span className="badge">Trainee</span>}
|
||||
</td>
|
||||
<td>{v.email}</td>
|
||||
<td>{v.role}</td>
|
||||
<td>{v.operational_roles || '—'}</td>
|
||||
<td>{v.completed_shifts}</td>
|
||||
<td>{v.active ? 'Active' : 'Inactive'}</td>
|
||||
<td>
|
||||
<button className="btn-small" onClick={() => handleToggleActive(v)}>
|
||||
{v.active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
{v.is_trainee && (
|
||||
<button className="btn-small" style={{ marginLeft: 4 }} onClick={() => handlePromoteTrainee(v)}>
|
||||
Promote ({v.completed_shifts} shifts)
|
||||
</button>
|
||||
)}
|
||||
{v.invite_token && (
|
||||
<button className="btn-small" style={{ marginLeft: 4 }} onClick={() => handleResendInvite(v)}>
|
||||
Resend Invite
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-small" style={{ marginLeft: 4 }}
|
||||
onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}>
|
||||
{expandedId === v.id ? 'Hide Notes' : 'Notes'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === v.id && (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
{editNotes?.id === v.id ? (
|
||||
<div>
|
||||
<textarea
|
||||
rows={3}
|
||||
style={{ width: '100%' }}
|
||||
value={editNotes.notes}
|
||||
onChange={e => setEditNotes({ id: v.id, notes: e.target.value })}
|
||||
/>
|
||||
<button className="btn-small" onClick={() => handleSaveNotes(v.id, editNotes.notes)}>Save</button>
|
||||
<button className="btn-small" style={{ marginLeft: 4 }} onClick={() => setEditNotes(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<em style={{ color: '#666' }}>Admin notes:</em>{' '}
|
||||
{v.admin_notes || <span style={{ color: '#999' }}>None</span>}
|
||||
<button className="btn-small" style={{ marginLeft: 8 }}
|
||||
onClick={() => setEditNotes({ id: v.id, notes: v.admin_notes ?? '' })}>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{v.last_login && <div style={{ marginTop: 4, color: '#666', fontSize: '0.85em' }}>Last login: {v.last_login}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user