Implement Issue #1: User Accounts & Profiles
Some checks failed
CI / Go tests & lint (push) Successful in 7s
CI / Frontend tests & type-check (push) Failing after 9s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 9s

- 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:
2026-04-07 10:53:39 -03:00
parent c2b0a4fea2
commit 6c9746eb05
21 changed files with 1892 additions and 101 deletions

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import Activate from './Activate';
import { api } from '../api';
import { AuthProvider } from '../auth';
jest.mock('../api', () => ({
api: {
activate: jest.fn(),
},
}));
const mockActivate = api.activate as jest.Mock;
function renderActivate(token = 'valid-token') {
return render(
<AuthProvider>
<MemoryRouter initialEntries={[`/activate?token=${token}`]}>
<Routes>
<Route path="/activate" element={<Activate />} />
<Route path="/login" element={<div>Login Page</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>,
);
}
beforeEach(() => {
mockActivate.mockReset();
});
test('renders password fields', () => {
renderActivate();
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /activate account/i })).toBeInTheDocument();
});
test('shows error when no token in URL', () => {
render(
<AuthProvider>
<MemoryRouter initialEntries={['/activate']}>
<Routes>
<Route path="/activate" element={<Activate />} />
</Routes>
</MemoryRouter>
</AuthProvider>,
);
expect(screen.getByText(/no invite token/i)).toBeInTheDocument();
});
test('shows error when passwords do not match', async () => {
renderActivate();
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password1' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password2' } });
fireEvent.click(screen.getByRole('button', { name: /activate account/i }));
expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument();
});
test('shows error when password too short', async () => {
renderActivate();
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'short' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'short' } });
fireEvent.click(screen.getByRole('button', { name: /activate account/i }));
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
});
test('calls api.activate and navigates to login on success', async () => {
mockActivate.mockResolvedValueOnce({ id: 1, name: 'Alice' });
renderActivate('valid-token');
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } });
fireEvent.click(screen.getByRole('button', { name: /activate account/i }));
await waitFor(() => {
expect(mockActivate).toHaveBeenCalledWith('valid-token', 'goodpassword');
});
expect(await screen.findByText(/login page/i)).toBeInTheDocument();
});
test('shows error when api.activate fails', async () => {
mockActivate.mockRejectedValueOnce(new Error('invalid or expired invite token'));
renderActivate('bad-token');
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } });
fireEvent.click(screen.getByRole('button', { name: /activate account/i }));
expect(await screen.findByText(/invalid or expired invite token/i)).toBeInTheDocument();
});

View File

@@ -0,0 +1,75 @@
import React, { useState, FormEvent, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../auth';
export default function Activate() {
const [searchParams] = useSearchParams();
const { login } = useAuth();
const navigate = useNavigate();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const token = searchParams.get('token') ?? '';
useEffect(() => {
if (!token) setError('No invite token provided. Check your invite link.');
}, [token]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
if (password !== confirm) {
setError('Passwords do not match.');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
setSubmitting(true);
try {
await api.activate(token, password);
// After activation, prompt user to log in with their new password.
navigate('/login?activated=1');
} catch (err: any) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="auth-page">
<h1>Walkies</h1>
<h2>Set Your Password</h2>
<p>Welcome! Set a password to activate your account.</p>
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
/>
</label>
<button type="submit" disabled={submitting || !token}>
{submitting ? 'Activating…' : 'Activate Account'}
</button>
</form>
</div>
);
}

View File

@@ -1,14 +1,16 @@
import React, { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../auth';
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const activated = searchParams.get('activated') === '1';
async function handleSubmit(e: FormEvent) {
e.preventDefault();
@@ -27,6 +29,7 @@ export default function Login() {
<h1>Walkies</h1>
<h2>Sign In</h2>
<form onSubmit={handleSubmit}>
{activated && <p className="success">Account activated! Sign in with your new password.</p>}
{error && <p className="error">{error}</p>}
<label>
Email

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Profile from './Profile';
import { api, Volunteer } from '../api';
import { AuthProvider } from '../auth';
jest.mock('../api', () => ({
api: {
getVolunteer: jest.fn(),
updateVolunteer: jest.fn(),
},
}));
// Provide a pre-decoded token so AuthProvider exposes volunteerID = 5
const FAKE_TOKEN = buildFakeJWT({ volunteer_id: 5, role: 'volunteer', exp: 9999999999 });
function buildFakeJWT(payload: object): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.fakesig`;
}
const mockGetVolunteer = api.getVolunteer as jest.Mock;
const mockUpdateVolunteer = api.updateVolunteer as jest.Mock;
const baseVolunteer: Volunteer = {
id: 5,
name: 'Carol',
email: 'carol@example.com',
role: 'volunteer',
active: true,
is_trainee: false,
phone: '555-0100',
operational_roles: 'Floater',
notification_preference: 'email',
completed_shifts: 3,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
function renderProfile() {
localStorage.setItem('token', FAKE_TOKEN);
return render(
<AuthProvider>
<MemoryRouter>
<Profile />
</MemoryRouter>
</AuthProvider>,
);
}
beforeEach(() => {
mockGetVolunteer.mockReset();
mockUpdateVolunteer.mockReset();
localStorage.clear();
});
test('renders volunteer name and email from API', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
renderProfile();
expect(await screen.findByDisplayValue('Carol')).toBeInTheDocument();
expect(screen.getByDisplayValue('carol@example.com')).toBeInTheDocument();
});
test('email field is read-only', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
renderProfile();
await screen.findByDisplayValue('Carol');
expect(screen.getByDisplayValue('carol@example.com')).toBeDisabled();
});
test('submits updated name and phone', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
mockUpdateVolunteer.mockResolvedValueOnce({ ...baseVolunteer, name: 'Carol Updated' });
renderProfile();
const nameInput = await screen.findByDisplayValue('Carol');
fireEvent.change(nameInput, { target: { value: 'Carol Updated' } });
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => {
expect(mockUpdateVolunteer).toHaveBeenCalledWith(5, expect.objectContaining({
name: 'Carol Updated',
}));
});
expect(await screen.findByText(/profile updated/i)).toBeInTheDocument();
});
test('shows error on save failure', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
mockUpdateVolunteer.mockRejectedValueOnce(new Error('Server error'));
renderProfile();
await screen.findByDisplayValue('Carol');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
expect(await screen.findByText(/server error/i)).toBeInTheDocument();
});
test('shows completed shift count', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
renderProfile();
expect(await screen.findByText(/completed shifts: 3/i)).toBeInTheDocument();
});
test('shows trainee badge when is_trainee is true', async () => {
mockGetVolunteer.mockResolvedValueOnce({ ...baseVolunteer, is_trainee: true });
renderProfile();
expect(await screen.findByText(/trainee/i)).toBeInTheDocument();
});

77
web/src/pages/Profile.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React, { useEffect, useState, FormEvent } from 'react';
import { api, Volunteer } from '../api';
import { useAuth } from '../auth';
export default function Profile() {
const { volunteerID } = useAuth();
const [volunteer, setVolunteer] = useState<Volunteer | null>(null);
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!volunteerID) return;
api.getVolunteer(volunteerID).then(v => {
const vol = v as Volunteer;
setVolunteer(vol);
setName(vol.name);
setPhone(vol.phone ?? '');
}).catch(() => setError('Could not load profile.'));
}, [volunteerID]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
if (!volunteerID) return;
setSaving(true);
try {
const updated = await api.updateVolunteer(volunteerID, { name, phone: phone || undefined });
setVolunteer(updated);
setSuccess('Profile updated.');
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
if (!volunteer) return <div className="page"><p>Loading</p></div>;
return (
<div className="page">
<h2>My Profile</h2>
{error && <p className="error">{error}</p>}
{success && <p className="success">{success}</p>}
<form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
<label>
Full name
<input value={name} onChange={e => setName(e.target.value)} required />
</label>
<label>
Email
<input value={volunteer.email} disabled />
</label>
<label>
Phone
<input value={phone} onChange={e => setPhone(e.target.value)} placeholder="Optional" />
</label>
<label>
Operational roles
<input value={volunteer.operational_roles || '—'} disabled />
</label>
<label>
Notification preference
<input value={volunteer.notification_preference} disabled />
</label>
<p style={{ color: '#666', fontSize: '0.85em' }}>
Completed shifts: {volunteer.completed_shifts}
{volunteer.is_trainee && ' · Trainee'}
</p>
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Volunteers from './Volunteers';
import { api, AdminVolunteer } from '../api';
import { AuthProvider } from '../auth';
jest.mock('../api', () => ({
api: {
listVolunteers: jest.fn(),
createVolunteer: jest.fn(),
updateVolunteer: jest.fn(),
resendInvite: jest.fn(),
},
OPERATIONAL_ROLES: ['Behaviour Team', 'Dog Log Monitor', 'Dog Shelter Volunteer', 'Trainee', 'Floater'],
}));
const mockListVolunteers = api.listVolunteers as jest.Mock;
const mockCreateVolunteer = api.createVolunteer as jest.Mock;
const mockUpdateVolunteer = api.updateVolunteer as jest.Mock;
const mockResendInvite = api.resendInvite as jest.Mock;
function buildFakeJWT(payload: object): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.fakesig`;
}
const ADMIN_TOKEN = buildFakeJWT({ volunteer_id: 1, role: 'admin', exp: 9999999999 });
const alice: AdminVolunteer = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
role: 'volunteer',
active: true,
is_trainee: false,
operational_roles: 'Floater',
notification_preference: 'email',
completed_shifts: 5,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
const trainee: AdminVolunteer = {
...alice,
id: 2,
name: 'Bob',
email: 'bob@example.com',
is_trainee: true,
completed_shifts: 1,
};
function renderVolunteers() {
localStorage.setItem('token', ADMIN_TOKEN);
return render(
<AuthProvider>
<MemoryRouter>
<Volunteers />
</MemoryRouter>
</AuthProvider>,
);
}
beforeEach(() => {
mockListVolunteers.mockReset();
mockCreateVolunteer.mockReset();
mockUpdateVolunteer.mockReset();
mockResendInvite.mockReset();
localStorage.clear();
});
test('renders volunteer list', async () => {
mockListVolunteers.mockResolvedValueOnce([alice]);
renderVolunteers();
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
test('shows trainee badge for trainee volunteers', async () => {
mockListVolunteers.mockResolvedValueOnce([trainee]);
renderVolunteers();
await screen.findByText('Bob');
expect(screen.getByText('Trainee')).toBeInTheDocument();
});
test('shows promote button with shift count for trainees', async () => {
mockListVolunteers.mockResolvedValueOnce([trainee]);
renderVolunteers();
await screen.findByText('Bob');
expect(screen.getByRole('button', { name: /promote.*1 shift/i })).toBeInTheDocument();
});
test('shows add volunteer button', async () => {
mockListVolunteers.mockResolvedValueOnce([]);
renderVolunteers();
expect(await screen.findByRole('button', { name: /add volunteer/i })).toBeInTheDocument();
});
test('create form appears on Add Volunteer click', async () => {
mockListVolunteers.mockResolvedValueOnce([]);
renderVolunteers();
fireEvent.click(await screen.findByRole('button', { name: /add volunteer/i }));
expect(screen.getByRole('heading', { name: /new volunteer/i })).toBeInTheDocument();
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
test('creates volunteer and shows invite link', async () => {
mockListVolunteers.mockResolvedValueOnce([]);
const inviteToken = 'abc123invite';
mockCreateVolunteer.mockResolvedValueOnce({
...alice,
id: 10,
name: 'New Person',
email: 'new@example.com',
invite_token: inviteToken,
});
renderVolunteers();
fireEvent.click(await screen.findByRole('button', { name: /add volunteer/i }));
fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: 'New Person' } });
fireEvent.change(screen.getByLabelText(/^email/i), { target: { value: 'new@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /create.*invite/i }));
await waitFor(() => expect(mockCreateVolunteer).toHaveBeenCalled());
expect(await screen.findByText(new RegExp(inviteToken))).toBeInTheDocument();
});
test('deactivates volunteer on Deactivate click', async () => {
mockListVolunteers.mockResolvedValueOnce([alice]);
mockUpdateVolunteer.mockResolvedValueOnce({ ...alice, active: false });
renderVolunteers();
fireEvent.click(await screen.findByRole('button', { name: /deactivate/i }));
await waitFor(() =>
expect(mockUpdateVolunteer).toHaveBeenCalledWith(alice.id, { active: false }),
);
});
test('promotes trainee to volunteer', async () => {
mockListVolunteers.mockResolvedValueOnce([trainee]);
mockUpdateVolunteer.mockResolvedValueOnce({ ...trainee, is_trainee: false });
renderVolunteers();
await screen.findByText('Bob');
fireEvent.click(screen.getByRole('button', { name: /promote/i }));
await waitFor(() =>
expect(mockUpdateVolunteer).toHaveBeenCalledWith(trainee.id, { is_trainee: false }),
);
});
test('shows error when list fails', async () => {
mockListVolunteers.mockRejectedValueOnce(new Error('Network error'));
renderVolunteers();
expect(await screen.findByText(/could not load volunteers/i)).toBeInTheDocument();
});
test('resend invite shows new link', async () => {
const volWithToken: AdminVolunteer = { ...alice, invite_token: 'old-token' };
mockListVolunteers.mockResolvedValueOnce([volWithToken]);
mockResendInvite.mockResolvedValueOnce({ invite_token: 'new-fresh-token' });
renderVolunteers();
await screen.findByText('Alice');
fireEvent.click(screen.getByRole('button', { name: /resend invite/i }));
expect(await screen.findByText(/new-fresh-token/i)).toBeInTheDocument();
});

View File

@@ -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>