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 @@
- Welcome, {session?.user?.username} - · {session?.user?.role} + Welcome, {session?.user?.preferred_name} + · {#each roles as r}{r}{/each}