Refactored user/volunteer/participant identity.
This commit is contained in:
parent
e640bf8bed
commit
1eb6a99ff6
28 changed files with 469 additions and 265 deletions
26
auth.go
26
auth.go
|
|
@ -12,9 +12,9 @@ import (
|
|||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int `json:"uid"`
|
||||
Username string `json:"sub"`
|
||||
Role string `json:"role"`
|
||||
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,12 +97,14 @@ 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 {
|
||||
func hasAnyRole(roles []string, allowed []string) bool {
|
||||
for _, r := range roles {
|
||||
for _, a := range allowed {
|
||||
if r == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
15
auth_test.go
15
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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
282
db.go
282
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
|
||||
}
|
||||
|
||||
|
|
@ -191,8 +266,9 @@ type Event struct {
|
|||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
Roles []string `json:"roles"`
|
||||
DepartmentIDs []int `json:"department_ids"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
</script>
|
||||
|
||||
{#if updateAvailable}
|
||||
|
|
@ -103,8 +104,8 @@
|
|||
<ConfirmEmail />
|
||||
{:else if !session}
|
||||
<Login onlogin={onLogin} />
|
||||
{:else if role === 'gatekeeper'}
|
||||
<!-- Gate users get the full-screen GateKiosk instead of the standard layout -->
|
||||
{:else if roles.length === 1 && roles[0] === 'gatekeeper'}
|
||||
<!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
|
||||
<GateKiosk {session} {onLogout} />
|
||||
{:else}
|
||||
<div class="layout">
|
||||
|
|
@ -121,7 +122,7 @@
|
|||
<span class="mobile-brand">Turn<span class="accent">pike</span></span>
|
||||
</header>
|
||||
{#if path === '/' || path === ''}
|
||||
{#if role === 'colead'}
|
||||
{#if roles.length === 1 && roles[0] === 'colead'}
|
||||
<ScheduleBoard {session} />
|
||||
{:else}
|
||||
<Dashboard {session} />
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 ?? ''
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Ticket check-in (admin/ticketing) -->
|
||||
{#if isTicketing}
|
||||
<!-- Ticket check-in (admin) -->
|
||||
{#if isAdmin}
|
||||
<h2 class="dash-section">Ticket Check-in</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
|
|
@ -105,7 +106,7 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Volunteer stats (admin/ticketing/staffing/colead) -->
|
||||
<!-- Volunteer stats (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
|
||||
<div class="stats">
|
||||
|
|
@ -124,7 +125,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Shift coverage (admin/ticketing/staffing/colead) -->
|
||||
<!-- Shift coverage (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
||||
<div class="stats">
|
||||
|
|
@ -144,7 +145,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Quick actions -->
|
||||
{#if isTicketing}
|
||||
{#if isAdmin}
|
||||
<div class="dash-actions">
|
||||
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a>
|
||||
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a>
|
||||
|
|
@ -158,8 +159,8 @@
|
|||
{/if}
|
||||
|
||||
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
|
||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
||||
· <span class="badge badge-role">{session?.user?.role}</span>
|
||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong>
|
||||
· {#each roles as r}<span class="badge badge-role" style="margin-right:0.25rem">{r}</span>{/each}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<form onsubmit={submit}>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" bind:value={username} autocomplete="username" required />
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" bind:value={email} autocomplete="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -132,7 +134,6 @@
|
|||
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6">
|
||||
<strong style="color:var(--c-text)">Roles:</strong>
|
||||
admin — full access ·
|
||||
ticketing — participants, tickets, import ·
|
||||
staffing — volunteers, shifts, departments ·
|
||||
colead — manage assigned departments only ·
|
||||
gatekeeper — check-in only
|
||||
|
|
@ -150,20 +151,29 @@
|
|||
<form onsubmit={addUser}>
|
||||
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="u-username">Username *</label>
|
||||
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
||||
<label for="u-email">Email *</label>
|
||||
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-name">Name</label>
|
||||
<input id="u-name" bind:value={newName} placeholder="Preferred name" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-password">Password *</label>
|
||||
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-role">Role *</label>
|
||||
<select id="u-role" bind:value={newRole}>
|
||||
{#each roles as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||
{#each availableRoles as r}
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
checked={newRoles.includes(r)}
|
||||
onchange={() => newRoles = toggleItem(r, newRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
|
|
@ -174,7 +184,7 @@
|
|||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
checked={newDeptIDs.includes(d.id)}
|
||||
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} />
|
||||
onchange={() => newDeptIDs = toggleItem(d.id, newDeptIDs)} />
|
||||
<span class="dept-dot" style="background:{d.color}"></span>
|
||||
{d.name}
|
||||
</label>
|
||||
|
|
@ -204,8 +214,8 @@
|
|||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Departments</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -214,13 +224,18 @@
|
|||
{#each users as u (u.id)}
|
||||
{#if editID === u.id}
|
||||
<tr class="edit-row">
|
||||
<td class="td-name"><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||
<td class="td-name"><strong>{u.preferred_name || u.email}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||
<td>
|
||||
<select bind:value={editRole} style="width:auto;margin:0">
|
||||
{#each roles as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{#each availableRoles as r}
|
||||
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
checked={editRoles.includes(r)}
|
||||
onchange={() => editRoles = toggleItem(r, editRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
|
|
@ -229,7 +244,7 @@
|
|||
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
checked={editDeptIDs.includes(d.id)}
|
||||
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
|
||||
onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
|
||||
{d.name}
|
||||
</label>
|
||||
{/each}
|
||||
|
|
@ -251,18 +266,19 @@
|
|||
{:else}
|
||||
<tr>
|
||||
<td class="td-name">
|
||||
<strong>{u.username}</strong>
|
||||
<strong>{u.preferred_name || u.email}</strong>
|
||||
{#if u.id === me}
|
||||
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
||||
{/if}
|
||||
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
|
||||
</td>
|
||||
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
||||
<td>{#each u.roles ?? [] as r}<span class="badge badge-role" style="margin-right:0.25rem">{roleLabel(r)}</span>{/each}</td>
|
||||
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||
<td class="td-actions">
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||
{#if u.id !== me}
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Remove</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
Email string `json:"email"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
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,8 +54,13 @@ 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"`
|
||||
Roles []string `json:"roles"`
|
||||
Password string `json:"password"`
|
||||
DepartmentIDs []int `json:"department_ids"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
102
main.go
102
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue