diff --git a/auth.go b/auth.go index c2d11af..0cc812a 100644 --- a/auth.go +++ b/auth.go @@ -12,10 +12,10 @@ import ( ) type Claims struct { - UserID int `json:"uid"` - Username string `json:"sub"` - Role string `json:"role"` - DeptIDs []int `json:"dept_ids,omitempty"` + ParticipantID int `json:"pid"` + Email string `json:"sub"` + Roles []string `json:"roles"` + 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(u *User) (string, error) { +func (app *App) signToken(s *User) (string, error) { expiry := time.Duration(app.tokenExpiry) * time.Hour claims := Claims{ - UserID: u.ID, - Username: u.Username, - Role: u.Role, - DeptIDs: u.DepartmentIDs, + ParticipantID: s.ID, + Email: s.Email, + Roles: s.Roles, + DeptIDs: s.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 && !hasRole(claims.Role, roles) { + if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) { writeError(w, "forbidden", http.StatusForbidden) return } @@ -97,10 +97,12 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler } } -func hasRole(role string, allowed []string) bool { - for _, r := range allowed { - if r == role { - return true +func hasAnyRole(roles []string, allowed []string) bool { + for _, r := range roles { + for _, a := range allowed { + if r == a { + return true + } } } return false diff --git a/auth_test.go b/auth_test.go index f611bc1..602c6cf 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{ - "username": admin.Username, + "email": admin.Email, "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["username"] != "admin" { + if !ok || user["email"] != "oberon@athens.example" { 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{ - "username": "admin", + "email": "oberon@athens.example", "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{ - "username": "nobody", + "email": "nobody@test.com", "password": "test", }) w := httptest.NewRecorder() @@ -94,8 +94,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) { app := testApp(t) mux := testMux(app) - // Create a gate user — should not be able to access /api/users (admin only) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) req := testAuthRequest("GET", "/api/users", nil, token) @@ -121,7 +120,7 @@ func TestMeEndpoint(t *testing.T) { t.Fatalf("status = %d", w.Code) } result := parseJSON(t, w) - if result["username"] != "admin" { - t.Errorf("username = %v", result["username"]) + if result["email"] != "oberon@athens.example" { + t.Errorf("email = %v", result["email"]) } } diff --git a/db.go b/db.go index bda44ed..99b335c 100644 --- a/db.go +++ b/db.go @@ -40,20 +40,6 @@ 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, @@ -75,7 +61,7 @@ func migrate(db *sql.DB) error { 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), + checked_in_by INTEGER REFERENCES participants(id), note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -154,7 +140,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 users(id), + checked_in_by INTEGER REFERENCES participants(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -162,10 +148,99 @@ 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) + ); `) if err != nil { return err } + if err := migrateAuth(db); err != nil { + return err + } + return nil +} + +func migrateAuth(db *sql.DB) error { + // Add auth columns to participants (idempotent — ignore "duplicate column" errors). + db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`) + db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`) + + // Migrate users → participants if the old users table exists. + var hasUsers int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 { + return nil + } + + // Collect all users first (single connection — can't query and exec concurrently). + type oldUser struct { + id int + name string + hash string + role string + } + rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`) + if err != nil { + return nil + } + var users []oldUser + for rows.Next() { + var u oldUser + if err := rows.Scan(&u.id, &u.name, &u.hash, &u.role); err != nil { + continue + } + if u.role == "ticketing" { + u.role = "admin" + } + users = append(users, u) + } + rows.Close() + + // Collect department assignments. + type deptAssign struct { + userID int + deptID int + } + deptRows, err := db.Query(`SELECT user_id, department_id FROM user_departments`) + var deptAssigns []deptAssign + if err == nil { + for deptRows.Next() { + var da deptAssign + deptRows.Scan(&da.userID, &da.deptID) + deptAssigns = append(deptAssigns, da) + } + deptRows.Close() + } + + // Now insert with the connection free. + for _, u := range users { + res, err := db.Exec( + `INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, + u.name, u.hash, now(), + ) + if err != nil { + continue + } + pid, _ := res.LastInsertId() + db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, u.role) + for _, da := range deptAssigns { + if da.userID == u.id { + db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, da.deptID) + } + } + } + + db.Exec(`DROP TABLE IF EXISTS user_departments`) + db.Exec(`DROP TABLE IF EXISTS users`) return nil } @@ -190,11 +265,12 @@ type Event struct { } type User struct { - ID int `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - DepartmentIDs []int `json:"department_ids"` - CreatedAt string `json:"created_at"` + 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"` } type Attendee struct { @@ -325,11 +401,45 @@ func (app *App) upsertEvent(e Event) error { return err } -// --- Users --- +// --- Staff (participants with login_enabled) --- -func (app *App) getUserDeptIDs(userID int) ([]int, error) { +func (app *App) getParticipantRoles(participantID int) ([]string, error) { rows, err := app.db.Query( - `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID, + `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, ) if err != nil { return nil, err @@ -347,14 +457,13 @@ func (app *App) getUserDeptIDs(userID int) ([]int, error) { return ids, rows.Err() } -func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { - _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID) - if err != nil { +func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { + if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil { return err } for _, deptID := range deptIDs { if _, err := app.db.Exec( - `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID, + `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID, ); err != nil { return err } @@ -362,98 +471,157 @@ func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { return nil } -func (app *App) getUserByUsername(username string) (*User, string, error) { - var u User - var hash string +func (app *App) getLoginParticipant(email string) (*User, string, error) { + var s User + var hash sql.NullString err := app.db.QueryRow( - `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username, - ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt) + `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) if err == sql.ErrNoRows { return nil, "", nil } if err != nil { return nil, "", err } - u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) - return &u, hash, 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 } -func (app *App) getUserByID(id int) (*User, error) { - var u User +func (app *App) getUser(id int) (*User, error) { + var s User err := app.db.QueryRow( - `SELECT id, username, role, created_at FROM users WHERE id = ?`, id, - ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt) + `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) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) - return &u, err + s.Roles, _ = app.getParticipantRoles(s.ID) + s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) + return &s, nil } func (app *App) listUsers() ([]User, error) { rows, err := app.db.Query( - `SELECT id, username, role, created_at FROM users ORDER BY username`, + `SELECT id, email, preferred_name, created_at + FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`, ) if err != nil { return nil, err } defer rows.Close() - var users []User + var staff []User for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { + var s User + if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil { return nil, err } - u.DepartmentIDs = []int{} - users = append(users, u) + s.Roles = []string{} + s.DepartmentIDs = []int{} + staff = append(staff, s) } if err := rows.Err(); err != nil { return nil, err } - for i := range users { - users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID) + for i := range staff { + staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID) + staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID) } - return users, nil + return staff, nil } -func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) { +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. res, err := app.db.Exec( - `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, - username, hash, role, + `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at) + VALUES (?, ?, ?, 1, ?)`, + strings.ToLower(email), preferredName, hash, now(), ) 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.getUserByID(int(id)) + return app.getUser(int(id)) } -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 { +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 { return err } return app.setUserDeptIDs(id, deptIDs) } func (app *App) updateUserPassword(id int, hash string) error { - _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) + _, err := app.db.Exec( + `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id, + ) return err } -func (app *App) deleteUser(id int) error { - _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, 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) countUsers() (int, error) { var n int - err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n) return n, err } diff --git a/db_test.go b/db_test.go index c9e8b68..edb2b6d 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", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} + tables := []string{"event", "participants", "participant_roles", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7ad6bc9..52c6741 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -83,7 +83,8 @@ } const path = $derived(route || '/') - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } {#if updateAvailable} @@ -103,8 +104,8 @@ {:else if !session} -{:else if role === 'gatekeeper'} - +{:else if roles.length === 1 && roles[0] === 'gatekeeper'} + {:else}
@@ -121,7 +122,7 @@ Turnpike {#if path === '/' || path === ''} - {#if role === 'colead'} + {#if roles.length === 1 && roles[0] === 'colead'} {:else} diff --git a/frontend/src/api.js b/frontend/src/api.js index 686faa8..1b3d537 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -48,8 +48,8 @@ async function kioskFetch(path, options = {}) { } export const api = { - login: (username, password) => - apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + login: (email, password) => + apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }), logout: () => apiFetch('/api/logout', { method: 'POST' }), me: () => apiJSON('/api/me'), event: { diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js index 974dd32..a725f32 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', 'pass') + await api.login('admin@example.com', 'pass') const [url, opts] = f.mock.calls[0] expect(url).toBe('/api/login') expect(opts.method).toBe('POST') - expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' }) + expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' }) }) it('participants.list calls correct endpoint', async () => { diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index 545e171..61015f5 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -3,17 +3,18 @@ let { session, active, onLogout, navigate, open = false } = $props() - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } const iconProps = { size: 18, strokeWidth: 1.75 } const links = $derived.by(() => { - if (role === 'colead') return [ + if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [ { href: '/', label: 'Schedule', icon: CalendarDays }, { href: '/volunteers', label: 'Volunteers', icon: Heart }, { href: '/departments', label: 'Departments', icon: Hexagon }, ] - if (role === 'staffing') return [ + if (!hasRole('admin') && hasRole('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 7e96bbc..cbc0d38 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -51,6 +51,8 @@ db.version(5).stores({ participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', }) +db.version(6).stores({}).upgrade(tx => tx.table('session').clear()) + export async function getLastSync() { const m = await db.meta.get('last_sync') return m?.value ?? '' diff --git a/frontend/src/db.test.js b/frontend/src/db.test.js index 282b6fc..081ce1a 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, username: 'admin', role: 'admin' }) + await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] }) const s = await getSession() expect(s.token).toBe('tok123') - expect(s.user.username).toBe('admin') + expect(s.user.email).toBe('admin@example.com') }) it('clears session and meta', async () => { diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index e5a26de..0f6decc 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -4,11 +4,12 @@ let { session } = $props() - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } const myDeptIDs = $derived(session?.user?.department_ids ?? []) - const isTicketing = $derived(['admin', 'ticketing'].includes(role)) - const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role)) - const isColead = $derived(role === 'colead') + const isAdmin = $derived(hasRole('admin')) + const isStaffing = $derived(hasRole('admin', 'staffing')) + const isColead = $derived(hasRole('colead')) const event = liveQuery(() => db.event.get(1)) const allTickets = liveQuery(() => db.tickets.toArray()) @@ -76,8 +77,8 @@

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

Ticket Check-in

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

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

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

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

@@ -144,7 +145,7 @@ {/if} - {#if isTicketing} + {#if isAdmin}
Import CSV Manage Participants @@ -158,8 +159,8 @@ {/if}

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

diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index c2cf82b..26164eb 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -18,9 +18,10 @@ let editDesc = $state('') let saving = $state(false) - const role = $derived(session?.user?.role ?? '') - const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role)) - const canDelete = $derived(['admin', 'ticketing'].includes(role)) + 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 allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() diff --git a/frontend/src/pages/Login.svelte b/frontend/src/pages/Login.svelte index de4f6af..1512af7 100644 --- a/frontend/src/pages/Login.svelte +++ b/frontend/src/pages/Login.svelte @@ -4,7 +4,7 @@ let { onlogin } = $props() - let username = $state('') + let email = $state('') let password = $state('') let error = $state('') let loading = $state(false) @@ -14,7 +14,7 @@ error = '' loading = true try { - const { token, user } = await api.login(username, password) + const { token, user } = await api.login(email, password) await saveSession(token, user) onlogin({ token, user }) } catch (err) { @@ -34,8 +34,8 @@ {/if}
- - + +
diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 2867958..fa850a3 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -40,8 +40,9 @@ let newTicketType = $state('') let newTicketExtId = $state('') - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin')) const allParticipants = liveQuery(() => db.participants.toArray()) const allTickets = liveQuery(() => db.tickets.toArray()) diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 6755588..922a0b0 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -25,8 +25,9 @@ let assignVolID = $state(0) let assigning = $state(false) - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin', 'staffing', 'colead')) const myDeptIDs = $derived(session?.user?.department_ids ?? []) const allDepts = liveQuery(() => @@ -54,7 +55,7 @@ // Departments visible to this user const visibleDepts = $derived.by(() => { const depts = $allDepts ?? [] - if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id)) + if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id)) return depts }) diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index ee6b18a..328cc66 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -12,13 +12,14 @@ let showAdd = $state(false) let adding = $state(false) - let newUsername = $state('') + let newEmail = $state('') + let newName = $state('') let newPassword = $state('') - let newRole = $state('gate') + let newRoles = $state([]) let newDeptIDs = $state([]) let editID = $state(null) - let editRole = $state('') + let editRoles = $state([]) let editDeptIDs = $state([]) let editPassword = $state('') let saving = $state(false) @@ -28,7 +29,7 @@ .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) ) - const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper'] + const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper'] const me = $derived(session?.user?.id) @@ -51,15 +52,16 @@ error = '' try { const u = await api.users.create({ - username: newUsername, + email: newEmail, + preferred_name: newName, password: newPassword, - role: newRole, + roles: newRoles, department_ids: newDeptIDs, }) users = [...users, u] showAdd = false - newUsername = newPassword = '' - newRole = 'gate' + newEmail = newName = newPassword = '' + newRoles = [] newDeptIDs = [] } catch (err) { error = err.message @@ -70,7 +72,7 @@ function startEdit(u) { editID = u.id - editRole = u.role + editRoles = [...(u.roles || [])] editDeptIDs = [...(u.department_ids || [])] editPassword = '' } @@ -83,7 +85,7 @@ saving = true error = '' try { - const payload = { role: editRole, department_ids: editDeptIDs } + const payload = { roles: editRoles, 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) @@ -96,7 +98,7 @@ } async function deleteUser(u) { - if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return + if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return try { await api.users.delete(u.id) users = users.filter(x => x.id !== u.id) @@ -105,7 +107,7 @@ } } - function toggleDept(id, list) { + function toggleItem(id, list) { const idx = list.indexOf(id) if (idx === -1) return [...list, id] return list.filter(x => x !== id) @@ -117,7 +119,7 @@ } function roleLabel(r) { - return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r + return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r } @@ -132,7 +134,6 @@

Roles: admin — full access · - ticketing — participants, tickets, import · staffing — volunteers, shifts, departments · colead — manage assigned departments only · gatekeeper — check-in only @@ -150,20 +151,29 @@

- - + + +
+
+ +
-
- - +
+
+ Roles +
+ {#each availableRoles as r} + + {/each}
{#if ($allDepts ?? []).length > 0} @@ -174,7 +184,7 @@ @@ -204,8 +214,8 @@ - - + + @@ -214,13 +224,18 @@ {#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 e90bf80..79681f8 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -25,14 +25,15 @@ let editNote = $state('') let saving = $state(false) - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) - const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) + 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 myDeptIDs = $derived(session?.user?.department_ids ?? []) let deptInitialized = $state(false) $effect(() => { - if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) { + if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) { filterDept = String(myDeptIDs[0]) deptInitialized = true } diff --git a/handle_attendees_test.go b/handle_attendees_test.go index c5e6adb..7dd7ff8 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) != 1 { - t.Errorf("list: got %d, want 1", len(participants)) + if len(participants) != 2 { // admin + Titania + t.Errorf("list: got %d, want 2", 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) != 0 { - t.Errorf("after delete: got %d, want 0", len(ps)) + if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains + t.Errorf("after delete: got %d, want 1", len(ps)) } } @@ -77,7 +77,7 @@ func TestCheckInTicketHandler(t *testing.T) { func TestGatekeeperRoleCanCheckIn(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Philostrate", []string{"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 := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_auth.go b/handle_auth.go index 282bd85..d75483b 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 { - Username string `json:"username"` + Email string `json:"email"` 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.getUserByUsername(body.Username) + user, hash, err := app.getLoginParticipant(body.Email) 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.getUserByID(claims.UserID) + user, err := app.getUser(claims.ParticipantID) if err != nil || user == nil { - writeError(w, "not found", http.StatusNotFound) + writeError(w, "unauthorized", http.StatusUnauthorized) return } writeJSON(w, user) diff --git a/handle_participants.go b/handle_participants.go index 6277824..52624d5 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.UserID) + tk, err := app.checkInTicket(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_settings_test.go b/handle_settings_test.go index cbc53fb..16ef59f 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 := testUserWithRole(t, app, "gate1", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Snug", []string{"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 := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_shifts.go b/handle_shifts.go index a0ceb41..67312c5 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -17,7 +17,7 @@ func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { } claims := claimsFromContext(r) - if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { deptID = &claims.DeptIDs[0] } @@ -40,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -65,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) diff --git a/handle_sync_test.go b/handle_sync_test.go index e4aa2af..000bc60 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) - app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, DepartmentID: &deptID}) app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) 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) != 1 { - t.Errorf("participants = %d, want 1", len(participants)) + if len(participants) != 2 { // admin + Titania + t.Errorf("participants = %d, want 2", len(participants)) } depts := result["departments"].([]any) if len(depts) != 1 { @@ -47,14 +47,16 @@ 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" - // Oberon created with default updated_at (now), which is after our since - app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) + // Lysander created with default updated_at (now), which is after our since + app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"}) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) w := httptest.NewRecorder() @@ -62,14 +64,13 @@ 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"] != "Oberon" { - t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"]) + if p["preferred_name"] != "Lysander" { + t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"]) } } } @@ -80,8 +81,10 @@ 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 386e5b0..4de6109 100644 --- a/handle_users.go +++ b/handle_users.go @@ -17,17 +17,18 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { var body struct { - Username string `json:"username"` - Password string `json:"password"` - Role string `json:"role"` - DepartmentIDs []int `json:"department_ids"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + Password string `json:"password"` + Roles []string `json:"roles"` + DepartmentIDs []int `json:"department_ids"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - if body.Username == "" || body.Password == "" || body.Role == "" { - writeError(w, "username, password, and role are required", http.StatusBadRequest) + if body.Email == "" || body.Password == "" || len(body.Roles) == 0 { + writeError(w, "email, password, and at least one role are required", http.StatusBadRequest) return } hash, err := hashPassword(body.Password) @@ -38,7 +39,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs) + user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -53,10 +54,15 @@ 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 { - Role string `json:"role"` - Password string `json:"password"` - DepartmentIDs []int `json:"department_ids"` + Roles []string `json:"roles"` + 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) @@ -65,8 +71,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - if body.Role != "" { - if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil { + if body.Roles != nil { + if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -82,7 +88,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } } - user, _ := app.getUserByID(id) + user, _ := app.getUser(id) writeJSON(w, user) } @@ -93,11 +99,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.UserID == id { + if claims.ParticipantID == id { writeError(w, "cannot delete yourself", http.StatusBadRequest) return } - if err := app.deleteUser(id); err != nil { + if err := app.removeUser(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } diff --git a/handle_volunteers.go b/handle_volunteers.go index 5d086ad..ec3a317 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -21,7 +21,7 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { } claims := claimsFromContext(r) - if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { deptID = &claims.DeptIDs[0] } @@ -55,7 +55,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return @@ -127,7 +127,7 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { existing, _ := app.getVolunteer(id) if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -171,7 +171,7 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) return } claims := claimsFromContext(r) - v, err := app.markVolunteerReady(id, claims.UserID) + v, err := app.markVolunteerReady(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index ab51b9b..19ff1b0 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) - // Ticketing role should NOT be able to confirm volunteers. - ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) - tok := testToken(t, app, ticketing) + // Gatekeeper role should NOT be able to confirm volunteers. + gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{}) + tok := testToken(t, app, gatekeeper) p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) @@ -75,7 +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 ticketing role, got %d", w.Code) + t.Errorf("expected 403 for gatekeeper role, got %d", w.Code) } } diff --git a/main.go b/main.go index be9fc53..f775bb6 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", "ticketing")) + mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "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/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/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/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/departments", auth(app.handleListDepartments)) - 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("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("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("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("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "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/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/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/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/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("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("POST /api/import", auth(app.handleImport, "admin", "ticketing")) + mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin")) mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) @@ -161,7 +161,7 @@ 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", "ticketing", "staffing")) + mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing")) // Public endpoints — no JWT required. mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) @@ -196,9 +196,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) { } func (app *App) bootstrapAdmin() error { - adminUser := os.Getenv("TURNPIKE_ADMIN_USER") + adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL") adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD") - if adminUser == "" || adminPass == "" { + if adminEmail == "" || adminPass == "" { return nil } n, err := app.countUsers() @@ -209,11 +209,11 @@ func (app *App) bootstrapAdmin() error { if err != nil { return err } - _, err = app.createUser(adminUser, hash, "admin", []int{}) + _, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{}) if err != nil { return err } - log.Printf("Created admin user: %s", adminUser) + log.Printf("Created admin user: %s", adminEmail) return nil } diff --git a/testutil_test.go b/testutil_test.go index 8f58833..14351e5 100644 --- a/testutil_test.go +++ b/testutil_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -16,7 +17,6 @@ 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,17 +29,18 @@ func testApp(t *testing.T) *App { func testAdminUser(t *testing.T, app *App) *User { t.Helper() hash, _ := hashPassword("admin123") - u, err := app.createUser("admin", hash, "admin", []int{}) + u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{}) if err != nil { t.Fatal(err) } return u } -func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { +func testUserWithRoles(t *testing.T, app *App, name string, roles []string, deptIDs []int) *User { t.Helper() - hash, _ := hashPassword(username + "123") - u, err := app.createUser(username, hash, role, deptIDs) + email := strings.ToLower(name) + "@athens.example" + hash, _ := hashPassword(name + "123") + u, err := app.createUser(email, name, hash, roles, deptIDs) if err != nil { t.Fatal(err) }
UsernameRoleNameRoles Departments
{u.username} {#if u.id === me}you{/if}{u.preferred_name || u.email} {#if u.id === me}you{/if} - editRoles = toggleItem(r, editRoles)} /> + {roleLabel(r)} + {/each} - + {#if ($allDepts ?? []).length > 0} @@ -229,7 +244,7 @@ {/each} @@ -251,18 +266,19 @@ {:else}
- {u.username} + {u.preferred_name || u.email} {#if u.id === me} you {/if} +
{u.email}
{roleLabel(u.role)}{#each u.roles ?? [] as r}{roleLabel(r)}{/each} {deptNamesFor(u.department_ids || [])}
{#if u.id !== me} - + {/if}