Rescoped colead role and revised session handling.
This commit is contained in:
parent
da5f3524fa
commit
7dbcd05262
12 changed files with 376 additions and 50 deletions
14
auth.go
14
auth.go
|
|
@ -108,6 +108,20 @@ func hasAnyRole(roles []string, allowed []string) bool {
|
|||
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 {
|
||||
c, _ := r.Context().Value(claimsKey).(*Claims)
|
||||
return c
|
||||
|
|
|
|||
31
db.go
31
db.go
|
|
@ -939,6 +939,8 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error {
|
|||
); err != nil {
|
||||
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(
|
||||
`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`
|
||||
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`
|
||||
var args []any
|
||||
if since != "" {
|
||||
|
|
@ -1220,9 +1222,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu
|
|||
s := "%" + search + "%"
|
||||
args = append(args, s, s)
|
||||
}
|
||||
if deptID != nil {
|
||||
if len(deptIDs) == 1 {
|
||||
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`
|
||||
return queryVolunteers(app.db, q, args...)
|
||||
|
|
@ -1422,7 +1429,7 @@ func generateConfirmationToken() (string, error) {
|
|||
|
||||
// --- 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`
|
||||
var args []any
|
||||
if since != "" {
|
||||
|
|
@ -1431,9 +1438,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
|
|||
} else {
|
||||
q += ` AND deleted_at IS NULL`
|
||||
}
|
||||
if deptID != nil {
|
||||
if len(deptIDs) == 1 {
|
||||
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 != "" {
|
||||
q += ` AND day = ?`
|
||||
|
|
@ -1669,3 +1681,10 @@ func boolInt(b bool) int {
|
|||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func placeholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Repeat("?,", n-1) + "?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@
|
|||
|
||||
const path = $derived(route || '/')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
</script>
|
||||
|
||||
{#if updateAvailable}
|
||||
|
|
@ -125,7 +124,7 @@
|
|||
{#if roles.length === 1 && roles[0] === 'colead'}
|
||||
<ScheduleBoard {session} />
|
||||
{:else}
|
||||
<Dashboard {session} />
|
||||
<Dashboard {session} {navigate} />
|
||||
{/if}
|
||||
{:else if path.startsWith('/participants')}
|
||||
<Participants {session} />
|
||||
|
|
|
|||
|
|
@ -80,6 +80,18 @@ export async function saveSession(token, user) {
|
|||
}
|
||||
|
||||
export async function clearSession() {
|
||||
await db.session.clear()
|
||||
await db.meta.clear()
|
||||
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.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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
|
||||
let { session } = $props()
|
||||
let { session, navigate } = $props()
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
|
|
@ -147,14 +147,14 @@
|
|||
<!-- Quick actions -->
|
||||
{#if isAdmin}
|
||||
<div class="dash-actions">
|
||||
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a>
|
||||
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a>
|
||||
<a href="/settings" class="btn btn-ghost btn-sm">Settings</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" onclick={(e) => { e.preventDefault(); navigate('/participants') }}>Manage Participants</a>
|
||||
<a href="/settings" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/settings') }}>Settings</a>
|
||||
</div>
|
||||
{:else if isStaffing || isColead}
|
||||
<div class="dash-actions">
|
||||
<a href="/schedule" class="btn btn-ghost btn-sm">View Schedule</a>
|
||||
<a href="/volunteers" class="btn btn-ghost btn-sm">Manage Volunteers</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" onclick={(e) => { e.preventDefault(); navigate('/volunteers') }}>Manage Volunteers</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,11 +135,13 @@
|
|||
|
||||
try {
|
||||
const res = await api.shifts.reorder(positions)
|
||||
if (res && !res.ok) throw new Error()
|
||||
for (const p of positions) {
|
||||
const s = await db.shifts.get(p.id)
|
||||
if (s) await db.shifts.put({ ...s, position: p.position })
|
||||
}
|
||||
if (res && !res.ok) throw new Error('Reorder failed')
|
||||
await db.transaction('rw', db.shifts, async () => {
|
||||
for (const p of positions) {
|
||||
const s = await db.shifts.get(p.id)
|
||||
if (s) await db.shifts.put({ ...s, position: p.position })
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ async function checkBuildChanged() {
|
|||
await db.volunteers.clear()
|
||||
await db.shifts.clear()
|
||||
await db.volunteer_shifts.clear()
|
||||
await db.meta.put({ key: 'build', value: build })
|
||||
}
|
||||
)
|
||||
}
|
||||
await db.meta.put({ key: 'build', value: build })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
var val string
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if k == "smtp_password" && vv == "" {
|
||||
if k == "smtp_password" && (vv == "" || vv == "***") {
|
||||
continue
|
||||
}
|
||||
val = vv
|
||||
|
|
|
|||
|
|
@ -8,20 +8,19 @@ import (
|
|||
|
||||
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var deptID *int
|
||||
var deptIDs []int
|
||||
if d := q.Get("dept"); d != "" {
|
||||
id, err := strconv.Atoi(d)
|
||||
if err == nil {
|
||||
deptID = &id
|
||||
if id, err := strconv.Atoi(d); err == nil {
|
||||
deptIDs = []int{id}
|
||||
}
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
if isCoLeadOnly(claims) && len(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 {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -40,7 +39,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
@ -65,7 +64,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) {
|
||||
if isCoLeadOnly(claims) {
|
||||
existing, _ := app.getShift(id)
|
||||
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
||||
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)
|
||||
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 {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -111,6 +118,14 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques
|
|||
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -167,6 +190,16 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
||||
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))
|
||||
for i, p := range raw {
|
||||
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
|
|
|
|||
|
|
@ -12,20 +12,19 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
|||
search := q.Get("search")
|
||||
since := q.Get("since")
|
||||
|
||||
var deptID *int
|
||||
var deptIDs []int
|
||||
if d := q.Get("dept"); d != "" {
|
||||
id, err := strconv.Atoi(d)
|
||||
if err == nil {
|
||||
deptID = &id
|
||||
if id, err := strconv.Atoi(d); err == nil {
|
||||
deptIDs = []int{id}
|
||||
}
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
||||
deptIDs = claims.DeptIDs
|
||||
}
|
||||
|
||||
volunteers, err := app.listVolunteers(search, deptID, since)
|
||||
volunteers, err := app.listVolunteers(search, deptIDs, since)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -55,7 +54,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
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) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
|
|
@ -127,12 +126,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) {
|
||||
if isCoLeadOnly(claims) {
|
||||
existing, _ := app.getVolunteer(id)
|
||||
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
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,
|
||||
|
|
@ -157,6 +160,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
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 {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -171,6 +182,13 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request)
|
|||
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.markVolunteerReady(id, claims.ParticipantID)
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
@ -225,6 +268,14 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||
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 {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -232,11 +283,3 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func inSlice(v int, s []int) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue