diff --git a/auth.go b/auth.go index b675e6f..c2d11af 100644 --- a/auth.go +++ b/auth.go @@ -12,10 +12,10 @@ import ( ) type Claims struct { - ParticipantID int `json:"pid"` - Email string `json:"sub"` - Roles []string `json:"roles"` - DeptIDs []int `json:"dept_ids,omitempty"` + UserID int `json:"uid"` + Username string `json:"sub"` + Role string `json:"role"` + DeptIDs []int `json:"dept_ids,omitempty"` jwt.RegisteredClaims } @@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } -func (app *App) signToken(s *User) (string, error) { +func (app *App) signToken(u *User) (string, error) { expiry := time.Duration(app.tokenExpiry) * time.Hour claims := Claims{ - ParticipantID: s.ID, - Email: s.Email, - Roles: s.Roles, - DeptIDs: s.DepartmentIDs, + UserID: u.ID, + Username: u.Username, + Role: u.Role, + DeptIDs: u.DepartmentIDs, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), 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) return } - if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) { + if len(roles) > 0 && !hasRole(claims.Role, roles) { writeError(w, "forbidden", http.StatusForbidden) return } @@ -97,25 +97,9 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler } } -func hasAnyRole(roles []string, allowed []string) bool { - for _, r := range roles { - 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 { +func hasRole(role string, allowed []string) bool { + for _, r := range allowed { + if r == role { return true } } diff --git a/auth_test.go b/auth_test.go index 602c6cf..f611bc1 100644 --- a/auth_test.go +++ b/auth_test.go @@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "email": admin.Email, + "username": admin.Username, "password": "admin123", }) w := httptest.NewRecorder() @@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) { t.Error("missing token in response") } user, ok := result["user"].(map[string]any) - if !ok || user["email"] != "oberon@athens.example" { + if !ok || user["username"] != "admin" { t.Errorf("user = %v", result["user"]) } } @@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "email": "oberon@athens.example", + "username": "admin", "password": "wrong", }) w := httptest.NewRecorder() @@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "email": "nobody@test.com", + "username": "nobody", "password": "test", }) w := httptest.NewRecorder() @@ -94,7 +94,8 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) { app := testApp(t) mux := testMux(app) - gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{}) + // Create a gate user — should not be able to access /api/users (admin only) + gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) token := testToken(t, app, gate) req := testAuthRequest("GET", "/api/users", nil, token) @@ -120,7 +121,7 @@ func TestMeEndpoint(t *testing.T) { t.Fatalf("status = %d", w.Code) } result := parseJSON(t, w) - if result["email"] != "oberon@athens.example" { - t.Errorf("email = %v", result["email"]) + if result["username"] != "admin" { + t.Errorf("username = %v", result["username"]) } } diff --git a/db.go b/db.go index 0ec6716..bda44ed 100644 --- a/db.go +++ b/db.go @@ -40,6 +40,20 @@ func migrate(db *sql.DB) error { updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','ticketing','staffing','colead','gatekeeper')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS user_departments ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, department_id) + ); + CREATE TABLE IF NOT EXISTS departments ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -49,6 +63,28 @@ func migrate(db *sql.DB) error { deleted_at TEXT ); + CREATE TABLE IF NOT EXISTS attendees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + ticket_id TEXT NOT NULL DEFAULT '', + ticket_type TEXT NOT NULL DEFAULT '', + volunteer_token TEXT UNIQUE, + party_size INTEGER NOT NULL DEFAULT 1, + checked_in INTEGER NOT NULL DEFAULT 0, + checked_in_count INTEGER NOT NULL DEFAULT 0, + checked_in_at TEXT, + checked_in_by INTEGER REFERENCES users(id), + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket + ON attendees(name, ticket_id) WHERE deleted_at IS NULL; + CREATE TABLE IF NOT EXISTS volunteers ( id INTEGER PRIMARY KEY AUTOINCREMENT, participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, @@ -100,8 +136,6 @@ func migrate(db *sql.DB) error { note TEXT NOT NULL DEFAULT '', email_confirmed INTEGER NOT NULL DEFAULT 0, confirmation_token TEXT, - password_hash TEXT, - login_enabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -120,7 +154,7 @@ func migrate(db *sql.DB) error { order_id TEXT NOT NULL DEFAULT '', code TEXT UNIQUE, checked_in_at TEXT, - checked_in_by INTEGER REFERENCES participants(id), + checked_in_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -128,29 +162,19 @@ func migrate(db *sql.DB) error { CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL; - - CREATE TABLE IF NOT EXISTS participant_roles ( - participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK(role IN ('admin','staffing','colead','gatekeeper')), - PRIMARY KEY (participant_id, role) - ); - - CREATE TABLE IF NOT EXISTS participant_departments ( - participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, - department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - PRIMARY KEY (participant_id, department_id) - ); - - CREATE TABLE IF NOT EXISTS sso_nonces ( - nonce TEXT PRIMARY KEY, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); `) - return err + if err != nil { + return err + } + return nil } // --- Types --- +const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, + party_size, checked_in, checked_in_count, checked_in_at, checked_in_by, + note, created_at, updated_at, deleted_at` + const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at` const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at` @@ -166,12 +190,30 @@ type Event struct { } type User struct { - ID int `json:"id"` - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - Roles []string `json:"roles"` - DepartmentIDs []int `json:"department_ids"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + DepartmentIDs []int `json:"department_ids"` + CreatedAt string `json:"created_at"` +} + +type Attendee struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + TicketID string `json:"ticket_id"` + TicketType string `json:"ticket_type"` + VolunteerToken *string `json:"volunteer_token,omitempty"` + PartySize int `json:"party_size"` + CheckedIn bool `json:"checked_in"` + CheckedInCount int `json:"checked_in_count"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + CheckedInBy *int `json:"checked_in_by,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Department struct { @@ -283,45 +325,11 @@ func (app *App) upsertEvent(e Event) error { return err } -// --- Staff (participants with login_enabled) --- +// --- Users --- -func (app *App) getParticipantRoles(participantID int) ([]string, error) { +func (app *App) getUserDeptIDs(userID int) ([]int, error) { rows, err := app.db.Query( - `SELECT role FROM participant_roles WHERE participant_id = ? ORDER BY role`, participantID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var roles []string - for rows.Next() { - var r string - rows.Scan(&r) - roles = append(roles, r) - } - if roles == nil { - roles = []string{} - } - return roles, rows.Err() -} - -func (app *App) setParticipantRoles(participantID int, roles []string) error { - if _, err := app.db.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, participantID); err != nil { - return err - } - for _, role := range roles { - if _, err := app.db.Exec( - `INSERT INTO participant_roles (participant_id, role) VALUES (?, ?)`, participantID, role, - ); err != nil { - return err - } - } - return nil -} - -func (app *App) getUserDeptIDs(participantID int) ([]int, error) { - rows, err := app.db.Query( - `SELECT department_id FROM participant_departments WHERE participant_id = ? ORDER BY department_id`, participantID, + `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID, ) if err != nil { return nil, err @@ -339,13 +347,14 @@ func (app *App) getUserDeptIDs(participantID int) ([]int, error) { return ids, rows.Err() } -func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { - if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil { +func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { + _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID) + if err != nil { return err } for _, deptID := range deptIDs { if _, err := app.db.Exec( - `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID, + `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID, ); err != nil { return err } @@ -353,157 +362,98 @@ func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { return nil } -func (app *App) getLoginParticipant(email string) (*User, string, error) { - var s User - var hash sql.NullString +func (app *App) getUserByUsername(username string) (*User, string, error) { + var u User + var hash string err := app.db.QueryRow( - `SELECT id, email, preferred_name, password_hash, created_at - FROM participants WHERE LOWER(email) = LOWER(?) AND login_enabled = 1 AND deleted_at IS NULL`, email, - ).Scan(&s.ID, &s.Email, &s.PreferredName, &hash, &s.CreatedAt) + `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username, + ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt) if err == sql.ErrNoRows { return nil, "", nil } if err != nil { return nil, "", err } - var hashStr string - if hash.Valid { - hashStr = hash.String - } - s.Roles, _ = app.getParticipantRoles(s.ID) - s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) - return &s, hashStr, nil + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, hash, err } -func (app *App) getUser(id int) (*User, error) { - var s User +func (app *App) getUserByID(id int) (*User, error) { + var u User err := app.db.QueryRow( - `SELECT id, email, preferred_name, created_at - FROM participants WHERE id = ? AND login_enabled = 1 AND deleted_at IS NULL`, id, - ).Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt) + `SELECT id, username, role, created_at FROM users WHERE id = ?`, id, + ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - s.Roles, _ = app.getParticipantRoles(s.ID) - s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) - return &s, nil + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, err } func (app *App) listUsers() ([]User, error) { rows, err := app.db.Query( - `SELECT id, email, preferred_name, created_at - FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`, + `SELECT id, username, role, created_at FROM users ORDER BY username`, ) if err != nil { return nil, err } defer rows.Close() - var staff []User + var users []User for rows.Next() { - var s User - if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { return nil, err } - s.Roles = []string{} - s.DepartmentIDs = []int{} - staff = append(staff, s) + u.DepartmentIDs = []int{} + users = append(users, u) } if err := rows.Err(); err != nil { return nil, err } - for i := range staff { - staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID) - staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID) + for i := range users { + users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID) } - return staff, nil + return users, nil } -func (app *App) createUser(email, preferredName, hash string, roles []string, deptIDs []int) (*User, error) { - // Find or create participant by email. - p, err := app.getParticipantByEmail(email) - if err != nil { - return nil, err - } - if p != nil { - // Participant exists — promote to staff. - if _, err := app.db.Exec( - `UPDATE participants SET password_hash = ?, login_enabled = 1, updated_at = ? WHERE id = ?`, - hash, now(), p.ID, - ); err != nil { - return nil, err - } - if err := app.setParticipantRoles(p.ID, roles); err != nil { - return nil, err - } - if err := app.setUserDeptIDs(p.ID, deptIDs); err != nil { - return nil, err - } - return app.getUser(p.ID) - } - // Create new participant with auth. +func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at) - VALUES (?, ?, ?, 1, ?)`, - strings.ToLower(email), preferredName, hash, now(), + `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, + username, hash, role, ) if err != nil { return nil, err } id, _ := res.LastInsertId() - if err := app.setParticipantRoles(int(id), roles); err != nil { - return nil, err - } if err := app.setUserDeptIDs(int(id), deptIDs); err != nil { return nil, err } - return app.getUser(int(id)) + return app.getUserByID(int(id)) } -func (app *App) updateUserRoles(id int, roles []string, deptIDs []int) error { - var enabled int - err := app.db.QueryRow(`SELECT login_enabled FROM participants WHERE id = ? AND deleted_at IS NULL`, id).Scan(&enabled) - if err != nil || enabled != 1 { - return fmt.Errorf("participant not found or not a staff member") - } - if err := app.setParticipantRoles(id, roles); err != nil { +func (app *App) updateUser(id int, role string, deptIDs []int) error { + if _, err := app.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id); err != nil { return err } return app.setUserDeptIDs(id, deptIDs) } func (app *App) updateUserPassword(id int, hash string) error { - _, err := app.db.Exec( - `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id, - ) + _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) return err } -func (app *App) removeUser(id int) error { - tx, err := app.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, id); err != nil { - return err - } - if _, err := tx.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, id); err != nil { - return err - } - if _, err := tx.Exec( - `UPDATE participants SET login_enabled = 0, password_hash = NULL, updated_at = ? WHERE id = ?`, now(), id, - ); err != nil { - return err - } - return tx.Commit() +func (app *App) deleteUser(id int) error { + _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, id) + return err } func (app *App) countUsers() (int, error) { var n int - err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n) + err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n, err } @@ -570,6 +520,174 @@ func (app *App) generateCodesForAll() (int, error) { return count, nil } +// incrementPartySize is kept for backward compatibility with existing tests. +func (app *App) incrementPartySize(name, ticketID string) (bool, error) { + res, err := app.db.Exec( + `UPDATE attendees SET party_size = party_size + 1, updated_at = ? + WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`, + now(), name, ticketID, + ) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// --- Attendees --- + +func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) { + q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL` + var args []any + if search != "" { + q += ` AND (name LIKE ? OR email LIKE ? OR ticket_id LIKE ?)` + s := "%" + search + "%" + args = append(args, s, s, s) + } + if ticketType != "" { + q += ` AND ticket_type = ?` + args = append(args, ticketType) + } + if checkedIn == "true" { + q += ` AND checked_in = 1` + } else if checkedIn == "false" { + q += ` AND checked_in = 0` + } + q += ` ORDER BY name ASC` + return queryAttendees(app.db, q, args...) +} + +func (app *App) getAttendee(id int) (*Attendee, error) { + rows, err := queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createAttendee(a Attendee) (*Attendee, error) { + res, err := app.db.Exec( + `INSERT INTO attendees (name, email, phone, ticket_id, ticket_type, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getAttendee(int(id)) +} + +func (app *App) updateAttendee(a Attendee) error { + _, err := app.db.Exec( + `UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=? + WHERE id = ? AND deleted_at IS NULL`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID, + ) + return err +} + +func (app *App) deleteAttendee(id int) error { + _, err := app.db.Exec( + `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id, + ) + return err +} + +// checkInAttendee increments checked_in_count by count (capped at party_size). +// Sets checked_in and checked_in_at on the first check-in. +func (app *App) checkInAttendee(id, userID, count int) (*Attendee, error) { + if count < 1 { + count = 1 + } + a, err := app.getAttendee(id) + if err != nil || a == nil { + return nil, err + } + remaining := a.PartySize - a.CheckedInCount + if count > remaining { + count = remaining + } + if count <= 0 { + return a, nil + } + t := now() + _, err = app.db.Exec(` + UPDATE attendees SET + checked_in_count = checked_in_count + ?, + checked_in = CASE WHEN checked_in = 0 THEN 1 ELSE checked_in END, + checked_in_at = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_at END, + checked_in_by = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_by END, + updated_at = ? + WHERE id = ? AND deleted_at IS NULL`, + count, t, userID, t, id, + ) + if err != nil { + return nil, err + } + return app.getAttendee(id) +} + +func (app *App) attendeesSince(since string) ([]Attendee, error) { + return queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE updated_at > ? ORDER BY updated_at ASC`, since) +} + +func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Attendee + for rows.Next() { + var a Attendee + var checkedIn int + var token sql.NullString + if err := rows.Scan( + &a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType, + &token, &a.PartySize, &checkedIn, &a.CheckedInCount, + &a.CheckedInAt, &a.CheckedInBy, &a.Note, + &a.CreatedAt, &a.UpdatedAt, &a.DeletedAt, + ); err != nil { + return nil, err + } + if token.Valid && token.String != "" { + a.VolunteerToken = &token.String + } + a.CheckedIn = checkedIn == 1 + if a.PartySize < 1 { + a.PartySize = 1 + } + result = append(result, a) + } + return result, rows.Err() +} + +func (app *App) attendeeTicketTypes() ([]string, error) { + rows, err := app.db.Query( + `SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var types []string + for rows.Next() { + var t string + rows.Scan(&t) + types = append(types, t) + } + return types, rows.Err() +} + +func (app *App) attendeeCounts() (total, checkedIn int, err error) { + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE deleted_at IS NULL`).Scan(&total) + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE checked_in=1 AND deleted_at IS NULL`).Scan(&checkedIn) + return +} + // --- Participants --- const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` @@ -653,8 +771,6 @@ 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, ) @@ -735,6 +851,15 @@ func (app *App) getTicket(id int) (*Ticket, error) { return &rows[0], nil } +func (app *App) getTicketByCode(code string) (*Ticket, error) { + rows, err := queryTickets(app.db, + `SELECT `+ticketCols+` FROM tickets WHERE code = ? AND deleted_at IS NULL`, code) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + func (app *App) createTicket(t Ticket) (*Ticket, error) { res, err := app.db.Exec( `INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at) @@ -771,6 +896,16 @@ func (app *App) deleteTicket(id int) error { return err } +func (app *App) ticketsSince(since string) ([]Ticket, error) { + return queryTickets(app.db, + `SELECT `+ticketCols+` FROM tickets WHERE updated_at > ? ORDER BY updated_at ASC`, since) +} + +func (app *App) participantsSince(since string) ([]Participant, error) { + return queryParticipants(app.db, + `SELECT `+participantCols+` FROM participants WHERE updated_at > ? ORDER BY updated_at ASC`, since) +} + func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) { rows, err := db.Query(q, args...) if err != nil { @@ -903,7 +1038,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, deptIDs []int, since string) ([]Volunteer, error) { +func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` var args []any if since != "" { @@ -917,14 +1052,9 @@ func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Vo s := "%" + search + "%" args = append(args, s, s) } - if len(deptIDs) == 1 { + if deptID != nil { q += ` AND v.department_id = ?` - 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) - } + args = append(args, *deptID) } q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) @@ -939,6 +1069,15 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { return &rows[0], nil } +func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) @@ -1115,7 +1254,7 @@ func generateConfirmationToken() (string, error) { // --- Shifts --- -func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { +func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` var args []any if since != "" { @@ -1124,14 +1263,9 @@ func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { } else { q += ` AND deleted_at IS NULL` } - if len(deptIDs) == 1 { + if deptID != nil { q += ` AND department_id = ?` - 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) - } + args = append(args, *deptID) } if day != "" { q += ` AND day = ?` @@ -1355,27 +1489,6 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) { ORDER BY s.day, s.position, s.start_time`, deptID) } -// --- SSO Nonces --- - -func (app *App) createSSONonce(nonce string) error { - _, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce) - return err -} - -func (app *App) consumeSSONonce(nonce string) (bool, error) { - res, err := app.db.Exec( - `DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} - -func (app *App) cleanExpiredNonces() { - app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`) -} - // --- Helpers --- func now() string { @@ -1388,10 +1501,3 @@ func boolInt(b bool) int { } return 0 } - -func placeholders(n int) string { - if n <= 0 { - return "" - } - return strings.Repeat("?,", n-1) + "?" -} diff --git a/db_test.go b/db_test.go index 5755d08..c9e8b68 100644 --- a/db_test.go +++ b/db_test.go @@ -7,7 +7,7 @@ import ( func TestMigrate(t *testing.T) { app := testApp(t) // Verify tables exist by querying each one - tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"} + tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) @@ -17,6 +17,98 @@ 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) { token, err := generateToken() if err != nil { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ac0957e..7ad6bc9 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,6 +1,6 @@ {#if updateAvailable} @@ -129,9 +102,9 @@ {:else if isConfirmEmail} {:else if !session} - -{:else if roles.length === 1 && roles[0] === 'gatekeeper'} - + +{:else if role === 'gatekeeper'} + {:else}
@@ -148,10 +121,10 @@ Turnpike {#if path === '/' || path === ''} - {#if roles.length === 1 && roles[0] === 'colead'} + {#if role === 'colead'} {:else} - + {/if} {:else if path.startsWith('/participants')} diff --git a/frontend/src/api.js b/frontend/src/api.js index d15abc4..686faa8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,4 +1,4 @@ -import { db, clearSession } from './db.js' +import { db } from './db.js' async function getToken() { const session = await db.session.get(1) @@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) { const res = await fetch(path, { ...options, headers }) if (res.status === 401) { - await clearSession() + await db.session.clear() window.location.pathname = '/login' throw new Error('unauthorized') } @@ -48,8 +48,8 @@ async function kioskFetch(path, options = {}) { } export const api = { - login: (email, password) => - apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }), + login: (username, password) => + apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), logout: () => apiFetch('/api/logout', { method: 'POST' }), me: () => apiJSON('/api/me'), event: { @@ -118,10 +118,6 @@ export const api = { resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }), resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }), }, - sso: { - enabled: () => kioskFetch('/api/public/sso-enabled'), - init: () => kioskFetch('/api/sso/init'), - }, signup: { config: () => kioskFetch('/api/public/signup-config'), submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }), diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js index a725f32..974dd32 100644 --- a/frontend/src/api.test.js +++ b/frontend/src/api.test.js @@ -64,11 +64,11 @@ describe('apiJSON', () => { describe('api methods', () => { it('login calls correct endpoint', async () => { const f = mockFetch({ token: 'tok', user: { id: 1 } }) - await api.login('admin@example.com', 'pass') + await api.login('admin', 'pass') const [url, opts] = f.mock.calls[0] expect(url).toBe('/api/login') expect(opts.method).toBe('POST') - expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' }) + expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' }) }) it('participants.list calls correct endpoint', async () => { diff --git a/frontend/src/app.css b/frontend/src/app.css index 3a685ae..0cd0ee8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -66,9 +66,6 @@ a:hover { color: var(--c-accent-h); } /* Cards */ .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 { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } @@ -106,15 +103,8 @@ input, select, textarea { width: 100%; font-family: var(--font); 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::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-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } @@ -139,7 +129,6 @@ tr:hover td { background: rgba(255,255,255,0.02); } font-size: 0.72rem; font-weight: 600; 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-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } @@ -245,7 +234,6 @@ tr:hover td { background: rgba(255,255,255,0.02); } td { display: inline; padding: 0; border: none; } td:empty { display: none; } - /* Forms — 16px prevents iOS auto-zoom on focus */ - input, select, textarea { font-size: 16px; } - .form-grid, .form-grid-3 { grid-template-columns: 1fr !important; } + /* Forms */ + .form-grid { grid-template-columns: 1fr !important; } } diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index 61015f5..545e171 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -3,18 +3,17 @@ let { session, active, onLogout, navigate, open = false } = $props() - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const role = $derived(session?.user?.role ?? '') const iconProps = { size: 18, strokeWidth: 1.75 } const links = $derived.by(() => { - if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [ + if (role === 'colead') return [ { href: '/', label: 'Schedule', icon: CalendarDays }, { href: '/volunteers', label: 'Volunteers', icon: Heart }, { href: '/departments', label: 'Departments', icon: Hexagon }, ] - if (!hasRole('admin') && hasRole('staffing')) return [ + if (role === 'staffing') return [ { href: '/', label: 'Dashboard', icon: LayoutDashboard }, { href: '/schedule', label: 'Schedule', icon: CalendarDays }, { href: '/volunteers', label: 'Volunteers', icon: Heart }, diff --git a/frontend/src/db.js b/frontend/src/db.js index bd3b490..7e96bbc 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -51,17 +51,6 @@ db.version(5).stores({ 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() { const m = await db.meta.get('last_sync') return m?.value ?? '' @@ -80,18 +69,6 @@ export async function saveSession(token, user) { } 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.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.session.clear() + await db.meta.clear() } diff --git a/frontend/src/db.test.js b/frontend/src/db.test.js index 081ce1a..282b6fc 100644 --- a/frontend/src/db.test.js +++ b/frontend/src/db.test.js @@ -22,10 +22,10 @@ describe('session', () => { }) it('saves and retrieves session', async () => { - await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] }) + await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' }) const s = await getSession() expect(s.token).toBe('tok123') - expect(s.user.email).toBe('admin@example.com') + expect(s.user.username).toBe('admin') }) it('clears session and meta', async () => { diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 73800d6..e5a26de 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -2,14 +2,13 @@ import { liveQuery } from 'dexie' import { db } from '../db.js' - let { session, navigate } = $props() + let { session } = $props() - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const role = $derived(session?.user?.role ?? '') const myDeptIDs = $derived(session?.user?.department_ids ?? []) - const isAdmin = $derived(hasRole('admin')) - const isStaffing = $derived(hasRole('admin', 'staffing')) - const isColead = $derived(hasRole('colead')) + const isTicketing = $derived(['admin', 'ticketing'].includes(role)) + const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role)) + const isColead = $derived(role === 'colead') const event = liveQuery(() => db.event.get(1)) const allTickets = liveQuery(() => db.tickets.toArray()) @@ -77,8 +76,8 @@

{/if} - - {#if isAdmin} + + {#if isTicketing}

Ticket Check-in

@@ -106,7 +105,7 @@ {/if} {/if} - + {#if isStaffing || isColead}

{isColead ? 'My Volunteers' : 'Volunteers'}

@@ -125,7 +124,7 @@
{/if} - + {#if isStaffing || isColead}

{isColead ? 'My Shifts' : 'Shift Coverage'}

@@ -145,22 +144,22 @@ {/if} - {#if isAdmin} + {#if isTicketing} {:else if isStaffing || isColead} {/if}

- Welcome, {session?.user?.preferred_name} - · {#each roles as r}{r}{/each} + Welcome, {session?.user?.username} + · {session?.user?.role}

diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index 863c4a1..c2cf82b 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -18,10 +18,9 @@ let editDesc = $state('') let saving = $state(false) - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const canCreate = $derived(hasRole('admin', 'staffing')) - const canDelete = $derived(hasRole('admin')) + const role = $derived(session?.user?.role ?? '') + const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role)) + const canDelete = $derived(['admin', 'ticketing'].includes(role)) const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() @@ -101,7 +100,7 @@ {#if showAdd && canCreate}
-
+
@@ -112,7 +111,7 @@
- +
@@ -191,7 +190,6 @@
diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 0be1485..2867958 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -19,7 +19,6 @@ let showAdd = $state(false) let adding = $state(false) let newName = $state('') - let newTicketedName = $state('') let newEmail = $state('') let newPhone = $state('') let newPronouns = $state('') @@ -28,7 +27,6 @@ // Edit participant let editId = $state(null) let editName = $state('') - let editTicketedName = $state('') let editEmail = $state('') let editPhone = $state('') let editPronouns = $state('') @@ -42,9 +40,8 @@ let newTicketType = $state('') let newTicketExtId = $state('') - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const canManage = $derived(hasRole('admin')) + const role = $derived(session?.user?.role ?? '') + const canManage = $derived(['admin', 'ticketing'].includes(role)) const allParticipants = liveQuery(() => db.participants.toArray()) const allTickets = liveQuery(() => db.tickets.toArray()) @@ -153,12 +150,12 @@ adding = true; error = '' try { const p = await api.participants.create({ - preferred_name: newName, ticket_name: newTicketedName, email: newEmail, - phone: newPhone, pronouns: newPronouns, note: newNote, + preferred_name: newName, email: newEmail, phone: newPhone, + pronouns: newPronouns, note: newNote, }) await db.participants.put(p) showAdd = false - newName = newTicketedName = newEmail = newPhone = newPronouns = newNote = '' + newName = newEmail = newPhone = newPronouns = newNote = '' } catch (err) { error = err.message } finally { @@ -169,7 +166,6 @@ function startEdit(p) { editId = p.id editName = p.preferred_name - editTicketedName = p.ticket_name || '' editEmail = p.email editPhone = p.phone editPronouns = p.pronouns @@ -181,8 +177,8 @@ saving = true; error = '' try { const p = await api.participants.update(editId, { - preferred_name: editName, ticket_name: editTicketedName, email: editEmail, - phone: editPhone, pronouns: editPronouns, note: editNote, + preferred_name: editName, email: editEmail, phone: editPhone, + pronouns: editPronouns, note: editNote, }) await db.participants.put(p) editId = null @@ -248,15 +244,11 @@ {#if showAdd && canManage}
-
+
- +
-
- - -
@@ -331,7 +323,7 @@ - + @@ -351,7 +343,6 @@
- diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 529264f..6755588 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -25,9 +25,8 @@ let assignVolID = $state(0) let assigning = $state(false) - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const canManage = $derived(hasRole('admin', 'staffing', 'colead')) + const role = $derived(session?.user?.role ?? '') + const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) const allDepts = liveQuery(() => @@ -55,7 +54,7 @@ // Departments visible to this user const visibleDepts = $derived.by(() => { const depts = $allDepts ?? [] - if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id)) + if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id)) return depts }) @@ -135,13 +134,11 @@ try { const res = await api.shifts.reorder(positions) - 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 }) - } - }) + 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 }) + } } catch (err) { error = err.message } @@ -275,7 +272,7 @@ {#if showAdd && canManage}
-
+
- - {#each ($allVolunteers ?? []) - .filter(v => v.department_id === shift.department_id) - .filter(v => !assigned.some(a => a.volunteer.id === v.id)) - as v} - - {/each} - - - - -
- {:else} - - {/if} + {#if assigningShiftID === shift.id} +
+ + + + +
+ {:else} + {/if} {/if}
diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 9df6b81..2f6ee8e 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -7,7 +7,6 @@ let saving = $state(false) let savingEvent = $state(false) let testing = $state(false) - let resetting = $state(false) let error = $state('') let success = $state('') @@ -27,8 +26,6 @@ let eventEndDate = $state('') let eventTimezone = $state('') const timezones = Intl.supportedValuesOf('timeZone') - let discourseSSOUrl = $state('') - let discourseSSOSecret = $state('') let shiftSignupsOpen = $state(false) let togglingSignups = $state(false) @@ -52,8 +49,6 @@ baseURL = s.base_url ?? '' noteLabel = s.volunteer_note_label ?? 'Additional note' noteRequired = s.volunteer_note_required ?? false - discourseSSOUrl = s.discourse_sso_url ?? '' - discourseSSOSecret = '' shiftSignupsOpen = s.shift_signups_open ?? false } catch (err) { error = err.message @@ -94,17 +89,14 @@ smtp_host: smtpHost, smtp_port: smtpPort, smtp_user: smtpUser, - smtp_password: smtpPassword, + smtp_password: smtpPassword, // empty = keep existing smtp_from: smtpFrom, smtp_from_name: smtpFromName, base_url: baseURL, volunteer_note_label: noteLabel, volunteer_note_required: noteRequired, - discourse_sso_url: discourseSSOUrl, - discourse_sso_secret: discourseSSOSecret, }) smtpPassword = '' - discourseSSOSecret = '' success = 'Settings saved.' } catch (err) { error = err.message @@ -131,9 +123,7 @@ } async function resetModel(label, fn) { - if (resetting) return if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return - resetting = true error = '' success = '' try { @@ -141,8 +131,6 @@ success = `Deleted ${result.deleted} ${label}.` } catch (err) { error = err.message - } finally { - resetting = false } } @@ -178,14 +166,14 @@
Loading…
{:else} -
-

Event

-
-
+
+

Event

+
+
-
+
@@ -197,7 +185,7 @@
-
+
@@ -216,11 +204,11 @@
-
-

SMTP Email

+
+

SMTP Email

-
-
+
+
@@ -248,27 +236,10 @@
- +
-

Discourse SSO

-

- Enable DiscourseConnect SSO so users can log in with their Discourse account. - Set the same secret in your Discourse admin under Connect > discourse connect secret. -

-
-
- - -
-
- - -
-
-
{#if !shiftSignupsOpen} -

+

Opening signups will email all confirmed volunteers their shift signup links.

{/if} @@ -332,24 +303,24 @@
-

Data Management

-

+

Data Management

+

Permanently delete all records of a given type. This cannot be undone.

- - - - -
diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index f49c8c6..ee6b18a 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -12,14 +12,13 @@ let showAdd = $state(false) let adding = $state(false) - let newEmail = $state('') - let newName = $state('') + let newUsername = $state('') let newPassword = $state('') - let newRoles = $state([]) + let newRole = $state('gate') let newDeptIDs = $state([]) let editID = $state(null) - let editRoles = $state([]) + let editRole = $state('') let editDeptIDs = $state([]) let editPassword = $state('') let saving = $state(false) @@ -29,7 +28,7 @@ .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) ) - const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper'] + const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper'] const me = $derived(session?.user?.id) @@ -52,16 +51,15 @@ error = '' try { const u = await api.users.create({ - email: newEmail, - preferred_name: newName, + username: newUsername, password: newPassword, - roles: newRoles, + role: newRole, department_ids: newDeptIDs, }) users = [...users, u] showAdd = false - newEmail = newName = newPassword = '' - newRoles = [] + newUsername = newPassword = '' + newRole = 'gate' newDeptIDs = [] } catch (err) { error = err.message @@ -72,7 +70,7 @@ function startEdit(u) { editID = u.id - editRoles = [...(u.roles || [])] + editRole = u.role editDeptIDs = [...(u.department_ids || [])] editPassword = '' } @@ -85,7 +83,7 @@ saving = true error = '' try { - const payload = { roles: editRoles, department_ids: editDeptIDs } + const payload = { role: editRole, department_ids: editDeptIDs } if (editPassword) payload.password = editPassword const updated = await api.users.update(u.id, payload) users = users.map(x => x.id === u.id ? updated : x) @@ -98,7 +96,7 @@ } async function deleteUser(u) { - if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return + if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return try { await api.users.delete(u.id) users = users.filter(x => x.id !== u.id) @@ -107,7 +105,7 @@ } } - function toggleItem(id, list) { + function toggleDept(id, list) { const idx = list.indexOf(id) if (idx === -1) return [...list, id] return list.filter(x => x !== id) @@ -119,7 +117,7 @@ } function roleLabel(r) { - return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r + return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r } @@ -134,6 +132,7 @@

Roles: admin — full access · + ticketing — participants, tickets, import · staffing — volunteers, shifts, departments · colead — manage assigned departments only · gatekeeper — check-in only @@ -149,31 +148,22 @@ {#if showAdd}

-
+
- - -
-
- - + +
-
-
- Roles -
- {#each availableRoles as r} - - {/each} +
+ +
{#if ($allDepts ?? []).length > 0} @@ -181,10 +171,10 @@ Departments
{#each $allDepts ?? [] as d} - @@ -214,8 +204,8 @@
Preferred NameName Email Tickets Status
- - + + @@ -224,27 +214,22 @@ {#each users as u (u.id)} {#if editID === u.id} - + - + diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 8f5abe5..e90bf80 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -24,17 +24,15 @@ let editIsLead = $state(false) let editNote = $state('') let saving = $state(false) - let confirmingID = $state(null) - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const canManage = $derived(hasRole('admin', 'staffing', 'colead')) - const canConfirm = $derived(hasRole('admin', 'staffing', 'colead')) + const role = $derived(session?.user?.role ?? '') + const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) let deptInitialized = $state(false) $effect(() => { - if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) { + if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) { filterDept = String(myDeptIDs[0]) deptInitialized = true } @@ -77,15 +75,11 @@ } async function confirmVolunteer(v) { - if (confirmingID) return - confirmingID = v.id try { const updated = await api.volunteers.confirm(v.id) await db.volunteers.put(updated) } catch (err) { error = err.message - } finally { - confirmingID = null } } @@ -186,7 +180,7 @@ {#if showAdd && canManage}
-
+
@@ -214,8 +208,8 @@
-
@@ -261,7 +255,7 @@
Preferred NameRolesUsernameRole Departments
{u.preferred_name || u.email} {#if u.id === me}you{/if}{u.username} {#if u.id === me}you{/if} -
- {#each availableRoles as r} - +
{#if ($allDepts ?? []).length > 0}
{#each $allDepts ?? [] as d} - {/each} @@ -266,19 +251,18 @@ {:else}
- {u.preferred_name || u.email} + {u.username} {#if u.id === me} - you + you {/if} -
{u.email}
{#each u.roles ?? [] as r}{roleLabel(r)}{/each}{roleLabel(u.role)} {deptNamesFor(u.department_ids || [])}
{#if u.id !== me} - + {/if}
- + @@ -286,8 +280,8 @@ {:else} - {@const participant = participantFor(v.participant_id)}
Preferred NameName Department Status - @@ -301,20 +295,16 @@
{v.name} {#if v.is_lead} - Co-Lead + Co-Lead {/if} {#if !v.participant_id} - No ticket + No ticket {:else if !participantHasTickets(v.participant_id)} - No ticket - {/if} - {#if participant?.ticket_name && participant.ticket_name !== v.name} -
Ticket: {participant.ticket_name}
+ No ticket {/if} {#if v.email}
{v.email}
@@ -354,7 +344,7 @@ {#if canManage}
{#if canConfirm && v.email_confirmed && !v.confirmed} - + {/if} diff --git a/frontend/src/sync.js b/frontend/src/sync.js index ef22313..fa3b641 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -4,36 +4,10 @@ import { api } from './api.js' let syncing = false 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() { if (syncing) return syncing = true try { - await checkBuildChanged() const since = await getLastSync() const data = await api.sync.pull(since) @@ -77,7 +51,7 @@ export async function syncPull() { } ) - if (data.server_time) await setLastSync(data.server_time) + await setLastSync(data.server_time) return true } catch (err) { console.warn('Sync pull failed:', err.message) @@ -123,7 +97,7 @@ export function startSSE(onEvent) { syncPull() }, 5000) } - }).catch(() => {}) + }) } connect() @@ -134,23 +108,18 @@ export function stopSSE() { sseSource = null } +// Poll for sync when online, with exponential backoff on failure let syncInterval = null -let onlineHandler = null export function startSyncLoop(intervalMs = 30000) { if (syncInterval) return syncInterval = setInterval(() => { if (navigator.onLine) syncPull() }, intervalMs) - onlineHandler = () => syncPull() - window.addEventListener('online', onlineHandler) + window.addEventListener('online', () => syncPull()) } export function stopSyncLoop() { clearInterval(syncInterval) syncInterval = null - if (onlineHandler) { - window.removeEventListener('online', onlineHandler) - onlineHandler = null - } } diff --git a/handle_attendees_test.go b/handle_attendees_test.go index 7dd7ff8..c5e6adb 100644 --- a/handle_attendees_test.go +++ b/handle_attendees_test.go @@ -31,8 +31,8 @@ func TestParticipantsListCreateDelete(t *testing.T) { } list := parseJSON(t, w) participants := list["participants"].([]any) - if len(participants) != 2 { // admin + Titania - t.Errorf("list: got %d, want 2", len(participants)) + if len(participants) != 1 { + t.Errorf("list: got %d, want 1", len(participants)) } // Delete @@ -48,8 +48,8 @@ func TestParticipantsListCreateDelete(t *testing.T) { w = httptest.NewRecorder() mux.ServeHTTP(w, req) list = parseJSON(t, w) - if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains - t.Errorf("after delete: got %d, want 1", len(ps)) + if ps, ok := list["participants"].([]any); ok && len(ps) != 0 { + t.Errorf("after delete: got %d, want 0", len(ps)) } } @@ -77,7 +77,7 @@ func TestCheckInTicketHandler(t *testing.T) { func TestGatekeeperRoleCanCheckIn(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) token := testToken(t, app, gate) mux := testMux(app) @@ -94,7 +94,7 @@ func TestGatekeeperRoleCanCheckIn(t *testing.T) { func TestGatekeeperRoleCannotDelete(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_auth.go b/handle_auth.go index d75483b..282bd85 100644 --- a/handle_auth.go +++ b/handle_auth.go @@ -7,7 +7,7 @@ import ( func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { var body struct { - Email string `json:"email"` + Username string `json:"username"` Password string `json:"password"` } 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 } - user, hash, err := app.getLoginParticipant(body.Email) + user, hash, err := app.getUserByUsername(body.Username) if err != nil { writeError(w, "internal error", http.StatusInternalServerError) 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) { claims := claimsFromContext(r) - user, err := app.getUser(claims.ParticipantID) + user, err := app.getUserByID(claims.UserID) if err != nil || user == nil { - writeError(w, "unauthorized", http.StatusUnauthorized) + writeError(w, "not found", http.StatusNotFound) return } writeJSON(w, user) diff --git a/handle_participants.go b/handle_participants.go index 52624d5..6277824 100644 --- a/handle_participants.go +++ b/handle_participants.go @@ -169,7 +169,7 @@ func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - tk, err := app.checkInTicket(id, claims.ParticipantID) + tk, err := app.checkInTicket(id, claims.UserID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_settings.go b/handle_settings.go index 5da8084..d4ed01c 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -27,14 +27,6 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) { 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{ "smtp_host": cfg.Host, "smtp_port": cfg.Port, @@ -46,8 +38,6 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) { "volunteer_note_label": noteLabel, "volunteer_note_required": noteRequired == "true", "shift_signups_open": signupsOpen == "true", - "discourse_sso_url": ssoURL, - "discourse_sso_secret": maskedSSOSecret, }) } @@ -59,7 +49,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", - "volunteer_note_label", "volunteer_note_required", "discourse_sso_url", "discourse_sso_secret"} + "volunteer_note_label", "volunteer_note_required"} for _, k := range keys { v, ok := body[k] if !ok { @@ -68,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" || k == "discourse_sso_secret") && (vv == "" || vv == "***") { + if k == "smtp_password" && vv == "" { continue } val = vv diff --git a/handle_settings_test.go b/handle_settings_test.go index 16ef59f..cbc53fb 100644 --- a/handle_settings_test.go +++ b/handle_settings_test.go @@ -85,7 +85,7 @@ func TestResetTickets(t *testing.T) { func TestResetTicketsRequiresAdmin(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Snug", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{}) token := testToken(t, app, gate) mux := testMux(app) @@ -131,7 +131,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) { func TestSettingsNonAdminRejected(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_shifts.go b/handle_shifts.go index 9299916..a0ceb41 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -8,19 +8,20 @@ import ( func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - var deptIDs []int + var deptID *int if d := q.Get("dept"); d != "" { - if id, err := strconv.Atoi(d); err == nil { - deptIDs = []int{id} + id, err := strconv.Atoi(d) + if err == nil { + deptID = &id } } claims := claimsFromContext(r) - if isCoLeadOnly(claims) && len(deptIDs) == 0 { - deptIDs = claims.DeptIDs + if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + deptID = &claims.DeptIDs[0] } - shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since")) + shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since")) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -39,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) { + if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -64,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if isCoLeadOnly(claims) { + if claims.Role == "colead" { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -86,14 +87,6 @@ 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 @@ -118,14 +111,6 @@ 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) @@ -164,14 +149,6 @@ 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 @@ -190,16 +167,6 @@ 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} diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 19c49bf..940164e 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -104,86 +104,6 @@ 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) diff --git a/handle_sso.go b/handle_sso.go deleted file mode 100644 index e97c887..0000000 --- a/handle_sso.go +++ /dev/null @@ -1,190 +0,0 @@ -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) -} diff --git a/handle_sync_test.go b/handle_sync_test.go index 000bc60..e4aa2af 100644 --- a/handle_sync_test.go +++ b/handle_sync_test.go @@ -13,10 +13,10 @@ func TestSyncPullFull(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) 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) @@ -32,8 +32,8 @@ func TestSyncPullFull(t *testing.T) { t.Error("missing server_time") } participants := result["participants"].([]any) - if len(participants) != 2 { // admin + Titania - t.Errorf("participants = %d, want 2", len(participants)) + if len(participants) != 1 { + t.Errorf("participants = %d, want 1", len(participants)) } depts := result["departments"].([]any) if len(depts) != 1 { @@ -47,16 +47,14 @@ func TestSyncPullIncremental(t *testing.T) { token := testToken(t, app, admin) 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"}) + // 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) since := "2026-01-01T12:00:00Z" - // Lysander created with default updated_at (now), which is after our since - app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"}) + // Oberon created with default updated_at (now), which is after our since + app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) w := httptest.NewRecorder() @@ -64,13 +62,14 @@ func TestSyncPullIncremental(t *testing.T) { result := parseJSON(t, w) participants := result["participants"].([]any) + // Should only include Oberon (created after `since`) if len(participants) != 1 { t.Errorf("incremental: got %d participants, want 1", len(participants)) } if len(participants) == 1 { p := participants[0].(map[string]any) - if p["preferred_name"] != "Lysander" { - t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"]) + if p["preferred_name"] != "Oberon" { + t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"]) } } } @@ -81,10 +80,8 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) { token := testToken(t, app, admin) 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"}) + // 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) since := "2026-01-01T12:00:00Z" diff --git a/handle_users.go b/handle_users.go index 4de6109..386e5b0 100644 --- a/handle_users.go +++ b/handle_users.go @@ -17,18 +17,17 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { var body struct { - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - Password string `json:"password"` - Roles []string `json:"roles"` - DepartmentIDs []int `json:"department_ids"` + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + DepartmentIDs []int `json:"department_ids"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - if body.Email == "" || body.Password == "" || len(body.Roles) == 0 { - writeError(w, "email, password, and at least one role are required", http.StatusBadRequest) + if body.Username == "" || body.Password == "" || body.Role == "" { + writeError(w, "username, password, and role are required", http.StatusBadRequest) return } hash, err := hashPassword(body.Password) @@ -39,7 +38,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs) + user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -54,15 +53,10 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - target, _ := app.getUser(id) - if target == nil { - writeError(w, "not found", http.StatusNotFound) - return - } var body struct { - Roles []string `json:"roles"` - Password string `json:"password"` - DepartmentIDs []int `json:"department_ids"` + Role string `json:"role"` + Password string `json:"password"` + DepartmentIDs []int `json:"department_ids"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) @@ -71,8 +65,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - if body.Roles != nil { - if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil { + if body.Role != "" { + if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -88,7 +82,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } } - user, _ := app.getUser(id) + user, _ := app.getUserByID(id) writeJSON(w, user) } @@ -99,11 +93,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.ParticipantID == id { + if claims.UserID == id { writeError(w, "cannot delete yourself", http.StatusBadRequest) return } - if err := app.removeUser(id); err != nil { + if err := app.deleteUser(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } diff --git a/handle_volunteers.go b/handle_volunteers.go index cd891d2..5d086ad 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -12,19 +12,20 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { search := q.Get("search") since := q.Get("since") - var deptIDs []int + var deptID *int if d := q.Get("dept"); d != "" { - if id, err := strconv.Atoi(d); err == nil { - deptIDs = []int{id} + id, err := strconv.Atoi(d) + if err == nil { + deptID = &id } } claims := claimsFromContext(r) - if isCoLeadOnly(claims) && len(deptIDs) == 0 { - deptIDs = claims.DeptIDs + if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + deptID = &claims.DeptIDs[0] } - volunteers, err := app.listVolunteers(search, deptIDs, since) + volunteers, err := app.listVolunteers(search, deptID, since) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -54,7 +55,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if isCoLeadOnly(claims) { + if claims.Role == "colead" { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return @@ -126,16 +127,12 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if isCoLeadOnly(claims) { + if claims.Role == "colead" { 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, @@ -160,14 +157,6 @@ 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 @@ -182,14 +171,7 @@ 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) + v, err := app.markVolunteerReady(id, claims.UserID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -204,14 +186,6 @@ 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) @@ -233,24 +207,7 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "shift_id required", 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 - } - } - 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 - } + if err := app.assignShift(volunteerID, body.ShiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -268,14 +225,6 @@ 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 @@ -283,3 +232,11 @@ 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 +} diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index dc61f28..ab51b9b 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -65,9 +65,9 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { app := testApp(t) mux := testMux(app) - // Gatekeeper role should NOT be able to confirm volunteers. - gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{}) - tok := testToken(t, app, gatekeeper) + // Ticketing role should NOT be able to confirm volunteers. + ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) + tok := testToken(t, app, ticketing) p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) @@ -75,131 +75,7 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { 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 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()) + t.Errorf("expected 403 for ticketing role, got %d", w.Code) } } diff --git a/main.go b/main.go index 186362c..be9fc53 100644 --- a/main.go +++ b/main.go @@ -97,62 +97,62 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/me", auth(app.handleMe)) mux.HandleFunc("GET /api/event", auth(app.handleGetEvent)) - mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin")) + mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing")) - mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper")) - mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin")) - mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin")) - mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper")) - mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin")) - mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin")) - mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin")) + mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper")) + mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing")) + mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing")) + mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gatekeeper")) + mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing")) + mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing")) + mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing")) - mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper")) - mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin")) - mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper")) - mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin")) - mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin")) - mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin")) - mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin")) + mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper")) + mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing")) + mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper")) + mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing")) + mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing")) + mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing")) + mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing")) mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) - mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing")) - mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing")) - mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin")) + mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing")) + mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing")) + mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing")) - mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "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", "staffing", "colead")) + mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "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", "staffing", "colead")) - mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead")) - mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "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", "staffing", "colead")) - mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin")) - mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin")) - mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin")) - mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin")) + mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin", "ticketing")) + mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing")) + mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing")) + mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing")) - mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin")) - mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin")) - mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin")) - mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin")) - mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin")) - mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin")) - mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin")) - mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin")) + mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing")) + mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing")) + mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing")) + mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing")) + mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing")) + mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing")) + mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing")) + mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing")) - mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin")) + mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing")) mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) @@ -161,12 +161,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) { writeJSON(w, map[string]string{"build": buildID}) }) - mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing")) + mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "ticketing", "staffing")) // 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("POST /api/public/signup", app.handlePublicSignup) mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail) @@ -199,9 +196,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) { } func (app *App) bootstrapAdmin() error { - adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL") + adminUser := os.Getenv("TURNPIKE_ADMIN_USER") adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD") - if adminEmail == "" || adminPass == "" { + if adminUser == "" || adminPass == "" { return nil } n, err := app.countUsers() @@ -212,11 +209,11 @@ func (app *App) bootstrapAdmin() error { if err != nil { return err } - _, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{}) + _, err = app.createUser(adminUser, hash, "admin", []int{}) if err != nil { return err } - log.Printf("Created admin user: %s", adminEmail) + log.Printf("Created admin user: %s", adminUser) return nil } diff --git a/testutil_test.go b/testutil_test.go index 14351e5..8f58833 100644 --- a/testutil_test.go +++ b/testutil_test.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" "testing" ) @@ -17,6 +16,7 @@ func testApp(t *testing.T) *App { t.Fatal(err) } 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)`) return &App{ db: db, @@ -29,18 +29,17 @@ func testApp(t *testing.T) *App { func testAdminUser(t *testing.T, app *App) *User { t.Helper() hash, _ := hashPassword("admin123") - u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{}) + u, err := app.createUser("admin", hash, "admin", []int{}) if err != nil { t.Fatal(err) } return u } -func testUserWithRoles(t *testing.T, app *App, name string, roles []string, deptIDs []int) *User { +func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { t.Helper() - email := strings.ToLower(name) + "@athens.example" - hash, _ := hashPassword(name + "123") - u, err := app.createUser(email, name, hash, roles, deptIDs) + hash, _ := hashPassword(username + "123") + u, err := app.createUser(username, hash, role, deptIDs) if err != nil { t.Fatal(err) }