Compare commits

..

16 commits

38 changed files with 1493 additions and 1176 deletions

View file

@ -1,9 +1,9 @@
.PHONY: build frontend-build dev clean test patch minor major .PHONY: build frontend-build dev clean test patch minor major
LAST_TAG := $(shell git tag --sort=-v:refname | head -1) LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1) MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2) MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3) PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3)
build: frontend-build build: frontend-build
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike . CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
@ -25,13 +25,13 @@ clean:
rm -rf frontend/dist rm -rf frontend/dist
patch: patch:
git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
@echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
minor: minor:
git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0 git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
@echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0" @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
major: major:
git tag v$(shell echo $$(($(MAJOR)+1))).0.0 git tag $(shell echo $$(($(MAJOR)+1))).0.0
@echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0" @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"

40
auth.go
View file

@ -12,9 +12,9 @@ import (
) )
type Claims struct { type Claims struct {
UserID int `json:"uid"` ParticipantID int `json:"pid"`
Username string `json:"sub"` Email string `json:"sub"`
Role string `json:"role"` Roles []string `json:"roles"`
DeptIDs []int `json:"dept_ids,omitempty"` DeptIDs []int `json:"dept_ids,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
} }
func (app *App) signToken(u *User) (string, error) { func (app *App) signToken(s *User) (string, error) {
expiry := time.Duration(app.tokenExpiry) * time.Hour expiry := time.Duration(app.tokenExpiry) * time.Hour
claims := Claims{ claims := Claims{
UserID: u.ID, ParticipantID: s.ID,
Username: u.Username, Email: s.Email,
Role: u.Role, Roles: s.Roles,
DeptIDs: u.DepartmentIDs, DeptIDs: s.DepartmentIDs,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
@ -88,7 +88,7 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
writeError(w, "unauthorized", http.StatusUnauthorized) writeError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
if len(roles) > 0 && !hasRole(claims.Role, roles) { if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) {
writeError(w, "forbidden", http.StatusForbidden) writeError(w, "forbidden", http.StatusForbidden)
return return
} }
@ -97,9 +97,25 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
} }
} }
func hasRole(role string, allowed []string) bool { func hasAnyRole(roles []string, allowed []string) bool {
for _, r := range allowed { for _, r := range roles {
if r == role { for _, a := range allowed {
if r == a {
return true
}
}
}
return false
}
func isCoLeadOnly(claims *Claims) bool {
return hasAnyRole(claims.Roles, []string{"colead"}) &&
!hasAnyRole(claims.Roles, []string{"admin", "staffing"})
}
func inSlice(v int, s []int) bool {
for _, x := range s {
if x == v {
return true return true
} }
} }

View file

@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) {
mux := testMux(app) mux := testMux(app)
req := testRequest("POST", "/api/login", map[string]string{ req := testRequest("POST", "/api/login", map[string]string{
"username": admin.Username, "email": admin.Email,
"password": "admin123", "password": "admin123",
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) {
t.Error("missing token in response") t.Error("missing token in response")
} }
user, ok := result["user"].(map[string]any) user, ok := result["user"].(map[string]any)
if !ok || user["username"] != "admin" { if !ok || user["email"] != "oberon@athens.example" {
t.Errorf("user = %v", result["user"]) t.Errorf("user = %v", result["user"])
} }
} }
@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) {
mux := testMux(app) mux := testMux(app)
req := testRequest("POST", "/api/login", map[string]string{ req := testRequest("POST", "/api/login", map[string]string{
"username": "admin", "email": "oberon@athens.example",
"password": "wrong", "password": "wrong",
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) {
mux := testMux(app) mux := testMux(app)
req := testRequest("POST", "/api/login", map[string]string{ req := testRequest("POST", "/api/login", map[string]string{
"username": "nobody", "email": "nobody@test.com",
"password": "test", "password": "test",
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -94,8 +94,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
app := testApp(t) app := testApp(t)
mux := testMux(app) mux := testMux(app)
// Create a gate user — should not be able to access /api/users (admin only) gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{})
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
req := testAuthRequest("GET", "/api/users", nil, token) req := testAuthRequest("GET", "/api/users", nil, token)
@ -121,7 +120,7 @@ func TestMeEndpoint(t *testing.T) {
t.Fatalf("status = %d", w.Code) t.Fatalf("status = %d", w.Code)
} }
result := parseJSON(t, w) result := parseJSON(t, w)
if result["username"] != "admin" { if result["email"] != "oberon@athens.example" {
t.Errorf("username = %v", result["username"]) t.Errorf("email = %v", result["email"])
} }
} }

848
db.go

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ import (
func TestMigrate(t *testing.T) { func TestMigrate(t *testing.T) {
app := testApp(t) app := testApp(t)
// Verify tables exist by querying each one // Verify tables exist by querying each one
tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"}
for _, table := range tables { for _, table := range tables {
var count int var count int
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
@ -17,98 +17,6 @@ func TestMigrate(t *testing.T) {
} }
} }
func TestAttendeesCRUD(t *testing.T) {
app := testApp(t)
a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"})
if err != nil {
t.Fatal(err)
}
if a.ID == 0 || a.Name != "Titania" {
t.Errorf("create: got %+v", a)
}
got, err := app.getAttendee(a.ID)
if err != nil || got == nil {
t.Fatal("get: not found")
}
if got.Email != "titania@test.com" {
t.Errorf("get: email = %q", got.Email)
}
got.Name = "Titania Fairweather"
if err := app.updateAttendee(*got); err != nil {
t.Fatal(err)
}
got2, _ := app.getAttendee(a.ID)
if got2.Name != "Titania Fairweather" {
t.Errorf("update: name = %q", got2.Name)
}
if err := app.deleteAttendee(a.ID); err != nil {
t.Fatal(err)
}
// getAttendee returns soft-deleted records; listAttendees filters them
attendees, _ := app.listAttendees("", "", "")
for _, at := range attendees {
if at.ID == a.ID {
t.Error("delete: still visible in list")
}
}
}
func TestIncrementPartySize(t *testing.T) {
app := testApp(t)
app.createAttendee(Attendee{Name: "Oberon", TicketID: "ORD-100"})
merged, err := app.incrementPartySize("Oberon", "ORD-100")
if err != nil || !merged {
t.Fatalf("increment: merged=%v, err=%v", merged, err)
}
a, _ := app.getAttendee(1)
if a.PartySize != 2 {
t.Errorf("party_size = %d, want 2", a.PartySize)
}
// Different ticket_id should not merge
merged2, _ := app.incrementPartySize("Oberon", "ORD-200")
if merged2 {
t.Error("should not merge different ticket_id")
}
}
func TestCheckInAttendee(t *testing.T) {
app := testApp(t)
admin := testAdminUser(t, app)
app.createAttendee(Attendee{Name: "Puck"})
// Set party_size directly since createAttendee defaults to 1
app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`)
// Check in 1
a, err := app.checkInAttendee(1, admin.ID, 1)
if err != nil {
t.Fatal(err)
}
if a.CheckedInCount != 1 || !a.CheckedIn {
t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn)
}
// Check in 2 more (should cap at party_size=3)
a, _ = app.checkInAttendee(1, admin.ID, 5)
if a.CheckedInCount != 3 {
t.Errorf("after cap: count=%d, want 3", a.CheckedInCount)
}
// Check in again — already full, should stay at 3
a, _ = app.checkInAttendee(1, admin.ID, 1)
if a.CheckedInCount != 3 {
t.Errorf("after full: count=%d, want 3", a.CheckedInCount)
}
}
func TestGenerateToken(t *testing.T) { func TestGenerateToken(t *testing.T) {
token, err := generateToken() token, err := generateToken()
if err != nil { if err != nil {
@ -197,7 +105,8 @@ func TestAssignAndUnassignShift(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID deptID := dept.ID
s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID}) p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
if err := app.assignShift(v.ID, s.ID); err != nil { if err := app.assignShift(v.ID, s.ID); err != nil {
t.Fatal(err) t.Fatal(err)
@ -221,7 +130,8 @@ func TestCheckShiftConflict(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID deptID := dept.ID
v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID}) p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
@ -250,7 +160,8 @@ func TestCheckShiftConflictMidnight(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Sound"}) dept, _ := app.createDepartment(Department{Name: "Sound"})
deptID := dept.ID deptID := dept.ID
v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID}) p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
// Night shift: 22:00-02:00 (spans midnight) // Night shift: 22:00-02:00 (spans midnight)
night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"}) night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"})

View file

@ -1,6 +1,6 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { getSession, clearSession } from './db.js' import { getSession, saveSession, clearSession } from './db.js'
import { syncPull, startSSE, startSyncLoop } from './sync.js' import { syncPull, startSSE, startSyncLoop } from './sync.js'
import Login from './pages/Login.svelte' import Login from './pages/Login.svelte'
import Dashboard from './pages/Dashboard.svelte' import Dashboard from './pages/Dashboard.svelte'
@ -25,6 +25,7 @@
let route = $state(window.location.pathname) let route = $state(window.location.pathname)
let updateAvailable = $state(false) let updateAvailable = $state(false)
let mobileNavOpen = $state(false) let mobileNavOpen = $state(false)
let ssoError = $state('')
// Check if this is a public page (no auth needed) // Check if this is a public page (no auth needed)
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '') const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
@ -36,6 +37,7 @@
history.pushState(null, '', path) history.pushState(null, '', path)
route = path route = path
mobileNavOpen = false mobileNavOpen = false
window.scrollTo(0, 0)
} }
async function checkVersion() { async function checkVersion() {
@ -54,7 +56,32 @@
loading = false loading = false
return return
} }
// Handle SSO callback in URL fragment
const hash = window.location.hash
if (hash.startsWith('#sso_token=')) {
const token = decodeURIComponent(hash.slice('#sso_token='.length))
history.replaceState(null, '', '/')
try {
const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } })
if (res.ok) {
const user = await res.json()
await saveSession(token, user)
session = { token, user }
} else {
ssoError = 'SSO login failed. Please try again.'
}
} catch {
ssoError = 'SSO login failed. Please try again.'
}
} else if (hash.startsWith('#sso_error=')) {
ssoError = decodeURIComponent(hash.slice('#sso_error='.length))
history.replaceState(null, '', '/')
}
if (!session) {
session = await getSession() session = await getSession()
}
loading = false loading = false
if (session) { if (session) {
await syncPull() await syncPull()
@ -83,7 +110,7 @@
} }
const path = $derived(route || '/') const path = $derived(route || '/')
const role = $derived(session?.user?.role ?? '') const roles = $derived(session?.user?.roles ?? [])
</script> </script>
{#if updateAvailable} {#if updateAvailable}
@ -102,9 +129,9 @@
{:else if isConfirmEmail} {:else if isConfirmEmail}
<ConfirmEmail /> <ConfirmEmail />
{:else if !session} {:else if !session}
<Login onlogin={onLogin} /> <Login onlogin={onLogin} error={ssoError} />
{:else if role === 'gatekeeper'} {:else if roles.length === 1 && roles[0] === 'gatekeeper'}
<!-- Gate users get the full-screen GateKiosk instead of the standard layout --> <!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
<GateKiosk {session} {onLogout} /> <GateKiosk {session} {onLogout} />
{:else} {:else}
<div class="layout"> <div class="layout">
@ -121,10 +148,10 @@
<span class="mobile-brand">Turn<span class="accent">pike</span></span> <span class="mobile-brand">Turn<span class="accent">pike</span></span>
</header> </header>
{#if path === '/' || path === ''} {#if path === '/' || path === ''}
{#if role === 'colead'} {#if roles.length === 1 && roles[0] === 'colead'}
<ScheduleBoard {session} /> <ScheduleBoard {session} />
{:else} {:else}
<Dashboard {session} /> <Dashboard {session} {navigate} />
{/if} {/if}
{:else if path.startsWith('/participants')} {:else if path.startsWith('/participants')}
<Participants {session} /> <Participants {session} />

View file

@ -1,4 +1,4 @@
import { db } from './db.js' import { db, clearSession } from './db.js'
async function getToken() { async function getToken() {
const session = await db.session.get(1) const session = await db.session.get(1)
@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) {
const res = await fetch(path, { ...options, headers }) const res = await fetch(path, { ...options, headers })
if (res.status === 401) { if (res.status === 401) {
await db.session.clear() await clearSession()
window.location.pathname = '/login' window.location.pathname = '/login'
throw new Error('unauthorized') throw new Error('unauthorized')
} }
@ -48,8 +48,8 @@ async function kioskFetch(path, options = {}) {
} }
export const api = { export const api = {
login: (username, password) => login: (email, password) =>
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => apiFetch('/api/logout', { method: 'POST' }), logout: () => apiFetch('/api/logout', { method: 'POST' }),
me: () => apiJSON('/api/me'), me: () => apiJSON('/api/me'),
event: { event: {
@ -118,6 +118,10 @@ export const api = {
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }), resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }), resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
}, },
sso: {
enabled: () => kioskFetch('/api/public/sso-enabled'),
init: () => kioskFetch('/api/sso/init'),
},
signup: { signup: {
config: () => kioskFetch('/api/public/signup-config'), config: () => kioskFetch('/api/public/signup-config'),
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }), submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),

View file

@ -64,11 +64,11 @@ describe('apiJSON', () => {
describe('api methods', () => { describe('api methods', () => {
it('login calls correct endpoint', async () => { it('login calls correct endpoint', async () => {
const f = mockFetch({ token: 'tok', user: { id: 1 } }) const f = mockFetch({ token: 'tok', user: { id: 1 } })
await api.login('admin', 'pass') await api.login('admin@example.com', 'pass')
const [url, opts] = f.mock.calls[0] const [url, opts] = f.mock.calls[0]
expect(url).toBe('/api/login') expect(url).toBe('/api/login')
expect(opts.method).toBe('POST') expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' }) expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' })
}) })
it('participants.list calls correct endpoint', async () => { it('participants.list calls correct endpoint', async () => {

View file

@ -66,6 +66,9 @@ a:hover { color: var(--c-accent-h); }
/* Cards */ /* Cards */
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; } .card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; }
.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; }
.card-hint { font-size: 0.78rem; color: var(--c-muted); }
/* Stats */ /* Stats */
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } .stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
@ -103,8 +106,15 @@ input, select, textarea {
width: 100%; font-family: var(--font); width: 100%; font-family: var(--font);
transition: border-color var(--transition); transition: border-color var(--transition);
} }
input[type="checkbox"] { width: auto; }
input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
input::placeholder { color: var(--c-muted); } input::placeholder { color: var(--c-muted); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
.form-grid .full { grid-column: 1 / -1; }
.checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; }
.checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); }
/* Search */ /* Search */
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } .search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
@ -129,6 +139,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
font-size: 0.72rem; font-weight: 600; font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; text-transform: uppercase; letter-spacing: 0.04em;
} }
* + .badge { margin-left: 0.3rem; }
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } .badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
@ -234,6 +245,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
td { display: inline; padding: 0; border: none; } td { display: inline; padding: 0; border: none; }
td:empty { display: none; } td:empty { display: none; }
/* Forms */ /* Forms — 16px prevents iOS auto-zoom on focus */
.form-grid { grid-template-columns: 1fr !important; } input, select, textarea { font-size: 16px; }
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
} }

View file

@ -3,17 +3,18 @@
let { session, active, onLogout, navigate, open = false } = $props() let { session, active, onLogout, navigate, open = false } = $props()
const role = $derived(session?.user?.role ?? '') const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
const iconProps = { size: 18, strokeWidth: 1.75 } const iconProps = { size: 18, strokeWidth: 1.75 }
const links = $derived.by(() => { const links = $derived.by(() => {
if (role === 'colead') return [ if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [
{ href: '/', label: 'Schedule', icon: CalendarDays }, { href: '/', label: 'Schedule', icon: CalendarDays },
{ href: '/volunteers', label: 'Volunteers', icon: Heart }, { href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon }, { href: '/departments', label: 'Departments', icon: Hexagon },
] ]
if (role === 'staffing') return [ if (!hasRole('admin') && hasRole('staffing')) return [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard }, { href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/schedule', label: 'Schedule', icon: CalendarDays }, { href: '/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '/volunteers', label: 'Volunteers', icon: Heart }, { href: '/volunteers', label: 'Volunteers', icon: Heart },

View file

@ -46,6 +46,22 @@ db.version(4).stores({
volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at', volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at',
}) })
db.version(5).stores({
volunteers: 'id, participant_id, department_id, deleted_at',
participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at',
})
db.version(6).stores({}).upgrade(async tx => {
await tx.table('session').clear()
await tx.table('meta').clear()
await tx.table('participants').clear()
await tx.table('tickets').clear()
await tx.table('departments').clear()
await tx.table('volunteers').clear()
await tx.table('shifts').clear()
await tx.table('volunteer_shifts').clear()
})
export async function getLastSync() { export async function getLastSync() {
const m = await db.meta.get('last_sync') const m = await db.meta.get('last_sync')
return m?.value ?? '' return m?.value ?? ''
@ -64,6 +80,18 @@ export async function saveSession(token, user) {
} }
export async function clearSession() { export async function clearSession() {
await db.transaction('rw',
[db.session, db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
async () => {
await db.session.clear() await db.session.clear()
await db.meta.clear() await db.meta.clear()
await db.event.clear()
await db.participants.clear()
await db.tickets.clear()
await db.departments.clear()
await db.volunteers.clear()
await db.shifts.clear()
await db.volunteer_shifts.clear()
}
)
} }

View file

@ -22,10 +22,10 @@ describe('session', () => {
}) })
it('saves and retrieves session', async () => { it('saves and retrieves session', async () => {
await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' }) await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] })
const s = await getSession() const s = await getSession()
expect(s.token).toBe('tok123') expect(s.token).toBe('tok123')
expect(s.user.username).toBe('admin') expect(s.user.email).toBe('admin@example.com')
}) })
it('clears session and meta', async () => { it('clears session and meta', async () => {

View file

@ -2,13 +2,14 @@
import { liveQuery } from 'dexie' import { liveQuery } from 'dexie'
import { db } from '../db.js' import { db } from '../db.js'
let { session } = $props() let { session, navigate } = $props()
const role = $derived(session?.user?.role ?? '') const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
const myDeptIDs = $derived(session?.user?.department_ids ?? []) const myDeptIDs = $derived(session?.user?.department_ids ?? [])
const isTicketing = $derived(['admin', 'ticketing'].includes(role)) const isAdmin = $derived(hasRole('admin'))
const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role)) const isStaffing = $derived(hasRole('admin', 'staffing'))
const isColead = $derived(role === 'colead') const isColead = $derived(hasRole('colead'))
const event = liveQuery(() => db.event.get(1)) const event = liveQuery(() => db.event.get(1))
const allTickets = liveQuery(() => db.tickets.toArray()) const allTickets = liveQuery(() => db.tickets.toArray())
@ -76,8 +77,8 @@
</p> </p>
{/if} {/if}
<!-- Ticket check-in (admin/ticketing) --> <!-- Ticket check-in (admin) -->
{#if isTicketing} {#if isAdmin}
<h2 class="dash-section">Ticket Check-in</h2> <h2 class="dash-section">Ticket Check-in</h2>
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat">
@ -105,7 +106,7 @@
{/if} {/if}
{/if} {/if}
<!-- Volunteer stats (admin/ticketing/staffing/colead) --> <!-- Volunteer stats (admin/staffing/colead) -->
{#if isStaffing || isColead} {#if isStaffing || isColead}
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2> <h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
<div class="stats"> <div class="stats">
@ -124,7 +125,7 @@
</div> </div>
{/if} {/if}
<!-- Shift coverage (admin/ticketing/staffing/colead) --> <!-- Shift coverage (admin/staffing/colead) -->
{#if isStaffing || isColead} {#if isStaffing || isColead}
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2> <h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
<div class="stats"> <div class="stats">
@ -144,22 +145,22 @@
{/if} {/if}
<!-- Quick actions --> <!-- Quick actions -->
{#if isTicketing} {#if isAdmin}
<div class="dash-actions"> <div class="dash-actions">
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a> <a href="/import" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/import') }}>Import CSV</a>
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a> <a href="/participants" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/participants') }}>Manage Participants</a>
<a href="/settings" class="btn btn-ghost btn-sm">Settings</a> <a href="/settings" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/settings') }}>Settings</a>
</div> </div>
{:else if isStaffing || isColead} {:else if isStaffing || isColead}
<div class="dash-actions"> <div class="dash-actions">
<a href="/schedule" class="btn btn-ghost btn-sm">View Schedule</a> <a href="/schedule" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/schedule') }}>View Schedule</a>
<a href="/volunteers" class="btn btn-ghost btn-sm">Manage Volunteers</a> <a href="/volunteers" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/volunteers') }}>Manage Volunteers</a>
</div> </div>
{/if} {/if}
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem"> <p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong> Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong>
· <span class="badge badge-role">{session?.user?.role}</span> · {#each roles as r}<span class="badge badge-role">{r}</span>{/each}
</p> </p>
</div> </div>

View file

@ -18,9 +18,10 @@
let editDesc = $state('') let editDesc = $state('')
let saving = $state(false) let saving = $state(false)
const role = $derived(session?.user?.role ?? '') const roles = $derived(session?.user?.roles ?? [])
const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role)) function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
const canDelete = $derived(['admin', 'ticketing'].includes(role)) const canCreate = $derived(hasRole('admin', 'staffing'))
const canDelete = $derived(hasRole('admin'))
const allDepts = liveQuery(() => const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray() db.departments.filter(d => !d.deleted_at).toArray()
@ -100,7 +101,7 @@
{#if showAdd && canCreate} {#if showAdd && canCreate}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addDept}> <form onsubmit={addDept}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end"> <div class="form-grid-3">
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label for="d-name">Name *</label> <label for="d-name">Name *</label>
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" /> <input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
@ -111,7 +112,7 @@
</div> </div>
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label for="d-color">Color</label> <label for="d-color">Color</label>
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" /> <input id="d-color" type="color" bind:value={newColor} class="color-input" />
</div> </div>
</div> </div>
<div class="actions" style="margin-top:1rem"> <div class="actions" style="margin-top:1rem">
@ -190,6 +191,7 @@
</div> </div>
<style> <style>
.color-input { width: 60px; padding: 0.2rem; height: 2.3rem; cursor: pointer; }
@media (max-width: 640px) { @media (max-width: 640px) {
.td-name { width: 100%; } .td-name { width: 100%; }
.td-desc { width: 100%; } .td-desc { width: 100%; }

View file

@ -1,20 +1,32 @@
<script> <script>
import { onMount } from 'svelte'
import { api } from '../api.js' import { api } from '../api.js'
import { saveSession } from '../db.js' import { saveSession } from '../db.js'
let { onlogin } = $props() let { onlogin, error: externalError = '' } = $props()
let username = $state('') let email = $state('')
let password = $state('') let password = $state('')
let error = $state('') let error = $state('')
$effect(() => { if (externalError) error = externalError })
let loading = $state(false) let loading = $state(false)
let ssoEnabled = $state(false)
let ssoLoading = $state(false)
onMount(async () => {
try {
const res = await api.sso.enabled()
ssoEnabled = res.enabled
} catch {}
})
async function submit(e) { async function submit(e) {
e.preventDefault() e.preventDefault()
error = '' error = ''
loading = true loading = true
try { try {
const { token, user } = await api.login(username, password) const { token, user } = await api.login(email, password)
await saveSession(token, user) await saveSession(token, user)
onlogin({ token, user }) onlogin({ token, user })
} catch (err) { } catch (err) {
@ -23,6 +35,18 @@
loading = false loading = false
} }
} }
async function startSSO() {
error = ''
ssoLoading = true
try {
const { redirect_url } = await api.sso.init()
window.location.href = redirect_url
} catch (err) {
error = err.message || 'SSO failed'
ssoLoading = false
}
}
</script> </script>
<div class="login-wrap"> <div class="login-wrap">
@ -34,8 +58,8 @@
{/if} {/if}
<form onsubmit={submit}> <form onsubmit={submit}>
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="email">Email</label>
<input id="username" bind:value={username} autocomplete="username" required /> <input id="email" type="email" bind:value={email} autocomplete="email" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
@ -45,5 +69,28 @@
{loading ? 'Signing in…' : 'Sign in'} {loading ? 'Signing in…' : 'Sign in'}
</button> </button>
</form> </form>
{#if ssoEnabled}
<div class="sso-divider"><span>or</span></div>
<button class="btn btn-ghost" style="width:100%" onclick={startSSO} disabled={ssoLoading}>
{ssoLoading ? 'Redirecting…' : 'Log in with Discourse'}
</button>
{/if}
</div> </div>
</div> </div>
<style>
.sso-divider {
display: flex;
align-items: center;
margin: 1rem 0;
gap: 0.75rem;
color: var(--c-muted);
font-size: 0.8rem;
}
.sso-divider::before,
.sso-divider::after {
content: '';
flex: 1;
border-top: 1px solid var(--c-border);
}
</style>

View file

@ -19,6 +19,7 @@
let showAdd = $state(false) let showAdd = $state(false)
let adding = $state(false) let adding = $state(false)
let newName = $state('') let newName = $state('')
let newTicketedName = $state('')
let newEmail = $state('') let newEmail = $state('')
let newPhone = $state('') let newPhone = $state('')
let newPronouns = $state('') let newPronouns = $state('')
@ -27,6 +28,7 @@
// Edit participant // Edit participant
let editId = $state(null) let editId = $state(null)
let editName = $state('') let editName = $state('')
let editTicketedName = $state('')
let editEmail = $state('') let editEmail = $state('')
let editPhone = $state('') let editPhone = $state('')
let editPronouns = $state('') let editPronouns = $state('')
@ -40,8 +42,9 @@
let newTicketType = $state('') let newTicketType = $state('')
let newTicketExtId = $state('') let newTicketExtId = $state('')
const role = $derived(session?.user?.role ?? '') const roles = $derived(session?.user?.roles ?? [])
const canManage = $derived(['admin', 'ticketing'].includes(role)) function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
const canManage = $derived(hasRole('admin'))
const allParticipants = liveQuery(() => db.participants.toArray()) const allParticipants = liveQuery(() => db.participants.toArray())
const allTickets = liveQuery(() => db.tickets.toArray()) const allTickets = liveQuery(() => db.tickets.toArray())
@ -150,12 +153,12 @@
adding = true; error = '' adding = true; error = ''
try { try {
const p = await api.participants.create({ const p = await api.participants.create({
preferred_name: newName, email: newEmail, phone: newPhone, preferred_name: newName, ticket_name: newTicketedName, email: newEmail,
pronouns: newPronouns, note: newNote, phone: newPhone, pronouns: newPronouns, note: newNote,
}) })
await db.participants.put(p) await db.participants.put(p)
showAdd = false showAdd = false
newName = newEmail = newPhone = newPronouns = newNote = '' newName = newTicketedName = newEmail = newPhone = newPronouns = newNote = ''
} catch (err) { } catch (err) {
error = err.message error = err.message
} finally { } finally {
@ -166,6 +169,7 @@
function startEdit(p) { function startEdit(p) {
editId = p.id editId = p.id
editName = p.preferred_name editName = p.preferred_name
editTicketedName = p.ticket_name || ''
editEmail = p.email editEmail = p.email
editPhone = p.phone editPhone = p.phone
editPronouns = p.pronouns editPronouns = p.pronouns
@ -177,8 +181,8 @@
saving = true; error = '' saving = true; error = ''
try { try {
const p = await api.participants.update(editId, { const p = await api.participants.update(editId, {
preferred_name: editName, email: editEmail, phone: editPhone, preferred_name: editName, ticket_name: editTicketedName, email: editEmail,
pronouns: editPronouns, note: editNote, phone: editPhone, pronouns: editPronouns, note: editNote,
}) })
await db.participants.put(p) await db.participants.put(p)
editId = null editId = null
@ -244,11 +248,15 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addParticipant}> <form onsubmit={addParticipant}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="p-name">Name</label> <label for="p-name">Preferred Name</label>
<input id="p-name" bind:value={newName} placeholder="Preferred name" /> <input id="p-name" bind:value={newName} placeholder="Preferred name" />
</div> </div>
<div class="form-group">
<label for="p-tname">Ticketed Name</label>
<input id="p-tname" bind:value={newTicketedName} placeholder="Legal/ticketed name" />
</div>
<div class="form-group"> <div class="form-group">
<label for="p-email">Email</label> <label for="p-email">Email</label>
<input id="p-email" type="email" bind:value={newEmail} placeholder="email@example.com" /> <input id="p-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
@ -323,7 +331,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Preferred Name</th>
<th>Email</th> <th>Email</th>
<th>Tickets</th> <th>Tickets</th>
<th>Status</th> <th>Status</th>
@ -343,6 +351,7 @@
<form class="participant-edit-form" onsubmit={saveEdit}> <form class="participant-edit-form" onsubmit={saveEdit}>
<div class="edit-fields"> <div class="edit-fields">
<input bind:value={editName} placeholder="Preferred name" /> <input bind:value={editName} placeholder="Preferred name" />
<input bind:value={editTicketedName} placeholder="Ticketed name" />
<input type="email" bind:value={editEmail} placeholder="Email" /> <input type="email" bind:value={editEmail} placeholder="Email" />
<input bind:value={editPhone} placeholder="Phone" /> <input bind:value={editPhone} placeholder="Phone" />
<input bind:value={editPronouns} placeholder="Pronouns" /> <input bind:value={editPronouns} placeholder="Pronouns" />

View file

@ -25,8 +25,9 @@
let assignVolID = $state(0) let assignVolID = $state(0)
let assigning = $state(false) let assigning = $state(false)
const role = $derived(session?.user?.role ?? '') const roles = $derived(session?.user?.roles ?? [])
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
const myDeptIDs = $derived(session?.user?.department_ids ?? []) const myDeptIDs = $derived(session?.user?.department_ids ?? [])
const allDepts = liveQuery(() => const allDepts = liveQuery(() =>
@ -54,7 +55,7 @@
// Departments visible to this user // Departments visible to this user
const visibleDepts = $derived.by(() => { const visibleDepts = $derived.by(() => {
const depts = $allDepts ?? [] const depts = $allDepts ?? []
if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id)) if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id))
return depts return depts
}) })
@ -134,11 +135,13 @@
try { try {
const res = await api.shifts.reorder(positions) const res = await api.shifts.reorder(positions)
if (res && !res.ok) throw new Error() if (res && !res.ok) throw new Error('Reorder failed')
await db.transaction('rw', db.shifts, async () => {
for (const p of positions) { for (const p of positions) {
const s = await db.shifts.get(p.id) const s = await db.shifts.get(p.id)
if (s) await db.shifts.put({ ...s, position: p.position }) if (s) await db.shifts.put({ ...s, position: p.position })
} }
})
} catch (err) { } catch (err) {
error = err.message error = err.message
} }
@ -272,7 +275,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addShift}> <form onsubmit={addShift}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="s-dept">Department *</label> <label for="s-dept">Department *</label>
<select id="s-dept" bind:value={newDeptID} required> <select id="s-dept" bind:value={newDeptID} required>
@ -377,10 +380,11 @@
<span class="board-cap">{assigned.length}</span> <span class="board-cap">{assigned.length}</span>
{/if} {/if}
{#if hasConflict} {#if hasConflict}
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span> <span class="badge badge-lead">⚠ conflict</span>
{/if} {/if}
</div> </div>
{#if canManage}
<div class="board-shift-actions"> <div class="board-shift-actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
<button class="btn btn-ghost btn-sm" title="Move up" <button class="btn btn-ghost btn-sm" title="Move up"
@ -389,6 +393,7 @@
onclick={() => reorder(shift.id, 1, rows)}>↓</button> onclick={() => reorder(shift.id, 1, rows)}>↓</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button> <button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
</div> </div>
{/if}
</div> </div>
<!-- Assigned volunteers --> <!-- Assigned volunteers -->
@ -403,13 +408,14 @@
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
<span title="Scheduling conflict" style="color:var(--c-warn)"></span> <span title="Scheduling conflict" style="color:var(--c-warn)"></span>
{/if} {/if}
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button> {#if canManage}<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>{/if}
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Assign volunteer --> <!-- Assign volunteer -->
{#if canManage}
{#if assigningShiftID === shift.id} {#if assigningShiftID === shift.id}
<div class="board-assign-row"> <div class="board-assign-row">
<select bind:value={assignVolID} style="width:auto"> <select bind:value={assignVolID} style="width:auto">
@ -433,6 +439,7 @@
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button> <button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
{/if} {/if}
{/if} {/if}
{/if}
</div> </div>
{/each} {/each}
{/each} {/each}

View file

@ -7,6 +7,7 @@
let saving = $state(false) let saving = $state(false)
let savingEvent = $state(false) let savingEvent = $state(false)
let testing = $state(false) let testing = $state(false)
let resetting = $state(false)
let error = $state('') let error = $state('')
let success = $state('') let success = $state('')
@ -26,6 +27,8 @@
let eventEndDate = $state('') let eventEndDate = $state('')
let eventTimezone = $state('') let eventTimezone = $state('')
const timezones = Intl.supportedValuesOf('timeZone') const timezones = Intl.supportedValuesOf('timeZone')
let discourseSSOUrl = $state('')
let discourseSSOSecret = $state('')
let shiftSignupsOpen = $state(false) let shiftSignupsOpen = $state(false)
let togglingSignups = $state(false) let togglingSignups = $state(false)
@ -49,6 +52,8 @@
baseURL = s.base_url ?? '' baseURL = s.base_url ?? ''
noteLabel = s.volunteer_note_label ?? 'Additional note' noteLabel = s.volunteer_note_label ?? 'Additional note'
noteRequired = s.volunteer_note_required ?? false noteRequired = s.volunteer_note_required ?? false
discourseSSOUrl = s.discourse_sso_url ?? ''
discourseSSOSecret = ''
shiftSignupsOpen = s.shift_signups_open ?? false shiftSignupsOpen = s.shift_signups_open ?? false
} catch (err) { } catch (err) {
error = err.message error = err.message
@ -89,14 +94,17 @@
smtp_host: smtpHost, smtp_host: smtpHost,
smtp_port: smtpPort, smtp_port: smtpPort,
smtp_user: smtpUser, smtp_user: smtpUser,
smtp_password: smtpPassword, // empty = keep existing smtp_password: smtpPassword,
smtp_from: smtpFrom, smtp_from: smtpFrom,
smtp_from_name: smtpFromName, smtp_from_name: smtpFromName,
base_url: baseURL, base_url: baseURL,
volunteer_note_label: noteLabel, volunteer_note_label: noteLabel,
volunteer_note_required: noteRequired, volunteer_note_required: noteRequired,
discourse_sso_url: discourseSSOUrl,
discourse_sso_secret: discourseSSOSecret,
}) })
smtpPassword = '' smtpPassword = ''
discourseSSOSecret = ''
success = 'Settings saved.' success = 'Settings saved.'
} catch (err) { } catch (err) {
error = err.message error = err.message
@ -123,7 +131,9 @@
} }
async function resetModel(label, fn) { async function resetModel(label, fn) {
if (resetting) return
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
resetting = true
error = '' error = ''
success = '' success = ''
try { try {
@ -131,6 +141,8 @@
success = `Deleted ${result.deleted} ${label}.` success = `Deleted ${result.deleted} ${label}.`
} catch (err) { } catch (err) {
error = err.message error = err.message
} finally {
resetting = false
} }
} }
@ -166,14 +178,14 @@
<div class="text-muted">Loading…</div> <div class="text-muted">Loading…</div>
{:else} {:else}
<form onsubmit={saveEvent}> <form onsubmit={saveEvent}>
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2> <h2 class="card-title">Event</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-name">Event Name *</label> <label for="e-name">Event Name *</label>
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" /> <input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-venue">Venue</label> <label for="e-venue">Venue</label>
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" /> <input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
</div> </div>
@ -185,7 +197,7 @@
<label for="e-end">End Date *</label> <label for="e-end">End Date *</label>
<input id="e-end" type="date" bind:value={eventEndDate} required /> <input id="e-end" type="date" bind:value={eventEndDate} required />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-tz">Timezone</label> <label for="e-tz">Timezone</label>
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" /> <input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
<datalist id="tz-list"> <datalist id="tz-list">
@ -204,11 +216,11 @@
</form> </form>
<form onsubmit={save}> <form onsubmit={save}>
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2> <h2 class="card-title">SMTP Email</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group" style="grid-column:1"> <div class="form-group">
<label for="s-host">SMTP Host</label> <label for="s-host">SMTP Host</label>
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" /> <input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
</div> </div>
@ -236,10 +248,27 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for kiosk links in emails)</span></label> <label for="s-url">Base URL <span class="card-hint" style="font-weight:400">(for kiosk links in emails)</span></label>
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" /> <input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
</div> </div>
<h2 class="card-title" style="margin-top:1.5rem">Discourse SSO</h2>
<p class="card-hint" style="margin-bottom:1rem">
Enable DiscourseConnect SSO so users can log in with their Discourse account.
Set the same secret in your Discourse admin under Connect &gt; discourse connect secret.
</p>
<div class="form-grid">
<div class="form-group full">
<label for="sso-url">Discourse URL</label>
<input id="sso-url" bind:value={discourseSSOUrl} placeholder="https://forum.example.com" />
</div>
<div class="form-group full">
<label for="sso-secret">SSO Secret</label>
<input id="sso-secret" type="password" bind:value={discourseSSOSecret}
placeholder="Leave blank to keep existing" autocomplete="new-password" />
</div>
</div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary" disabled={saving}> <button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'} {saving ? 'Saving…' : 'Save Settings'}
@ -249,8 +278,8 @@
</form> </form>
<!-- Test email --> <!-- Test email -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2> <h2 class="card-title">Test Email</h2>
<div style="display:flex;gap:0.5rem;align-items:flex-end"> <div style="display:flex;gap:0.5rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0"> <div class="form-group" style="flex:1;margin-bottom:0">
<label for="s-test">Send to</label> <label for="s-test">Send to</label>
@ -263,24 +292,24 @@
</div> </div>
<!-- Volunteer Signup --> <!-- Volunteer Signup -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Volunteer Signup</h2> <h2 class="card-title">Volunteer Signup</h2>
<div class="form-group"> <div class="form-group">
<label for="s-note-label">Note Field Label</label> <label for="s-note-label">Note Field Label</label>
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" /> <input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
</div> </div>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;cursor:pointer"> <label class="checkbox-label">
<input type="checkbox" bind:checked={noteRequired} /> <input type="checkbox" bind:checked={noteRequired} />
Note field is required Note field is required
</label> </label>
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem"> <p class="card-hint" style="margin-top:0.75rem">
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a> Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
</p> </p>
</div> </div>
<!-- Shift Signups --> <!-- Shift Signups -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2> <h2 class="card-title">Shift Signups</h2>
<div style="display:flex;align-items:center;gap:1rem"> <div style="display:flex;align-items:center;gap:1rem">
<span style="font-size:0.875rem"> <span style="font-size:0.875rem">
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong> Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
@ -295,7 +324,7 @@
</button> </button>
</div> </div>
{#if !shiftSignupsOpen} {#if !shiftSignupsOpen}
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem"> <p class="card-hint" style="margin-top:0.75rem">
Opening signups will email all confirmed volunteers their shift signup links. Opening signups will email all confirmed volunteers their shift signup links.
</p> </p>
{/if} {/if}
@ -303,24 +332,24 @@
<!-- Data Management --> <!-- Data Management -->
<div class="card"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2> <h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem"> <p class="card-hint" style="margin-bottom:1rem">
Permanently delete all records of a given type. This cannot be undone. Permanently delete all records of a given type. This cannot be undone.
</p> </p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem"> <div style="display:flex;flex-wrap:wrap;gap:0.5rem">
<button class="btn btn-danger" onclick={() => resetModel('tickets', api.settings.resetTickets)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('tickets', api.settings.resetTickets)}>
Delete All Tickets Delete All Tickets
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
Delete All Volunteers Delete All Volunteers
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('shifts', api.settings.resetShifts)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('shifts', api.settings.resetShifts)}>
Delete All Shifts Delete All Shifts
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('departments', api.settings.resetDepartments)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('departments', api.settings.resetDepartments)}>
Delete All Departments Delete All Departments
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
Delete All Shift Assignments Delete All Shift Assignments
</button> </button>
</div> </div>

View file

@ -12,13 +12,14 @@
let showAdd = $state(false) let showAdd = $state(false)
let adding = $state(false) let adding = $state(false)
let newUsername = $state('') let newEmail = $state('')
let newName = $state('')
let newPassword = $state('') let newPassword = $state('')
let newRole = $state('gate') let newRoles = $state([])
let newDeptIDs = $state([]) let newDeptIDs = $state([])
let editID = $state(null) let editID = $state(null)
let editRole = $state('') let editRoles = $state([])
let editDeptIDs = $state([]) let editDeptIDs = $state([])
let editPassword = $state('') let editPassword = $state('')
let saving = $state(false) let saving = $state(false)
@ -28,7 +29,7 @@
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
) )
const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper'] const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper']
const me = $derived(session?.user?.id) const me = $derived(session?.user?.id)
@ -51,15 +52,16 @@
error = '' error = ''
try { try {
const u = await api.users.create({ const u = await api.users.create({
username: newUsername, email: newEmail,
preferred_name: newName,
password: newPassword, password: newPassword,
role: newRole, roles: newRoles,
department_ids: newDeptIDs, department_ids: newDeptIDs,
}) })
users = [...users, u] users = [...users, u]
showAdd = false showAdd = false
newUsername = newPassword = '' newEmail = newName = newPassword = ''
newRole = 'gate' newRoles = []
newDeptIDs = [] newDeptIDs = []
} catch (err) { } catch (err) {
error = err.message error = err.message
@ -70,7 +72,7 @@
function startEdit(u) { function startEdit(u) {
editID = u.id editID = u.id
editRole = u.role editRoles = [...(u.roles || [])]
editDeptIDs = [...(u.department_ids || [])] editDeptIDs = [...(u.department_ids || [])]
editPassword = '' editPassword = ''
} }
@ -83,7 +85,7 @@
saving = true saving = true
error = '' error = ''
try { try {
const payload = { role: editRole, department_ids: editDeptIDs } const payload = { roles: editRoles, department_ids: editDeptIDs }
if (editPassword) payload.password = editPassword if (editPassword) payload.password = editPassword
const updated = await api.users.update(u.id, payload) const updated = await api.users.update(u.id, payload)
users = users.map(x => x.id === u.id ? updated : x) users = users.map(x => x.id === u.id ? updated : x)
@ -96,7 +98,7 @@
} }
async function deleteUser(u) { async function deleteUser(u) {
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return
try { try {
await api.users.delete(u.id) await api.users.delete(u.id)
users = users.filter(x => x.id !== u.id) users = users.filter(x => x.id !== u.id)
@ -105,7 +107,7 @@
} }
} }
function toggleDept(id, list) { function toggleItem(id, list) {
const idx = list.indexOf(id) const idx = list.indexOf(id)
if (idx === -1) return [...list, id] if (idx === -1) return [...list, id]
return list.filter(x => x !== id) return list.filter(x => x !== id)
@ -117,7 +119,7 @@
} }
function roleLabel(r) { function roleLabel(r) {
return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r
} }
</script> </script>
@ -132,7 +134,6 @@
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6"> <p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6">
<strong style="color:var(--c-text)">Roles:</strong> <strong style="color:var(--c-text)">Roles:</strong>
admin — full access · admin — full access ·
ticketing — participants, tickets, import ·
staffing — volunteers, shifts, departments · staffing — volunteers, shifts, departments ·
colead — manage assigned departments only · colead — manage assigned departments only ·
gatekeeper — check-in only gatekeeper — check-in only
@ -148,22 +149,31 @@
{#if showAdd} {#if showAdd}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addUser}> <form onsubmit={addUser}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem"> <div class="form-grid-3">
<div class="form-group"> <div class="form-group">
<label for="u-username">Username *</label> <label for="u-email">Email *</label>
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" /> <input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
</div>
<div class="form-group">
<label for="u-name">Preferred Name</label>
<input id="u-name" bind:value={newName} placeholder="Preferred name" autocomplete="off" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="u-password">Password *</label> <label for="u-password">Password *</label>
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" /> <input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
</div> </div>
</div>
<div class="form-group"> <div class="form-group">
<label for="u-role">Role *</label> <span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span>
<select id="u-role" bind:value={newRole}> <div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
{#each roles as r} {#each availableRoles as r}
<option value={r}>{roleLabel(r)}</option> <label class="checkbox-label">
<input type="checkbox"
checked={newRoles.includes(r)}
onchange={() => newRoles = toggleItem(r, newRoles)} />
{roleLabel(r)}
</label>
{/each} {/each}
</select>
</div> </div>
</div> </div>
{#if ($allDepts ?? []).length > 0} {#if ($allDepts ?? []).length > 0}
@ -171,10 +181,10 @@
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span> <span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem"> <div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
{#each $allDepts ?? [] as d} {#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)"> <label class="checkbox-label">
<input type="checkbox" style="width:auto" <input type="checkbox"
checked={newDeptIDs.includes(d.id)} checked={newDeptIDs.includes(d.id)}
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} /> onchange={() => newDeptIDs = toggleItem(d.id, newDeptIDs)} />
<span class="dept-dot" style="background:{d.color}"></span> <span class="dept-dot" style="background:{d.color}"></span>
{d.name} {d.name}
</label> </label>
@ -204,8 +214,8 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>Preferred Name</th>
<th>Role</th> <th>Roles</th>
<th>Departments</th> <th>Departments</th>
<th></th> <th></th>
</tr> </tr>
@ -214,22 +224,27 @@
{#each users as u (u.id)} {#each users as u (u.id)}
{#if editID === u.id} {#if editID === u.id}
<tr class="edit-row"> <tr class="edit-row">
<td class="td-name"><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td> <td class="td-name"><strong>{u.preferred_name || u.email}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
<td> <td>
<select bind:value={editRole} style="width:auto;margin:0"> <div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{#each roles as r} {#each availableRoles as r}
<option value={r}>{roleLabel(r)}</option> <label class="checkbox-label-sm">
<input type="checkbox"
checked={editRoles.includes(r)}
onchange={() => editRoles = toggleItem(r, editRoles)} />
{roleLabel(r)}
</label>
{/each} {/each}
</select> </div>
</td> </td>
<td> <td>
{#if ($allDepts ?? []).length > 0} {#if ($allDepts ?? []).length > 0}
<div style="display:flex;flex-wrap:wrap;gap:0.4rem"> <div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{#each $allDepts ?? [] as d} {#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)"> <label class="checkbox-label-sm">
<input type="checkbox" style="width:auto" <input type="checkbox"
checked={editDeptIDs.includes(d.id)} checked={editDeptIDs.includes(d.id)}
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} /> onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
{d.name} {d.name}
</label> </label>
{/each} {/each}
@ -251,18 +266,19 @@
{:else} {:else}
<tr> <tr>
<td class="td-name"> <td class="td-name">
<strong>{u.username}</strong> <strong>{u.preferred_name || u.email}</strong>
{#if u.id === me} {#if u.id === me}
<span class="badge badge-role" style="margin-left:0.4rem">you</span> <span class="badge badge-role">you</span>
{/if} {/if}
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
</td> </td>
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td> <td>{#each u.roles ?? [] as r}<span class="badge badge-role">{roleLabel(r)}</span>{/each}</td>
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td> <td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
<td class="td-actions"> <td class="td-actions">
<div class="actions"> <div class="actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
{#if u.id !== me} {#if u.id !== me}
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button> <button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Remove</button>
{/if} {/if}
</div> </div>
</td> </td>

View file

@ -24,15 +24,17 @@
let editIsLead = $state(false) let editIsLead = $state(false)
let editNote = $state('') let editNote = $state('')
let saving = $state(false) let saving = $state(false)
let confirmingID = $state(null)
const role = $derived(session?.user?.role ?? '') const roles = $derived(session?.user?.roles ?? [])
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
const canConfirm = $derived(hasRole('admin', 'staffing', 'colead'))
const myDeptIDs = $derived(session?.user?.department_ids ?? []) const myDeptIDs = $derived(session?.user?.department_ids ?? [])
let deptInitialized = $state(false) let deptInitialized = $state(false)
$effect(() => { $effect(() => {
if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) { if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) {
filterDept = String(myDeptIDs[0]) filterDept = String(myDeptIDs[0])
deptInitialized = true deptInitialized = true
} }
@ -75,11 +77,15 @@
} }
async function confirmVolunteer(v) { async function confirmVolunteer(v) {
if (confirmingID) return
confirmingID = v.id
try { try {
const updated = await api.volunteers.confirm(v.id) const updated = await api.volunteers.confirm(v.id)
await db.volunteers.put(updated) await db.volunteers.put(updated)
} catch (err) { } catch (err) {
error = err.message error = err.message
} finally {
confirmingID = null
} }
} }
@ -180,7 +186,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addVolunteer}> <form onsubmit={addVolunteer}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="v-name">Preferred Name *</label> <label for="v-name">Preferred Name *</label>
<input id="v-name" bind:value={newName} required placeholder="What they go by" /> <input id="v-name" bind:value={newName} required placeholder="What they go by" />
@ -190,8 +196,8 @@
<input id="v-ticket-name" bind:value={newTicketName} placeholder="Legal/ticketed name" /> <input id="v-ticket-name" bind:value={newTicketName} placeholder="Legal/ticketed name" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="v-email">Email</label> <label for="v-email">Email *</label>
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" /> <input id="v-email" type="email" bind:value={newEmail} required placeholder="email@example.com" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="v-dept">Department</label> <label for="v-dept">Department</label>
@ -208,8 +214,8 @@
<input id="v-note" bind:value={newNote} placeholder="Optional note" /> <input id="v-note" bind:value={newNote} placeholder="Optional note" />
</div> </div>
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer"> <label class="checkbox-label">
<input type="checkbox" style="width:auto" bind:checked={newIsLead} /> <input type="checkbox" bind:checked={newIsLead} />
Department lead Department lead
</label> </label>
</div> </div>
@ -255,7 +261,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Preferred Name</th>
<th>Department</th> <th>Department</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
@ -280,8 +286,8 @@
</select> </select>
</td> </td>
<td class="td-edit-checks"> <td class="td-edit-checks">
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap"> <label class="checkbox-label-sm" style="white-space:nowrap">
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead <input type="checkbox" bind:checked={editIsLead} /> Co-Lead
</label> </label>
</td> </td>
<td class="td-edit-note"> <td class="td-edit-note">
@ -295,16 +301,20 @@
</td> </td>
</tr> </tr>
{:else} {:else}
{@const participant = participantFor(v.participant_id)}
<tr> <tr>
<td class="td-name"> <td class="td-name">
<strong>{v.name}</strong> <strong>{v.name}</strong>
{#if v.is_lead} {#if v.is_lead}
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span> <span class="badge badge-lead">Co-Lead</span>
{/if} {/if}
{#if !v.participant_id} {#if !v.participant_id}
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span> <span class="badge badge-unchecked" title="Not linked to a participant">No ticket</span>
{:else if !participantHasTickets(v.participant_id)} {:else if !participantHasTickets(v.participant_id)}
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span> <span class="badge badge-partial" title="No ticket on file">No ticket</span>
{/if}
{#if participant?.ticket_name && participant.ticket_name !== v.name}
<div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div>
{/if} {/if}
{#if v.email} {#if v.email}
<div class="text-muted" style="font-size:0.78rem">{v.email}</div> <div class="text-muted" style="font-size:0.78rem">{v.email}</div>
@ -344,7 +354,7 @@
{#if canManage} {#if canManage}
<td class="td-actions"> <td class="td-actions">
{#if canConfirm && v.email_confirmed && !v.confirmed} {#if canConfirm && v.email_confirmed && !v.confirmed}
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button> <button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)} disabled={confirmingID === v.id}>Confirm</button>
{/if} {/if}
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button> <button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>

View file

@ -4,10 +4,36 @@ import { api } from './api.js'
let syncing = false let syncing = false
let sseSource = null let sseSource = null
async function checkBuildChanged() {
try {
const res = await fetch('/api/version')
const { build } = await res.json()
if (!build) return
const stored = await db.meta.get('build')
if (!stored || stored.value !== build) {
await db.transaction('rw',
[db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
async () => {
await db.meta.clear()
await db.event.clear()
await db.participants.clear()
await db.tickets.clear()
await db.departments.clear()
await db.volunteers.clear()
await db.shifts.clear()
await db.volunteer_shifts.clear()
await db.meta.put({ key: 'build', value: build })
}
)
}
} catch {}
}
export async function syncPull() { export async function syncPull() {
if (syncing) return if (syncing) return
syncing = true syncing = true
try { try {
await checkBuildChanged()
const since = await getLastSync() const since = await getLastSync()
const data = await api.sync.pull(since) const data = await api.sync.pull(since)
@ -51,7 +77,7 @@ export async function syncPull() {
} }
) )
await setLastSync(data.server_time) if (data.server_time) await setLastSync(data.server_time)
return true return true
} catch (err) { } catch (err) {
console.warn('Sync pull failed:', err.message) console.warn('Sync pull failed:', err.message)
@ -97,7 +123,7 @@ export function startSSE(onEvent) {
syncPull() syncPull()
}, 5000) }, 5000)
} }
}) }).catch(() => {})
} }
connect() connect()
@ -108,18 +134,23 @@ export function stopSSE() {
sseSource = null sseSource = null
} }
// Poll for sync when online, with exponential backoff on failure
let syncInterval = null let syncInterval = null
let onlineHandler = null
export function startSyncLoop(intervalMs = 30000) { export function startSyncLoop(intervalMs = 30000) {
if (syncInterval) return if (syncInterval) return
syncInterval = setInterval(() => { syncInterval = setInterval(() => {
if (navigator.onLine) syncPull() if (navigator.onLine) syncPull()
}, intervalMs) }, intervalMs)
window.addEventListener('online', () => syncPull()) onlineHandler = () => syncPull()
window.addEventListener('online', onlineHandler)
} }
export function stopSyncLoop() { export function stopSyncLoop() {
clearInterval(syncInterval) clearInterval(syncInterval)
syncInterval = null syncInterval = null
if (onlineHandler) {
window.removeEventListener('online', onlineHandler)
onlineHandler = null
}
} }

View file

@ -31,8 +31,8 @@ func TestParticipantsListCreateDelete(t *testing.T) {
} }
list := parseJSON(t, w) list := parseJSON(t, w)
participants := list["participants"].([]any) participants := list["participants"].([]any)
if len(participants) != 1 { if len(participants) != 2 { // admin + Titania
t.Errorf("list: got %d, want 1", len(participants)) t.Errorf("list: got %d, want 2", len(participants))
} }
// Delete // Delete
@ -48,8 +48,8 @@ func TestParticipantsListCreateDelete(t *testing.T) {
w = httptest.NewRecorder() w = httptest.NewRecorder()
mux.ServeHTTP(w, req) mux.ServeHTTP(w, req)
list = parseJSON(t, w) list = parseJSON(t, w)
if ps, ok := list["participants"].([]any); ok && len(ps) != 0 { if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains
t.Errorf("after delete: got %d, want 0", len(ps)) t.Errorf("after delete: got %d, want 1", len(ps))
} }
} }
@ -77,7 +77,7 @@ func TestCheckInTicketHandler(t *testing.T) {
func TestGatekeeperRoleCanCheckIn(t *testing.T) { func TestGatekeeperRoleCanCheckIn(t *testing.T) {
app := testApp(t) app := testApp(t)
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)
@ -94,7 +94,7 @@ func TestGatekeeperRoleCanCheckIn(t *testing.T) {
func TestGatekeeperRoleCannotDelete(t *testing.T) { func TestGatekeeperRoleCannotDelete(t *testing.T) {
app := testApp(t) app := testApp(t)
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)

View file

@ -7,7 +7,7 @@ import (
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Username string `json:"username"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@ -15,7 +15,7 @@ func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
user, hash, err := app.getUserByUsername(body.Username) user, hash, err := app.getLoginParticipant(body.Email)
if err != nil { if err != nil {
writeError(w, "internal error", http.StatusInternalServerError) writeError(w, "internal error", http.StatusInternalServerError)
return return
@ -40,9 +40,9 @@ func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) {
func (app *App) handleMe(w http.ResponseWriter, r *http.Request) { func (app *App) handleMe(w http.ResponseWriter, r *http.Request) {
claims := claimsFromContext(r) claims := claimsFromContext(r)
user, err := app.getUserByID(claims.UserID) user, err := app.getUser(claims.ParticipantID)
if err != nil || user == nil { if err != nil || user == nil {
writeError(w, "not found", http.StatusNotFound) writeError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
writeJSON(w, user) writeJSON(w, user)

View file

@ -16,7 +16,7 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
// Create volunteer with a kiosk_code directly on the volunteer record // Create volunteer with a kiosk_code directly on the volunteer record
p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"})
v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
token, _ := app.generateVolunteerKioskCode() token, _ := app.generateVolunteerKioskCode()
app.assignKioskCode(v.ID, token) app.assignKioskCode(v.ID, token)
@ -132,7 +132,8 @@ func TestKioskClaimFull(t *testing.T) {
// Shift 2 has capacity 1. Fill it with another volunteer. // Shift 2 has capacity 1. Fill it with another volunteer.
dept, _ := app.createDepartment(Department{Name: "Build"}) dept, _ := app.createDepartment(Department{Name: "Build"})
deptID := dept.ID deptID := dept.ID
other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID}) otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"})
other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, DepartmentID: &deptID})
app.assignShift(other.ID, 2) // fills the capacity-1 shift app.assignShift(other.ID, 2) // fills the capacity-1 shift
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil)

View file

@ -169,7 +169,7 @@ func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
tk, err := app.checkInTicket(id, claims.UserID) tk, err := app.checkInTicket(id, claims.ParticipantID)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return

View file

@ -27,6 +27,14 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
noteLabel = "Additional note" noteLabel = "Additional note"
} }
var ssoURL, ssoSecret string
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
maskedSSOSecret := ""
if ssoSecret != "" {
maskedSSOSecret = "***"
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"smtp_host": cfg.Host, "smtp_host": cfg.Host,
"smtp_port": cfg.Port, "smtp_port": cfg.Port,
@ -38,6 +46,8 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
"volunteer_note_label": noteLabel, "volunteer_note_label": noteLabel,
"volunteer_note_required": noteRequired == "true", "volunteer_note_required": noteRequired == "true",
"shift_signups_open": signupsOpen == "true", "shift_signups_open": signupsOpen == "true",
"discourse_sso_url": ssoURL,
"discourse_sso_secret": maskedSSOSecret,
}) })
} }
@ -49,7 +59,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
} }
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url", keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
"volunteer_note_label", "volunteer_note_required"} "volunteer_note_label", "volunteer_note_required", "discourse_sso_url", "discourse_sso_secret"}
for _, k := range keys { for _, k := range keys {
v, ok := body[k] v, ok := body[k]
if !ok { if !ok {
@ -58,7 +68,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var val string var val string
switch vv := v.(type) { switch vv := v.(type) {
case string: case string:
if k == "smtp_password" && vv == "" { if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
continue continue
} }
val = vv val = vv

View file

@ -85,7 +85,7 @@ func TestResetTickets(t *testing.T) {
func TestResetTicketsRequiresAdmin(t *testing.T) { func TestResetTicketsRequiresAdmin(t *testing.T) {
app := testApp(t) app := testApp(t)
gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{}) gate := testUserWithRoles(t, app, "Snug", []string{"gatekeeper"}, []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)
@ -131,7 +131,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) {
func TestSettingsNonAdminRejected(t *testing.T) { func TestSettingsNonAdminRejected(t *testing.T) {
app := testApp(t) app := testApp(t)
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)

View file

@ -8,20 +8,19 @@ import (
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
var deptID *int var deptIDs []int
if d := q.Get("dept"); d != "" { if d := q.Get("dept"); d != "" {
id, err := strconv.Atoi(d) if id, err := strconv.Atoi(d); err == nil {
if err == nil { deptIDs = []int{id}
deptID = &id
} }
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { if isCoLeadOnly(claims) && len(deptIDs) == 0 {
deptID = &claims.DeptIDs[0] deptIDs = claims.DeptIDs
} }
shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since")) shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since"))
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -40,7 +39,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) { if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)
return return
} }
@ -65,7 +64,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "colead" { if isCoLeadOnly(claims) {
existing, _ := app.getShift(id) existing, _ := app.getShift(id)
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)
@ -87,6 +86,14 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) {
writeError(w, "invalid id", http.StatusBadRequest) writeError(w, "invalid id", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
s, _ := app.getShift(id)
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
if err := app.deleteShift(id); err != nil { if err := app.deleteShift(id); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -111,6 +118,14 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques
writeError(w, "volunteer_id required", http.StatusBadRequest) writeError(w, "volunteer_id required", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
s, _ := app.getShift(shiftID)
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
if !body.Force { if !body.Force {
conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID) conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID)
@ -149,6 +164,14 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ
writeError(w, "invalid volunteer id", http.StatusBadRequest) writeError(w, "invalid volunteer id", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
s, _ := app.getShift(shiftID)
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
if err := app.unassignShift(volunteerID, shiftID); err != nil { if err := app.unassignShift(volunteerID, shiftID); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -167,6 +190,16 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) {
writeError(w, "array of {id, position} required", http.StatusBadRequest) writeError(w, "array of {id, position} required", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
for _, p := range raw {
s, _ := app.getShift(p.ID)
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
}
positions := make([]struct{ ID, Position int }, len(raw)) positions := make([]struct{ ID, Position int }, len(raw))
for i, p := range raw { for i, p := range raw {
positions[i] = struct{ ID, Position int }{p.ID, p.Position} positions[i] = struct{ ID, Position int }{p.ID, p.Position}

View file

@ -55,7 +55,8 @@ func TestShiftAssignVolunteer(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID deptID := dept.ID
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
// Assign // Assign
req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{
@ -86,7 +87,8 @@ func TestShiftAssignConflict(t *testing.T) {
deptID := dept.ID deptID := dept.ID
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
// Assign to first shift // Assign to first shift
app.assignShift(1, 1) app.assignShift(1, 1)
@ -102,6 +104,86 @@ func TestShiftAssignConflict(t *testing.T) {
} }
} }
func TestCoLeadDeleteShiftOtherDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for other dept, got %d", w.Code)
}
}
func TestCoLeadDeleteShiftOwnDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
if w.Code != http.StatusNoContent {
t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String())
}
}
func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
deptBID := deptB.ID
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{
"volunteer_id": v.ID,
}, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for other dept, got %d", w.Code)
}
}
func TestCoLeadReorderShiftsOtherDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{
{"id": s1.ID, "position": 2},
{"id": s2.ID, "position": 1},
}, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for other dept reorder, got %d", w.Code)
}
}
func TestShiftReorder(t *testing.T) { func TestShiftReorder(t *testing.T) {
app := testApp(t) app := testApp(t)
admin := testAdminUser(t, app) admin := testAdminUser(t, app)

View file

@ -89,17 +89,12 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
writeError(w, "internal error", http.StatusInternalServerError) writeError(w, "internal error", http.StatusInternalServerError)
return return
} }
app.setParticipantConfirmationToken(participant.ID, confirmToken)
vol := Volunteer{ vol := Volunteer{
ParticipantID: &participant.ID, ParticipantID: participant.ID,
Name: body.PreferredName,
PreferredName: body.PreferredName,
Email: body.Email,
Phone: body.Phone,
Pronouns: body.Pronouns,
DepartmentID: body.DepartmentID, DepartmentID: body.DepartmentID,
Note: body.Note, Note: body.Note,
ConfirmationToken: &confirmToken,
} }
if _, err := app.createVolunteer(vol); err != nil { if _, err := app.createVolunteer(vol); err != nil {
@ -136,7 +131,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := app.confirmVolunteerEmail(vol.ID); err != nil { if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil {
writeError(w, "internal error", http.StatusInternalServerError) writeError(w, "internal error", http.StatusInternalServerError)
return return
} }
@ -153,7 +148,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code)
response["kiosk_link"] = kioskLink response["kiosk_link"] = kioskLink
go func() { go func() {
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil { if err := app.sendShiftSignupEmail(vol.Email, vol.Name, kioskLink); err != nil {
log.Printf("shift signup email to %s failed: %v", vol.Email, err) log.Printf("shift signup email to %s failed: %v", vol.Email, err)
} }
}() }()
@ -198,7 +193,7 @@ func (app *App) openShiftSignups() {
// Email all email-confirmed volunteers that now have a kiosk code. // Email all email-confirmed volunteers that now have a kiosk code.
confirmed, _ := queryVolunteers(app.db, ` confirmed, _ := queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+` SELECT `+volunteerSelect+` `+volunteerFrom+`
WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) WHERE p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`)
baseURL := app.resolveBaseURL() baseURL := app.resolveBaseURL()
sent := 0 sent := 0
@ -207,11 +202,7 @@ func (app *App) openShiftSignups() {
continue continue
} }
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode)
name := v.PreferredName if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil {
if name == "" {
name = v.Name
}
if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil {
sent++ sent++
} else { } else {
log.Printf("shift signup email to %s failed: %v", v.Email, err) log.Printf("shift signup email to %s failed: %v", v.Email, err)

View file

@ -58,27 +58,27 @@ func TestPublicSignup(t *testing.T) {
if err != nil || vol == nil { if err != nil || vol == nil {
t.Fatal("volunteer not created") t.Fatal("volunteer not created")
} }
if vol.PreferredName != "Titania" { if vol.Name != "Titania" {
t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) t.Errorf("name = %q, want Titania", vol.Name)
} }
if vol.Pronouns != "she/they" { if vol.Pronouns != "she/they" {
t.Errorf("pronouns = %q, want she/they", vol.Pronouns) t.Errorf("pronouns = %q, want she/they", vol.Pronouns)
} }
if vol.ConfirmationToken == nil || *vol.ConfirmationToken == "" {
t.Error("expected confirmation token to be set")
}
if vol.EmailConfirmed { if vol.EmailConfirmed {
t.Error("should not be confirmed yet") t.Error("should not be confirmed yet")
} }
// Participant should be auto-created and linked // Participant should be auto-created and linked
if vol.ParticipantID == nil { if vol.ParticipantID == 0 {
t.Fatal("expected participant to be linked") t.Fatal("expected participant to be linked")
} }
p, _ := app.getParticipant(*vol.ParticipantID) p, _ := app.getParticipant(vol.ParticipantID)
if p == nil { if p == nil {
t.Fatal("linked participant not found") t.Fatal("linked participant not found")
} }
if p.ConfirmationToken == nil || *p.ConfirmationToken == "" {
t.Error("expected confirmation token on participant")
}
if p.Email != "titania@example.com" { if p.Email != "titania@example.com" {
t.Errorf("participant email = %q, want titania@example.com", p.Email) t.Errorf("participant email = %q, want titania@example.com", p.Email)
} }
@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchParticipant(t *testing.T) {
if vol == nil { if vol == nil {
t.Fatal("volunteer not created") t.Fatal("volunteer not created")
} }
if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID { if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID {
t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID) t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID)
} }
} }
@ -200,12 +200,8 @@ func TestConfirmEmail(t *testing.T) {
mux := testMux(app) mux := testMux(app)
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: p.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ConfirmationToken: &token,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
@ -217,12 +213,13 @@ func TestConfirmEmail(t *testing.T) {
t.Errorf("expected confirmed, got %v", result["status"]) t.Errorf("expected confirmed, got %v", result["status"])
} }
// Verify volunteer is confirmed // Verify participant is email confirmed
vol, _ := app.getVolunteerByEmail("titania@example.com") vol, _ := app.getVolunteerByEmail("titania@example.com")
if vol == nil || !vol.EmailConfirmed { if vol == nil || !vol.EmailConfirmed {
t.Error("volunteer should be email confirmed") t.Error("volunteer should show email confirmed via participant")
} }
if vol.ConfirmationToken != nil { updatedP, _ := app.getParticipant(p.ID)
if updatedP.ConfirmationToken != nil {
t.Error("confirmation token should be cleared after confirmation") t.Error("confirmation token should be cleared after confirmation")
} }
} }
@ -247,12 +244,8 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) {
mux := testMux(app) mux := testMux(app)
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: p.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ConfirmationToken: &token,
})
// Confirm first time // Confirm first time
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -277,15 +270,9 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) {
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
app.baseURL = "https://example.com" app.baseURL = "https://example.com"
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: participant.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ParticipantID: &participant.ID,
ConfirmationToken: &token,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
@ -327,10 +314,10 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) {
} }
vol, _ := app.getVolunteerByEmail("titania@example.com") vol, _ := app.getVolunteerByEmail("titania@example.com")
if vol == nil || vol.ParticipantID == nil { if vol == nil || vol.ParticipantID == 0 {
t.Fatal("volunteer/participant not created") t.Fatal("volunteer/participant not created")
} }
p, _ := app.getParticipant(*vol.ParticipantID) p, _ := app.getParticipant(vol.ParticipantID)
if p == nil { if p == nil {
t.Fatal("participant not found") t.Fatal("participant not found")
} }
@ -349,15 +336,9 @@ func TestConfirmEmailAssignsKioskCode(t *testing.T) {
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
app.baseURL = "https://example.com" app.baseURL = "https://example.com"
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"})
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: participant.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ParticipantID: &participant.ID,
ConfirmationToken: &token,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))

190
handle_sso.go Normal file
View file

@ -0,0 +1,190 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strings"
)
func (app *App) getSSOConfig() (ssoURL, ssoSecret string) {
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
return
}
func (app *App) handleSSOEnabled(w http.ResponseWriter, r *http.Request) {
ssoURL, ssoSecret := app.getSSOConfig()
writeJSON(w, map[string]bool{"enabled": ssoURL != "" && ssoSecret != ""})
}
func (app *App) getBaseURL() string {
if app.baseURL != "" {
return app.baseURL
}
var u string
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&u)
return u
}
func (app *App) handleSSOInit(w http.ResponseWriter, r *http.Request) {
ssoURL, ssoSecret := app.getSSOConfig()
if ssoURL == "" || ssoSecret == "" {
writeError(w, "SSO not configured", http.StatusNotFound)
return
}
baseURL := app.getBaseURL()
if baseURL == "" {
writeError(w, "base_url must be configured for SSO", http.StatusBadRequest)
return
}
b := make([]byte, 32)
rand.Read(b)
nonce := hex.EncodeToString(b)
app.cleanExpiredNonces()
if err := app.createSSONonce(nonce); err != nil {
writeError(w, "internal error", http.StatusInternalServerError)
return
}
returnURL := strings.TrimRight(baseURL, "/") + "/api/sso/callback"
payload := fmt.Sprintf("nonce=%s&return_sso_url=%s", url.QueryEscape(nonce), url.QueryEscape(returnURL))
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
mac := hmac.New(sha256.New, []byte(ssoSecret))
mac.Write([]byte(encoded))
sig := hex.EncodeToString(mac.Sum(nil))
redirect := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s",
strings.TrimRight(ssoURL, "/"), url.QueryEscape(encoded), url.QueryEscape(sig))
writeJSON(w, map[string]string{"redirect_url": redirect})
}
func (app *App) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
baseURL := app.getBaseURL()
ssoRedirectError := func(msg string) {
if baseURL != "" {
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_error="+url.QueryEscape(msg), http.StatusFound)
} else {
writeError(w, msg, http.StatusBadRequest)
}
}
_, ssoSecret := app.getSSOConfig()
if ssoSecret == "" {
ssoRedirectError("SSO not configured")
return
}
ssoParam := r.URL.Query().Get("sso")
sigParam := r.URL.Query().Get("sig")
if ssoParam == "" || sigParam == "" {
ssoRedirectError("Invalid SSO response")
return
}
mac := hmac.New(sha256.New, []byte(ssoSecret))
mac.Write([]byte(ssoParam))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSig), []byte(sigParam)) {
ssoRedirectError("Invalid SSO signature")
return
}
decoded, err := base64.StdEncoding.DecodeString(ssoParam)
if err != nil {
ssoRedirectError("Invalid SSO payload")
return
}
vals, err := url.ParseQuery(string(decoded))
if err != nil {
ssoRedirectError("Invalid SSO payload")
return
}
nonce := vals.Get("nonce")
valid, err := app.consumeSSONonce(nonce)
if err != nil || !valid {
ssoRedirectError("SSO session expired. Please try again.")
return
}
email := strings.ToLower(vals.Get("email"))
if email == "" {
ssoRedirectError("No email in SSO response")
return
}
name := vals.Get("name")
if name == "" {
name = vals.Get("username")
}
user, _, err := app.getLoginParticipant(email)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
if user == nil {
p, err := app.getParticipantByEmail(email)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
if p != nil {
if _, err := app.db.Exec(
`UPDATE participants SET login_enabled = 1, updated_at = ? WHERE id = ?`,
now(), p.ID,
); err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
user, err = app.getUser(p.ID)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
}
}
if user == nil {
if name == "" {
name = strings.Split(email, "@")[0]
}
res, err := app.db.Exec(
`INSERT INTO participants (email, preferred_name, login_enabled, updated_at) VALUES (?, ?, 1, ?)`,
email, name, now(),
)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
id, _ := res.LastInsertId()
user, err = app.getUser(int(id))
if err != nil || user == nil {
ssoRedirectError("Login failed. Please try again.")
return
}
}
token, err := app.signToken(user)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_token="+url.QueryEscape(token), http.StatusFound)
}

View file

@ -13,10 +13,10 @@ func TestSyncPullFull(t *testing.T) {
token := testToken(t, app, admin) token := testToken(t, app, admin)
mux := testMux(app) mux := testMux(app)
app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID deptID := dept.ID
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, DepartmentID: &deptID})
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
req := testAuthRequest("GET", "/api/sync/pull", nil, token) req := testAuthRequest("GET", "/api/sync/pull", nil, token)
@ -32,8 +32,8 @@ func TestSyncPullFull(t *testing.T) {
t.Error("missing server_time") t.Error("missing server_time")
} }
participants := result["participants"].([]any) participants := result["participants"].([]any)
if len(participants) != 1 { if len(participants) != 2 { // admin + Titania
t.Errorf("participants = %d, want 1", len(participants)) t.Errorf("participants = %d, want 2", len(participants))
} }
depts := result["departments"].([]any) depts := result["departments"].([]any)
if len(depts) != 1 { if len(depts) != 1 {
@ -47,14 +47,16 @@ func TestSyncPullIncremental(t *testing.T) {
token := testToken(t, app, admin) token := testToken(t, app, admin)
mux := testMux(app) mux := testMux(app)
// Backdate admin participant so it falls before the "since" cutoff.
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID)
p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
// Backdate Titania so she falls before the "since" cutoff
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID) app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID)
since := "2026-01-01T12:00:00Z" since := "2026-01-01T12:00:00Z"
// Oberon created with default updated_at (now), which is after our since // Lysander created with default updated_at (now), which is after our since
app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"})
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -62,14 +64,13 @@ func TestSyncPullIncremental(t *testing.T) {
result := parseJSON(t, w) result := parseJSON(t, w)
participants := result["participants"].([]any) participants := result["participants"].([]any)
// Should only include Oberon (created after `since`)
if len(participants) != 1 { if len(participants) != 1 {
t.Errorf("incremental: got %d participants, want 1", len(participants)) t.Errorf("incremental: got %d participants, want 1", len(participants))
} }
if len(participants) == 1 { if len(participants) == 1 {
p := participants[0].(map[string]any) p := participants[0].(map[string]any)
if p["preferred_name"] != "Oberon" { if p["preferred_name"] != "Lysander" {
t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"]) t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"])
} }
} }
} }
@ -80,8 +81,10 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) {
token := testToken(t, app, admin) token := testToken(t, app, admin)
mux := testMux(app) mux := testMux(app)
// Backdate admin participant.
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID)
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
// Backdate Titania's creation so the since cutoff is between creation and deletion
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID) app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID)
since := "2026-01-01T12:00:00Z" since := "2026-01-01T12:00:00Z"

View file

@ -17,17 +17,18 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Username string `json:"username"` Email string `json:"email"`
PreferredName string `json:"preferred_name"`
Password string `json:"password"` Password string `json:"password"`
Role string `json:"role"` Roles []string `json:"roles"`
DepartmentIDs []int `json:"department_ids"` DepartmentIDs []int `json:"department_ids"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request", http.StatusBadRequest) writeError(w, "invalid request", http.StatusBadRequest)
return return
} }
if body.Username == "" || body.Password == "" || body.Role == "" { if body.Email == "" || body.Password == "" || len(body.Roles) == 0 {
writeError(w, "username, password, and role are required", http.StatusBadRequest) writeError(w, "email, password, and at least one role are required", http.StatusBadRequest)
return return
} }
hash, err := hashPassword(body.Password) hash, err := hashPassword(body.Password)
@ -38,7 +39,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if body.DepartmentIDs == nil { if body.DepartmentIDs == nil {
body.DepartmentIDs = []int{} body.DepartmentIDs = []int{}
} }
user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs) user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -53,8 +54,13 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
writeError(w, "invalid id", http.StatusBadRequest) writeError(w, "invalid id", http.StatusBadRequest)
return return
} }
target, _ := app.getUser(id)
if target == nil {
writeError(w, "not found", http.StatusNotFound)
return
}
var body struct { var body struct {
Role string `json:"role"` Roles []string `json:"roles"`
Password string `json:"password"` Password string `json:"password"`
DepartmentIDs []int `json:"department_ids"` DepartmentIDs []int `json:"department_ids"`
} }
@ -65,8 +71,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if body.DepartmentIDs == nil { if body.DepartmentIDs == nil {
body.DepartmentIDs = []int{} body.DepartmentIDs = []int{}
} }
if body.Role != "" { if body.Roles != nil {
if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil { if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -82,7 +88,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
user, _ := app.getUserByID(id) user, _ := app.getUser(id)
writeJSON(w, user) writeJSON(w, user)
} }
@ -93,11 +99,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.UserID == id { if claims.ParticipantID == id {
writeError(w, "cannot delete yourself", http.StatusBadRequest) writeError(w, "cannot delete yourself", http.StatusBadRequest)
return return
} }
if err := app.deleteUser(id); err != nil { if err := app.removeUser(id); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }

View file

@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"strconv" "strconv"
) )
@ -11,20 +12,19 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
search := q.Get("search") search := q.Get("search")
since := q.Get("since") since := q.Get("since")
var deptID *int var deptIDs []int
if d := q.Get("dept"); d != "" { if d := q.Get("dept"); d != "" {
id, err := strconv.Atoi(d) if id, err := strconv.Atoi(d); err == nil {
if err == nil { deptIDs = []int{id}
deptID = &id
} }
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { if isCoLeadOnly(claims) && len(deptIDs) == 0 {
deptID = &claims.DeptIDs[0] deptIDs = claims.DeptIDs
} }
volunteers, err := app.listVolunteers(search, deptID, since) volunteers, err := app.listVolunteers(search, deptIDs, since)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -34,41 +34,64 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Volunteer Name string `json:"name"`
TicketName string `json:"ticket_name"` TicketName string `json:"ticket_name"`
Email string `json:"email"`
DepartmentID *int `json:"department_id"`
IsLead bool `json:"is_lead"`
Note string `json:"note"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request", http.StatusBadRequest) writeError(w, "invalid request", http.StatusBadRequest)
return return
} }
v := body.Volunteer if body.Name == "" {
if v.Name == "" {
writeError(w, "name is required", http.StatusBadRequest) writeError(w, "name is required", http.StatusBadRequest)
return return
} }
if body.Email == "" {
writeError(w, "email is required", http.StatusBadRequest)
return
}
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "colead" { if isCoLeadOnly(claims) {
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)
return return
} }
} }
if v.Email != "" && v.ParticipantID == nil { p, _ := app.getParticipantByEmail(body.Email)
p, _ := app.getParticipantByEmail(v.Email)
if p == nil { if p == nil {
p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email, TicketName: body.TicketName}) p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName})
} else if body.TicketName != "" && p.TicketName == "" { } else if body.TicketName != "" && p.TicketName == "" {
app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID)
} }
if p != nil { if p == nil {
v.ParticipantID = &p.ID writeError(w, "failed to create participant", http.StatusInternalServerError)
return
} }
confirmToken, err := generateConfirmationToken()
if err != nil {
writeError(w, "internal error", http.StatusInternalServerError)
return
}
app.setParticipantConfirmationToken(p.ID, confirmToken)
v := Volunteer{
ParticipantID: p.ID,
DepartmentID: body.DepartmentID,
IsLead: body.IsLead,
Note: body.Note,
} }
created, err := app.createVolunteer(v) created, err := app.createVolunteer(v)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
go func() {
if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil {
log.Printf("confirmation email to %s failed: %v", body.Email, err)
}
}()
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
writeJSON(w, created) writeJSON(w, created)
} }
@ -93,29 +116,37 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
writeError(w, "invalid id", http.StatusBadRequest) writeError(w, "invalid id", http.StatusBadRequest)
return return
} }
var v Volunteer var body struct {
if err := json.NewDecoder(r.Body).Decode(&v); err != nil { DepartmentID *int `json:"department_id"`
IsLead bool `json:"is_lead"`
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request", http.StatusBadRequest) writeError(w, "invalid request", http.StatusBadRequest)
return return
} }
if v.Name == "" {
writeError(w, "name is required", http.StatusBadRequest)
return
}
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "colead" { if isCoLeadOnly(claims) {
existing, _ := app.getVolunteer(id) existing, _ := app.getVolunteer(id)
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)
return return
} }
if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden)
return
}
}
v := Volunteer{
ID: id,
DepartmentID: body.DepartmentID,
IsLead: body.IsLead,
Note: body.Note,
} }
v.ID = id
if err := app.updateVolunteer(v); err != nil { if err := app.updateVolunteer(v); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
if v.IsLead { if v.IsLead {
app.confirmVolunteer(id) app.confirmVolunteer(id)
} }
@ -129,6 +160,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
writeError(w, "invalid id", http.StatusBadRequest) writeError(w, "invalid id", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
v, _ := app.getVolunteer(id)
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
if err := app.deleteVolunteer(id); err != nil { if err := app.deleteVolunteer(id); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -143,7 +182,14 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request)
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
v, err := app.markVolunteerReady(id, claims.UserID) if isCoLeadOnly(claims) {
v, _ := app.getVolunteer(id)
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
v, err := app.markVolunteerReady(id, claims.ParticipantID)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -158,6 +204,14 @@ func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) {
writeError(w, "invalid id", http.StatusBadRequest) writeError(w, "invalid id", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
v, _ := app.getVolunteer(id)
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
v, err := app.confirmVolunteer(id) v, err := app.confirmVolunteer(id)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
@ -179,7 +233,24 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
writeError(w, "shift_id required", http.StatusBadRequest) writeError(w, "shift_id required", http.StatusBadRequest)
return return
} }
if err := app.assignShift(volunteerID, body.ShiftID); err != nil { claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
v, _ := app.getVolunteer(volunteerID)
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
shift, err := app.getShift(body.ShiftID)
if err != nil || shift == nil {
writeError(w, "shift not found", http.StatusNotFound)
return
}
if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil {
if err == errShiftFull {
writeError(w, "shift is at capacity", http.StatusConflict)
return
}
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -197,6 +268,14 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
writeError(w, "invalid shift id", http.StatusBadRequest) writeError(w, "invalid shift id", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r)
if isCoLeadOnly(claims) {
v, _ := app.getVolunteer(volunteerID)
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
}
}
if err := app.unassignShift(volunteerID, shiftID); err != nil { if err := app.unassignShift(volunteerID, shiftID); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@ -204,11 +283,3 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func inSlice(v int, s []int) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}

View file

@ -14,10 +14,8 @@ func TestConfirmVolunteer(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID deptID := dept.ID
v, _ := app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true})
Name: "Titania", Email: "titania@test.com", v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
DepartmentID: &deptID, EmailConfirmed: true,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
@ -46,7 +44,8 @@ func TestConfirmVolunteerIdempotent(t *testing.T) {
admin := testAdminUser(t, app) admin := testAdminUser(t, app)
tok := testToken(t, app, admin) tok := testToken(t, app, admin)
v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
// Confirm twice — second should be a no-op, not an error. // Confirm twice — second should be a no-op, not an error.
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -66,16 +65,141 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) {
app := testApp(t) app := testApp(t)
mux := testMux(app) mux := testMux(app)
// Ticketing role should NOT be able to confirm volunteers. // Gatekeeper role should NOT be able to confirm volunteers.
ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{})
tok := testToken(t, app, ticketing) tok := testToken(t, app, gatekeeper)
v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
if w.Code != http.StatusForbidden { if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for ticketing role, got %d", w.Code) t.Errorf("expected 403 for gatekeeper role, got %d", w.Code)
}
}
func TestCoLeadDeleteVolunteerOwnDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
deptAID := deptA.ID
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok))
if w.Code != http.StatusNoContent {
t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String())
}
}
func TestCoLeadDeleteVolunteerOtherDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
deptBID := deptB.ID
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for other dept, got %d", w.Code)
}
}
func TestCoLeadConfirmVolunteerOtherDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
deptBID := deptB.ID
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for other dept, got %d", w.Code)
}
}
func TestCoLeadReadyVolunteerOtherDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
deptBID := deptB.ID
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/ready", nil, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for other dept, got %d", w.Code)
}
}
func TestCoLeadAssignShiftOtherDept(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
deptBID := deptB.ID
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/shifts", map[string]any{
"shift_id": s.ID,
}, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for other dept, got %d", w.Code)
}
}
func TestCoLeadUpdateVolunteerTargetDeptForbidden(t *testing.T) {
app := testApp(t)
mux := testMux(app)
deptA, _ := app.createDepartment(Department{Name: "Gate"})
deptB, _ := app.createDepartment(Department{Name: "Build"})
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
tok := testToken(t, app, colead)
deptAID := deptA.ID
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
"department_id": deptB.ID,
}, tok))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 moving to other dept, got %d: %s", w.Code, w.Body.String())
} }
} }
@ -86,12 +210,13 @@ func TestUpdateVolunteerDepartment(t *testing.T) {
tok := testToken(t, app, admin) tok := testToken(t, app, admin)
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) p, _ := app.createParticipant(Participant{PreferredName: "Hermia"})
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
// Assign department via update. // Assign department via update.
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
"name": "Hermia", "department_id": dept.ID, "department_id": dept.ID,
}, tok)) }, tok))
if w.Code != 200 { if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
@ -111,10 +236,8 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Build"}) dept, _ := app.createDepartment(Department{Name: "Build"})
deptID := dept.ID deptID := dept.ID
v, _ := app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true})
Name: "Lysander", Email: "lys@test.com", v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
DepartmentID: &deptID, EmailConfirmed: true,
})
// Verify not confirmed before update. // Verify not confirmed before update.
got, _ := app.getVolunteer(v.ID) got, _ := app.getVolunteer(v.ID)
@ -125,7 +248,7 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) {
// Update is_lead=true should auto-confirm. // Update is_lead=true should auto-confirm.
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
"name": "Lysander", "department_id": deptID, "is_lead": true, "department_id": deptID, "is_lead": true,
}, tok)) }, tok))
if w.Code != 200 { if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())

105
main.go
View file

@ -97,62 +97,62 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/me", auth(app.handleMe)) mux.HandleFunc("GET /api/me", auth(app.handleMe))
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent)) mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing")) mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper")) mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper"))
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing")) mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin"))
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing")) mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin"))
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gatekeeper")) mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper"))
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing")) mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin"))
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing")) mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin"))
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing")) mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin"))
mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper")) mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper"))
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin"))
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper")) mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper"))
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin"))
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing")) mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin"))
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin"))
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin"))
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing")) mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing"))
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing")) mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing"))
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing")) mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "staffing", "colead"))
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead"))
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead"))
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead"))
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin", "ticketing")) mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin"))
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing")) mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing")) mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin"))
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing")) mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin"))
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing")) mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing")) mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin"))
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin"))
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin"))
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin"))
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin"))
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing")) mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin"))
mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull))
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
@ -161,9 +161,12 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
writeJSON(w, map[string]string{"build": buildID}) writeJSON(w, map[string]string{"build": buildID})
}) })
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "ticketing", "staffing")) mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing"))
// Public endpoints — no JWT required. // Public endpoints — no JWT required.
mux.HandleFunc("GET /api/public/sso-enabled", app.handleSSOEnabled)
mux.HandleFunc("GET /api/sso/init", app.handleSSOInit)
mux.HandleFunc("GET /api/sso/callback", app.handleSSOCallback)
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup) mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup)
mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail) mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail)
@ -196,9 +199,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
} }
func (app *App) bootstrapAdmin() error { func (app *App) bootstrapAdmin() error {
adminUser := os.Getenv("TURNPIKE_ADMIN_USER") adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL")
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD") adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
if adminUser == "" || adminPass == "" { if adminEmail == "" || adminPass == "" {
return nil return nil
} }
n, err := app.countUsers() n, err := app.countUsers()
@ -209,11 +212,11 @@ func (app *App) bootstrapAdmin() error {
if err != nil { if err != nil {
return err return err
} }
_, err = app.createUser(adminUser, hash, "admin", []int{}) _, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{})
if err != nil { if err != nil {
return err return err
} }
log.Printf("Created admin user: %s", adminUser) log.Printf("Created admin user: %s", adminEmail)
return nil return nil
} }

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@ -16,7 +17,6 @@ func testApp(t *testing.T) *App {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
// Ensure config table exists (normally created by getOrCreateSecret)
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
return &App{ return &App{
db: db, db: db,
@ -29,17 +29,18 @@ func testApp(t *testing.T) *App {
func testAdminUser(t *testing.T, app *App) *User { func testAdminUser(t *testing.T, app *App) *User {
t.Helper() t.Helper()
hash, _ := hashPassword("admin123") hash, _ := hashPassword("admin123")
u, err := app.createUser("admin", hash, "admin", []int{}) u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return u return u
} }
func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { func testUserWithRoles(t *testing.T, app *App, name string, roles []string, deptIDs []int) *User {
t.Helper() t.Helper()
hash, _ := hashPassword(username + "123") email := strings.ToLower(name) + "@athens.example"
u, err := app.createUser(username, hash, role, deptIDs) hash, _ := hashPassword(name + "123")
u, err := app.createUser(email, name, hash, roles, deptIDs)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }