Rescoped colead role and revised session handling.

This commit is contained in:
Pen Anderson 2026-03-10 15:14:36 -05:00
parent da5f3524fa
commit 7dbcd05262
12 changed files with 376 additions and 50 deletions

14
auth.go
View file

@ -108,6 +108,20 @@ func hasAnyRole(roles []string, allowed []string) bool {
return false 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 false
}
func claimsFromContext(r *http.Request) *Claims { func claimsFromContext(r *http.Request) *Claims {
c, _ := r.Context().Value(claimsKey).(*Claims) c, _ := r.Context().Value(claimsKey).(*Claims)
return c return c

31
db.go
View file

@ -939,6 +939,8 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error {
); err != nil { ); err != nil {
return err return err
} }
app.db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) SELECT ?, role FROM participant_roles WHERE participant_id = ?`, canonicalID, otherID)
app.db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) SELECT ?, department_id FROM participant_departments WHERE participant_id = ?`, canonicalID, otherID)
_, err := app.db.Exec( _, err := app.db.Exec(
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID, `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID,
) )
@ -1206,7 +1208,7 @@ const volunteerSelect = `v.id, v.participant_id,
v.created_at, v.updated_at, v.deleted_at` v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id`
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
var args []any var args []any
if since != "" { if since != "" {
@ -1220,9 +1222,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu
s := "%" + search + "%" s := "%" + search + "%"
args = append(args, s, s) args = append(args, s, s)
} }
if deptID != nil { if len(deptIDs) == 1 {
q += ` AND v.department_id = ?` q += ` AND v.department_id = ?`
args = append(args, *deptID) args = append(args, deptIDs[0])
} else if len(deptIDs) > 1 {
q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)`
for _, id := range deptIDs {
args = append(args, id)
}
} }
q += ` ORDER BY p.preferred_name` q += ` ORDER BY p.preferred_name`
return queryVolunteers(app.db, q, args...) return queryVolunteers(app.db, q, args...)
@ -1422,7 +1429,7 @@ func generateConfirmationToken() (string, error) {
// --- Shifts --- // --- Shifts ---
func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) {
q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1`
var args []any var args []any
if since != "" { if since != "" {
@ -1431,9 +1438,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
} else { } else {
q += ` AND deleted_at IS NULL` q += ` AND deleted_at IS NULL`
} }
if deptID != nil { if len(deptIDs) == 1 {
q += ` AND department_id = ?` q += ` AND department_id = ?`
args = append(args, *deptID) args = append(args, deptIDs[0])
} else if len(deptIDs) > 1 {
q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)`
for _, id := range deptIDs {
args = append(args, id)
}
} }
if day != "" { if day != "" {
q += ` AND day = ?` q += ` AND day = ?`
@ -1669,3 +1681,10 @@ func boolInt(b bool) int {
} }
return 0 return 0
} }
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.Repeat("?,", n-1) + "?"
}

View file

@ -84,7 +84,6 @@
const path = $derived(route || '/') const path = $derived(route || '/')
const roles = $derived(session?.user?.roles ?? []) const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
</script> </script>
{#if updateAvailable} {#if updateAvailable}
@ -125,7 +124,7 @@
{#if roles.length === 1 && roles[0] === '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

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

@ -2,7 +2,7 @@
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 roles = $derived(session?.user?.roles ?? []) const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
@ -147,14 +147,14 @@
<!-- Quick actions --> <!-- Quick actions -->
{#if isAdmin} {#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}

View file

@ -135,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
} }

View file

@ -22,10 +22,10 @@ async function checkBuildChanged() {
await db.volunteers.clear() await db.volunteers.clear()
await db.shifts.clear() await db.shifts.clear()
await db.volunteer_shifts.clear() await db.volunteer_shifts.clear()
await db.meta.put({ key: 'build', value: build })
} }
) )
} }
await db.meta.put({ key: 'build', value: build })
} catch {} } catch {}
} }

View file

@ -58,7 +58,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" && (vv == "" || vv == "***") {
continue continue
} }
val = vv val = vv

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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && 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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { 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

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

@ -12,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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && 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
@ -55,7 +54,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { if isCoLeadOnly(claims) {
if body.DepartmentID == nil || !inSlice(*body.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
@ -127,12 +126,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { 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{ v := Volunteer{
ID: id, ID: id,
@ -157,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
@ -171,6 +182,13 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request)
return return
} }
claims := claimsFromContext(r) 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.markVolunteerReady(id, claims.ParticipantID) 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)
@ -186,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)
@ -207,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
} }
@ -225,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
@ -232,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

@ -79,6 +79,130 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) {
} }
} }
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())
}
}
func TestUpdateVolunteerDepartment(t *testing.T) { func TestUpdateVolunteerDepartment(t *testing.T) {
app := testApp(t) app := testApp(t)
mux := testMux(app) mux := testMux(app)