Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d64e93674e | |||
| d73a74965d | |||
| 6d4c49a223 | |||
| 374316944e | |||
| faa359751d | |||
| dc08723500 | |||
| 54da04763f | |||
| 5527c1eb91 | |||
| ad8c3a64b6 | |||
| 7dbcd05262 | |||
| da5f3524fa | |||
| 1eb6a99ff6 | |||
| e640bf8bed |
35 changed files with 1279 additions and 750 deletions
18
Makefile
18
Makefile
|
|
@ -1,9 +1,9 @@
|
||||||
.PHONY: build frontend-build dev clean test patch minor major
|
.PHONY: build frontend-build dev clean test patch minor major
|
||||||
|
|
||||||
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
|
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
|
||||||
MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1)
|
MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
|
||||||
MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2)
|
MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
|
||||||
PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3)
|
PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3)
|
||||||
|
|
||||||
build: frontend-build
|
build: frontend-build
|
||||||
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
|
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
|
||||||
|
|
@ -25,13 +25,13 @@ clean:
|
||||||
rm -rf frontend/dist
|
rm -rf frontend/dist
|
||||||
|
|
||||||
patch:
|
patch:
|
||||||
git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
||||||
@echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
@echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
||||||
|
|
||||||
minor:
|
minor:
|
||||||
git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
||||||
@echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
@echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
||||||
|
|
||||||
major:
|
major:
|
||||||
git tag v$(shell echo $$(($(MAJOR)+1))).0.0
|
git tag $(shell echo $$(($(MAJOR)+1))).0.0
|
||||||
@echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0"
|
@echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"
|
||||||
|
|
|
||||||
42
auth.go
42
auth.go
|
|
@ -12,10 +12,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID int `json:"uid"`
|
ParticipantID int `json:"pid"`
|
||||||
Username string `json:"sub"`
|
Email string `json:"sub"`
|
||||||
Role string `json:"role"`
|
Roles []string `json:"roles"`
|
||||||
DeptIDs []int `json:"dept_ids,omitempty"`
|
DeptIDs []int `json:"dept_ids,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
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
|
expiry := time.Duration(app.tokenExpiry) * time.Hour
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
UserID: u.ID,
|
ParticipantID: s.ID,
|
||||||
Username: u.Username,
|
Email: s.Email,
|
||||||
Role: u.Role,
|
Roles: s.Roles,
|
||||||
DeptIDs: u.DepartmentIDs,
|
DeptIDs: s.DepartmentIDs,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
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)
|
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(roles) > 0 && !hasRole(claims.Role, roles) {
|
if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) {
|
||||||
writeError(w, "forbidden", http.StatusForbidden)
|
writeError(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -97,9 +97,25 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasRole(role string, allowed []string) bool {
|
func hasAnyRole(roles []string, allowed []string) bool {
|
||||||
for _, r := range allowed {
|
for _, r := range roles {
|
||||||
if r == role {
|
for _, a := range allowed {
|
||||||
|
if r == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCoLeadOnly(claims *Claims) bool {
|
||||||
|
return hasAnyRole(claims.Roles, []string{"colead"}) &&
|
||||||
|
!hasAnyRole(claims.Roles, []string{"admin", "staffing"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func inSlice(v int, s []int) bool {
|
||||||
|
for _, x := range s {
|
||||||
|
if x == v {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
auth_test.go
15
auth_test.go
|
|
@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) {
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
req := testRequest("POST", "/api/login", map[string]string{
|
req := testRequest("POST", "/api/login", map[string]string{
|
||||||
"username": admin.Username,
|
"email": admin.Email,
|
||||||
"password": "admin123",
|
"password": "admin123",
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) {
|
||||||
t.Error("missing token in response")
|
t.Error("missing token in response")
|
||||||
}
|
}
|
||||||
user, ok := result["user"].(map[string]any)
|
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"])
|
t.Errorf("user = %v", result["user"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) {
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
req := testRequest("POST", "/api/login", map[string]string{
|
req := testRequest("POST", "/api/login", map[string]string{
|
||||||
"username": "admin",
|
"email": "oberon@athens.example",
|
||||||
"password": "wrong",
|
"password": "wrong",
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) {
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
req := testRequest("POST", "/api/login", map[string]string{
|
req := testRequest("POST", "/api/login", map[string]string{
|
||||||
"username": "nobody",
|
"email": "nobody@test.com",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -94,8 +94,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
// Create a gate user — should not be able to access /api/users (admin only)
|
gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{})
|
||||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
|
||||||
token := testToken(t, app, gate)
|
token := testToken(t, app, gate)
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/users", nil, token)
|
req := testAuthRequest("GET", "/api/users", nil, token)
|
||||||
|
|
@ -121,7 +120,7 @@ func TestMeEndpoint(t *testing.T) {
|
||||||
t.Fatalf("status = %d", w.Code)
|
t.Fatalf("status = %d", w.Code)
|
||||||
}
|
}
|
||||||
result := parseJSON(t, w)
|
result := parseJSON(t, w)
|
||||||
if result["username"] != "admin" {
|
if result["email"] != "oberon@athens.example" {
|
||||||
t.Errorf("username = %v", result["username"])
|
t.Errorf("email = %v", result["email"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
516
db.go
516
db.go
|
|
@ -40,20 +40,6 @@ func migrate(db *sql.DB) error {
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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 (
|
CREATE TABLE IF NOT EXISTS departments (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
|
@ -63,28 +49,6 @@ func migrate(db *sql.DB) error {
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS attendees (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL DEFAULT '',
|
|
||||||
phone TEXT NOT NULL DEFAULT '',
|
|
||||||
ticket_id TEXT NOT NULL DEFAULT '',
|
|
||||||
ticket_type TEXT NOT NULL DEFAULT '',
|
|
||||||
volunteer_token TEXT UNIQUE,
|
|
||||||
party_size INTEGER NOT NULL DEFAULT 1,
|
|
||||||
checked_in INTEGER NOT NULL DEFAULT 0,
|
|
||||||
checked_in_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
checked_in_at TEXT,
|
|
||||||
checked_in_by INTEGER REFERENCES users(id),
|
|
||||||
note TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
deleted_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket
|
|
||||||
ON attendees(name, ticket_id) WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS volunteers (
|
CREATE TABLE IF NOT EXISTS volunteers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
|
@ -136,6 +100,8 @@ func migrate(db *sql.DB) error {
|
||||||
note TEXT NOT NULL DEFAULT '',
|
note TEXT NOT NULL DEFAULT '',
|
||||||
email_confirmed INTEGER NOT NULL DEFAULT 0,
|
email_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
confirmation_token TEXT,
|
confirmation_token TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
|
login_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
|
|
@ -154,7 +120,7 @@ func migrate(db *sql.DB) error {
|
||||||
order_id TEXT NOT NULL DEFAULT '',
|
order_id TEXT NOT NULL DEFAULT '',
|
||||||
code TEXT UNIQUE,
|
code TEXT UNIQUE,
|
||||||
checked_in_at TEXT,
|
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')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
|
|
@ -162,19 +128,29 @@ func migrate(db *sql.DB) error {
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external
|
||||||
ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL;
|
ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS participant_roles (
|
||||||
|
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('admin','staffing','colead','gatekeeper')),
|
||||||
|
PRIMARY KEY (participant_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS participant_departments (
|
||||||
|
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (participant_id, department_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sso_nonces (
|
||||||
|
nonce TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token,
|
|
||||||
party_size, checked_in, checked_in_count, checked_in_at, checked_in_by,
|
|
||||||
note, created_at, updated_at, deleted_at`
|
|
||||||
|
|
||||||
const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at`
|
const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at`
|
||||||
const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at`
|
const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at`
|
||||||
|
|
||||||
|
|
@ -190,30 +166,12 @@ type Event struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Username string `json:"username"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
PreferredName string `json:"preferred_name"`
|
||||||
DepartmentIDs []int `json:"department_ids"`
|
Roles []string `json:"roles"`
|
||||||
CreatedAt string `json:"created_at"`
|
DepartmentIDs []int `json:"department_ids"`
|
||||||
}
|
CreatedAt string `json:"created_at"`
|
||||||
|
|
||||||
type Attendee struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
TicketID string `json:"ticket_id"`
|
|
||||||
TicketType string `json:"ticket_type"`
|
|
||||||
VolunteerToken *string `json:"volunteer_token,omitempty"`
|
|
||||||
PartySize int `json:"party_size"`
|
|
||||||
CheckedIn bool `json:"checked_in"`
|
|
||||||
CheckedInCount int `json:"checked_in_count"`
|
|
||||||
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
|
||||||
CheckedInBy *int `json:"checked_in_by,omitempty"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Department struct {
|
type Department struct {
|
||||||
|
|
@ -325,11 +283,45 @@ func (app *App) upsertEvent(e Event) error {
|
||||||
return err
|
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(
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -347,14 +339,13 @@ func (app *App) getUserDeptIDs(userID int) ([]int, error) {
|
||||||
return ids, rows.Err()
|
return ids, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) setUserDeptIDs(userID int, deptIDs []int) error {
|
func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error {
|
||||||
_, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID)
|
if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, deptID := range deptIDs {
|
for _, deptID := range deptIDs {
|
||||||
if _, err := app.db.Exec(
|
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 {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -362,98 +353,157 @@ func (app *App) setUserDeptIDs(userID int, deptIDs []int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) getUserByUsername(username string) (*User, string, error) {
|
func (app *App) getLoginParticipant(email string) (*User, string, error) {
|
||||||
var u User
|
var s User
|
||||||
var hash string
|
var hash sql.NullString
|
||||||
err := app.db.QueryRow(
|
err := app.db.QueryRow(
|
||||||
`SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username,
|
`SELECT id, email, preferred_name, password_hash, created_at
|
||||||
).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt)
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, "", nil
|
return nil, "", nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
|
var hashStr string
|
||||||
return &u, hash, err
|
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) {
|
func (app *App) getUser(id int) (*User, error) {
|
||||||
var u User
|
var s User
|
||||||
err := app.db.QueryRow(
|
err := app.db.QueryRow(
|
||||||
`SELECT id, username, role, created_at FROM users WHERE id = ?`, id,
|
`SELECT id, email, preferred_name, created_at
|
||||||
).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt)
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
|
s.Roles, _ = app.getParticipantRoles(s.ID)
|
||||||
return &u, err
|
s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID)
|
||||||
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) listUsers() ([]User, error) {
|
func (app *App) listUsers() ([]User, error) {
|
||||||
rows, err := app.db.Query(
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var users []User
|
var staff []User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u User
|
var s User
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
|
if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
u.DepartmentIDs = []int{}
|
s.Roles = []string{}
|
||||||
users = append(users, u)
|
s.DepartmentIDs = []int{}
|
||||||
|
staff = append(staff, s)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for i := range users {
|
for i := range staff {
|
||||||
users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID)
|
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(
|
res, err := app.db.Exec(
|
||||||
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
|
`INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at)
|
||||||
username, hash, role,
|
VALUES (?, ?, ?, 1, ?)`,
|
||||||
|
strings.ToLower(email), preferredName, hash, now(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
id, _ := res.LastInsertId()
|
||||||
|
if err := app.setParticipantRoles(int(id), roles); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := app.setUserDeptIDs(int(id), deptIDs); err != nil {
|
if err := app.setUserDeptIDs(int(id), deptIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return app.getUserByID(int(id))
|
return app.getUser(int(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) updateUser(id int, role string, deptIDs []int) error {
|
func (app *App) updateUserRoles(id int, roles []string, deptIDs []int) error {
|
||||||
if _, err := app.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id); err != nil {
|
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 err
|
||||||
}
|
}
|
||||||
return app.setUserDeptIDs(id, deptIDs)
|
return app.setUserDeptIDs(id, deptIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) updateUserPassword(id int, hash string) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) deleteUser(id int) error {
|
func (app *App) removeUser(id int) error {
|
||||||
_, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, id)
|
tx, err := app.db.Begin()
|
||||||
return err
|
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) {
|
func (app *App) countUsers() (int, error) {
|
||||||
var n int
|
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
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,174 +570,6 @@ func (app *App) generateCodesForAll() (int, error) {
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// incrementPartySize is kept for backward compatibility with existing tests.
|
|
||||||
func (app *App) incrementPartySize(name, ticketID string) (bool, error) {
|
|
||||||
res, err := app.db.Exec(
|
|
||||||
`UPDATE attendees SET party_size = party_size + 1, updated_at = ?
|
|
||||||
WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`,
|
|
||||||
now(), name, ticketID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return n > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Attendees ---
|
|
||||||
|
|
||||||
func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) {
|
|
||||||
q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL`
|
|
||||||
var args []any
|
|
||||||
if search != "" {
|
|
||||||
q += ` AND (name LIKE ? OR email LIKE ? OR ticket_id LIKE ?)`
|
|
||||||
s := "%" + search + "%"
|
|
||||||
args = append(args, s, s, s)
|
|
||||||
}
|
|
||||||
if ticketType != "" {
|
|
||||||
q += ` AND ticket_type = ?`
|
|
||||||
args = append(args, ticketType)
|
|
||||||
}
|
|
||||||
if checkedIn == "true" {
|
|
||||||
q += ` AND checked_in = 1`
|
|
||||||
} else if checkedIn == "false" {
|
|
||||||
q += ` AND checked_in = 0`
|
|
||||||
}
|
|
||||||
q += ` ORDER BY name ASC`
|
|
||||||
return queryAttendees(app.db, q, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) getAttendee(id int) (*Attendee, error) {
|
|
||||||
rows, err := queryAttendees(app.db,
|
|
||||||
`SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id)
|
|
||||||
if err != nil || len(rows) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &rows[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) createAttendee(a Attendee) (*Attendee, error) {
|
|
||||||
res, err := app.db.Exec(
|
|
||||||
`INSERT INTO attendees (name, email, phone, ticket_id, ticket_type, note, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
id, _ := res.LastInsertId()
|
|
||||||
return app.getAttendee(int(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) updateAttendee(a Attendee) error {
|
|
||||||
_, err := app.db.Exec(
|
|
||||||
`UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=?
|
|
||||||
WHERE id = ? AND deleted_at IS NULL`,
|
|
||||||
a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) deleteAttendee(id int) error {
|
|
||||||
_, err := app.db.Exec(
|
|
||||||
`UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkInAttendee increments checked_in_count by count (capped at party_size).
|
|
||||||
// Sets checked_in and checked_in_at on the first check-in.
|
|
||||||
func (app *App) checkInAttendee(id, userID, count int) (*Attendee, error) {
|
|
||||||
if count < 1 {
|
|
||||||
count = 1
|
|
||||||
}
|
|
||||||
a, err := app.getAttendee(id)
|
|
||||||
if err != nil || a == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
remaining := a.PartySize - a.CheckedInCount
|
|
||||||
if count > remaining {
|
|
||||||
count = remaining
|
|
||||||
}
|
|
||||||
if count <= 0 {
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
t := now()
|
|
||||||
_, err = app.db.Exec(`
|
|
||||||
UPDATE attendees SET
|
|
||||||
checked_in_count = checked_in_count + ?,
|
|
||||||
checked_in = CASE WHEN checked_in = 0 THEN 1 ELSE checked_in END,
|
|
||||||
checked_in_at = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_at END,
|
|
||||||
checked_in_by = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_by END,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ? AND deleted_at IS NULL`,
|
|
||||||
count, t, userID, t, id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return app.getAttendee(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) attendeesSince(since string) ([]Attendee, error) {
|
|
||||||
return queryAttendees(app.db,
|
|
||||||
`SELECT `+attendeeCols+` FROM attendees WHERE updated_at > ? ORDER BY updated_at ASC`, since)
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) {
|
|
||||||
rows, err := db.Query(q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var result []Attendee
|
|
||||||
for rows.Next() {
|
|
||||||
var a Attendee
|
|
||||||
var checkedIn int
|
|
||||||
var token sql.NullString
|
|
||||||
if err := rows.Scan(
|
|
||||||
&a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType,
|
|
||||||
&token, &a.PartySize, &checkedIn, &a.CheckedInCount,
|
|
||||||
&a.CheckedInAt, &a.CheckedInBy, &a.Note,
|
|
||||||
&a.CreatedAt, &a.UpdatedAt, &a.DeletedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if token.Valid && token.String != "" {
|
|
||||||
a.VolunteerToken = &token.String
|
|
||||||
}
|
|
||||||
a.CheckedIn = checkedIn == 1
|
|
||||||
if a.PartySize < 1 {
|
|
||||||
a.PartySize = 1
|
|
||||||
}
|
|
||||||
result = append(result, a)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) attendeeTicketTypes() ([]string, error) {
|
|
||||||
rows, err := app.db.Query(
|
|
||||||
`SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var types []string
|
|
||||||
for rows.Next() {
|
|
||||||
var t string
|
|
||||||
rows.Scan(&t)
|
|
||||||
types = append(types, t)
|
|
||||||
}
|
|
||||||
return types, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) attendeeCounts() (total, checkedIn int, err error) {
|
|
||||||
app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE deleted_at IS NULL`).Scan(&total)
|
|
||||||
app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE checked_in=1 AND deleted_at IS NULL`).Scan(&checkedIn)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Participants ---
|
// --- Participants ---
|
||||||
|
|
||||||
const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at`
|
const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at`
|
||||||
|
|
@ -771,6 +653,8 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error {
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
app.db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) SELECT ?, role FROM participant_roles WHERE participant_id = ?`, canonicalID, otherID)
|
||||||
|
app.db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) SELECT ?, department_id FROM participant_departments WHERE participant_id = ?`, canonicalID, otherID)
|
||||||
_, err := app.db.Exec(
|
_, err := app.db.Exec(
|
||||||
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID,
|
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID,
|
||||||
)
|
)
|
||||||
|
|
@ -851,15 +735,6 @@ func (app *App) getTicket(id int) (*Ticket, error) {
|
||||||
return &rows[0], nil
|
return &rows[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) getTicketByCode(code string) (*Ticket, error) {
|
|
||||||
rows, err := queryTickets(app.db,
|
|
||||||
`SELECT `+ticketCols+` FROM tickets WHERE code = ? AND deleted_at IS NULL`, code)
|
|
||||||
if err != nil || len(rows) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &rows[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) createTicket(t Ticket) (*Ticket, error) {
|
func (app *App) createTicket(t Ticket) (*Ticket, error) {
|
||||||
res, err := app.db.Exec(
|
res, err := app.db.Exec(
|
||||||
`INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at)
|
`INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at)
|
||||||
|
|
@ -896,16 +771,6 @@ func (app *App) deleteTicket(id int) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) ticketsSince(since string) ([]Ticket, error) {
|
|
||||||
return queryTickets(app.db,
|
|
||||||
`SELECT `+ticketCols+` FROM tickets WHERE updated_at > ? ORDER BY updated_at ASC`, since)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) participantsSince(since string) ([]Participant, error) {
|
|
||||||
return queryParticipants(app.db,
|
|
||||||
`SELECT `+participantCols+` FROM participants WHERE updated_at > ? ORDER BY updated_at ASC`, since)
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) {
|
func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) {
|
||||||
rows, err := db.Query(q, args...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1038,7 +903,7 @@ const volunteerSelect = `v.id, v.participant_id,
|
||||||
v.created_at, v.updated_at, v.deleted_at`
|
v.created_at, v.updated_at, v.deleted_at`
|
||||||
const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id`
|
const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id`
|
||||||
|
|
||||||
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
|
func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) {
|
||||||
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
|
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
|
||||||
var args []any
|
var args []any
|
||||||
if since != "" {
|
if since != "" {
|
||||||
|
|
@ -1052,9 +917,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu
|
||||||
s := "%" + search + "%"
|
s := "%" + search + "%"
|
||||||
args = append(args, s, s)
|
args = append(args, s, s)
|
||||||
}
|
}
|
||||||
if deptID != nil {
|
if len(deptIDs) == 1 {
|
||||||
q += ` AND v.department_id = ?`
|
q += ` AND v.department_id = ?`
|
||||||
args = append(args, *deptID)
|
args = append(args, deptIDs[0])
|
||||||
|
} else if len(deptIDs) > 1 {
|
||||||
|
q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)`
|
||||||
|
for _, id := range deptIDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
q += ` ORDER BY p.preferred_name`
|
q += ` ORDER BY p.preferred_name`
|
||||||
return queryVolunteers(app.db, q, args...)
|
return queryVolunteers(app.db, q, args...)
|
||||||
|
|
@ -1069,15 +939,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) {
|
||||||
return &rows[0], nil
|
return &rows[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) {
|
|
||||||
rows, err := queryVolunteers(app.db,
|
|
||||||
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID)
|
|
||||||
if err != nil || len(rows) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &rows[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
|
func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
|
||||||
res, err := app.db.Exec(
|
res, err := app.db.Exec(
|
||||||
`INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at)
|
`INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at)
|
||||||
|
|
@ -1254,7 +1115,7 @@ func generateConfirmationToken() (string, error) {
|
||||||
|
|
||||||
// --- Shifts ---
|
// --- Shifts ---
|
||||||
|
|
||||||
func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
|
func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) {
|
||||||
q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1`
|
q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1`
|
||||||
var args []any
|
var args []any
|
||||||
if since != "" {
|
if since != "" {
|
||||||
|
|
@ -1263,9 +1124,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
|
||||||
} else {
|
} else {
|
||||||
q += ` AND deleted_at IS NULL`
|
q += ` AND deleted_at IS NULL`
|
||||||
}
|
}
|
||||||
if deptID != nil {
|
if len(deptIDs) == 1 {
|
||||||
q += ` AND department_id = ?`
|
q += ` AND department_id = ?`
|
||||||
args = append(args, *deptID)
|
args = append(args, deptIDs[0])
|
||||||
|
} else if len(deptIDs) > 1 {
|
||||||
|
q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)`
|
||||||
|
for _, id := range deptIDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if day != "" {
|
if day != "" {
|
||||||
q += ` AND day = ?`
|
q += ` AND day = ?`
|
||||||
|
|
@ -1489,6 +1355,27 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) {
|
||||||
ORDER BY s.day, s.position, s.start_time`, deptID)
|
ORDER BY s.day, s.position, s.start_time`, deptID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SSO Nonces ---
|
||||||
|
|
||||||
|
func (app *App) createSSONonce(nonce string) error {
|
||||||
|
_, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) consumeSSONonce(nonce string) (bool, error) {
|
||||||
|
res, err := app.db.Exec(
|
||||||
|
`DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) cleanExpiredNonces() {
|
||||||
|
app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
func now() string {
|
func now() string {
|
||||||
|
|
@ -1501,3 +1388,10 @@ func boolInt(b bool) int {
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func placeholders(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Repeat("?,", n-1) + "?"
|
||||||
|
}
|
||||||
|
|
|
||||||
94
db_test.go
94
db_test.go
|
|
@ -7,7 +7,7 @@ import (
|
||||||
func TestMigrate(t *testing.T) {
|
func TestMigrate(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
// Verify tables exist by querying each one
|
// Verify tables exist by querying each one
|
||||||
tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"}
|
tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"}
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
var count int
|
var count int
|
||||||
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
|
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
|
||||||
|
|
@ -17,98 +17,6 @@ func TestMigrate(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAttendeesCRUD(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
|
|
||||||
a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if a.ID == 0 || a.Name != "Titania" {
|
|
||||||
t.Errorf("create: got %+v", a)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := app.getAttendee(a.ID)
|
|
||||||
if err != nil || got == nil {
|
|
||||||
t.Fatal("get: not found")
|
|
||||||
}
|
|
||||||
if got.Email != "titania@test.com" {
|
|
||||||
t.Errorf("get: email = %q", got.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
got.Name = "Titania Fairweather"
|
|
||||||
if err := app.updateAttendee(*got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got2, _ := app.getAttendee(a.ID)
|
|
||||||
if got2.Name != "Titania Fairweather" {
|
|
||||||
t.Errorf("update: name = %q", got2.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.deleteAttendee(a.ID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// getAttendee returns soft-deleted records; listAttendees filters them
|
|
||||||
attendees, _ := app.listAttendees("", "", "")
|
|
||||||
for _, at := range attendees {
|
|
||||||
if at.ID == a.ID {
|
|
||||||
t.Error("delete: still visible in list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrementPartySize(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
|
|
||||||
app.createAttendee(Attendee{Name: "Oberon", TicketID: "ORD-100"})
|
|
||||||
|
|
||||||
merged, err := app.incrementPartySize("Oberon", "ORD-100")
|
|
||||||
if err != nil || !merged {
|
|
||||||
t.Fatalf("increment: merged=%v, err=%v", merged, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a, _ := app.getAttendee(1)
|
|
||||||
if a.PartySize != 2 {
|
|
||||||
t.Errorf("party_size = %d, want 2", a.PartySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different ticket_id should not merge
|
|
||||||
merged2, _ := app.incrementPartySize("Oberon", "ORD-200")
|
|
||||||
if merged2 {
|
|
||||||
t.Error("should not merge different ticket_id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckInAttendee(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
|
|
||||||
app.createAttendee(Attendee{Name: "Puck"})
|
|
||||||
// Set party_size directly since createAttendee defaults to 1
|
|
||||||
app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`)
|
|
||||||
|
|
||||||
// Check in 1
|
|
||||||
a, err := app.checkInAttendee(1, admin.ID, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if a.CheckedInCount != 1 || !a.CheckedIn {
|
|
||||||
t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check in 2 more (should cap at party_size=3)
|
|
||||||
a, _ = app.checkInAttendee(1, admin.ID, 5)
|
|
||||||
if a.CheckedInCount != 3 {
|
|
||||||
t.Errorf("after cap: count=%d, want 3", a.CheckedInCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check in again — already full, should stay at 3
|
|
||||||
a, _ = app.checkInAttendee(1, admin.ID, 1)
|
|
||||||
if a.CheckedInCount != 3 {
|
|
||||||
t.Errorf("after full: count=%d, want 3", a.CheckedInCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateToken(t *testing.T) {
|
func TestGenerateToken(t *testing.T) {
|
||||||
token, err := generateToken()
|
token, err := generateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { getSession, clearSession } from './db.js'
|
import { getSession, saveSession, clearSession } from './db.js'
|
||||||
import { syncPull, startSSE, startSyncLoop } from './sync.js'
|
import { syncPull, startSSE, startSyncLoop } from './sync.js'
|
||||||
import Login from './pages/Login.svelte'
|
import Login from './pages/Login.svelte'
|
||||||
import Dashboard from './pages/Dashboard.svelte'
|
import Dashboard from './pages/Dashboard.svelte'
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
let route = $state(window.location.pathname)
|
let route = $state(window.location.pathname)
|
||||||
let updateAvailable = $state(false)
|
let updateAvailable = $state(false)
|
||||||
let mobileNavOpen = $state(false)
|
let mobileNavOpen = $state(false)
|
||||||
|
let ssoError = $state('')
|
||||||
|
|
||||||
// Check if this is a public page (no auth needed)
|
// Check if this is a public page (no auth needed)
|
||||||
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||||
|
|
@ -36,6 +37,7 @@
|
||||||
history.pushState(null, '', path)
|
history.pushState(null, '', path)
|
||||||
route = path
|
route = path
|
||||||
mobileNavOpen = false
|
mobileNavOpen = false
|
||||||
|
window.scrollTo(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkVersion() {
|
async function checkVersion() {
|
||||||
|
|
@ -54,7 +56,32 @@
|
||||||
loading = false
|
loading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
session = await getSession()
|
|
||||||
|
// Handle SSO callback in URL fragment
|
||||||
|
const hash = window.location.hash
|
||||||
|
if (hash.startsWith('#sso_token=')) {
|
||||||
|
const token = decodeURIComponent(hash.slice('#sso_token='.length))
|
||||||
|
history.replaceState(null, '', '/')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
if (res.ok) {
|
||||||
|
const user = await res.json()
|
||||||
|
await saveSession(token, user)
|
||||||
|
session = { token, user }
|
||||||
|
} else {
|
||||||
|
ssoError = 'SSO login failed. Please try again.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ssoError = 'SSO login failed. Please try again.'
|
||||||
|
}
|
||||||
|
} else if (hash.startsWith('#sso_error=')) {
|
||||||
|
ssoError = decodeURIComponent(hash.slice('#sso_error='.length))
|
||||||
|
history.replaceState(null, '', '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
session = await getSession()
|
||||||
|
}
|
||||||
loading = false
|
loading = false
|
||||||
if (session) {
|
if (session) {
|
||||||
await syncPull()
|
await syncPull()
|
||||||
|
|
@ -83,7 +110,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = $derived(route || '/')
|
const path = $derived(route || '/')
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const roles = $derived(session?.user?.roles ?? [])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if updateAvailable}
|
{#if updateAvailable}
|
||||||
|
|
@ -102,9 +129,9 @@
|
||||||
{:else if isConfirmEmail}
|
{:else if isConfirmEmail}
|
||||||
<ConfirmEmail />
|
<ConfirmEmail />
|
||||||
{:else if !session}
|
{:else if !session}
|
||||||
<Login onlogin={onLogin} />
|
<Login onlogin={onLogin} error={ssoError} />
|
||||||
{:else if role === 'gatekeeper'}
|
{:else if roles.length === 1 && roles[0] === 'gatekeeper'}
|
||||||
<!-- Gate users get the full-screen GateKiosk instead of the standard layout -->
|
<!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
|
||||||
<GateKiosk {session} {onLogout} />
|
<GateKiosk {session} {onLogout} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
|
@ -121,10 +148,10 @@
|
||||||
<span class="mobile-brand">Turn<span class="accent">pike</span></span>
|
<span class="mobile-brand">Turn<span class="accent">pike</span></span>
|
||||||
</header>
|
</header>
|
||||||
{#if path === '/' || path === ''}
|
{#if path === '/' || path === ''}
|
||||||
{#if role === 'colead'}
|
{#if roles.length === 1 && roles[0] === 'colead'}
|
||||||
<ScheduleBoard {session} />
|
<ScheduleBoard {session} />
|
||||||
{:else}
|
{:else}
|
||||||
<Dashboard {session} />
|
<Dashboard {session} {navigate} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if path.startsWith('/participants')}
|
{:else if path.startsWith('/participants')}
|
||||||
<Participants {session} />
|
<Participants {session} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { db } from './db.js'
|
import { db, clearSession } from './db.js'
|
||||||
|
|
||||||
async function getToken() {
|
async function getToken() {
|
||||||
const session = await db.session.get(1)
|
const session = await db.session.get(1)
|
||||||
|
|
@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) {
|
||||||
|
|
||||||
const res = await fetch(path, { ...options, headers })
|
const res = await fetch(path, { ...options, headers })
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
await db.session.clear()
|
await clearSession()
|
||||||
window.location.pathname = '/login'
|
window.location.pathname = '/login'
|
||||||
throw new Error('unauthorized')
|
throw new Error('unauthorized')
|
||||||
}
|
}
|
||||||
|
|
@ -48,8 +48,8 @@ async function kioskFetch(path, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
login: (username, password) =>
|
login: (email, password) =>
|
||||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||||
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
||||||
me: () => apiJSON('/api/me'),
|
me: () => apiJSON('/api/me'),
|
||||||
event: {
|
event: {
|
||||||
|
|
@ -118,6 +118,10 @@ export const api = {
|
||||||
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
|
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
|
||||||
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
|
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
|
||||||
},
|
},
|
||||||
|
sso: {
|
||||||
|
enabled: () => kioskFetch('/api/public/sso-enabled'),
|
||||||
|
init: () => kioskFetch('/api/sso/init'),
|
||||||
|
},
|
||||||
signup: {
|
signup: {
|
||||||
config: () => kioskFetch('/api/public/signup-config'),
|
config: () => kioskFetch('/api/public/signup-config'),
|
||||||
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),
|
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,11 @@ describe('apiJSON', () => {
|
||||||
describe('api methods', () => {
|
describe('api methods', () => {
|
||||||
it('login calls correct endpoint', async () => {
|
it('login calls correct endpoint', async () => {
|
||||||
const f = mockFetch({ token: 'tok', user: { id: 1 } })
|
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]
|
const [url, opts] = f.mock.calls[0]
|
||||||
expect(url).toBe('/api/login')
|
expect(url).toBe('/api/login')
|
||||||
expect(opts.method).toBe('POST')
|
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 () => {
|
it('participants.list calls correct endpoint', async () => {
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ a:hover { color: var(--c-accent-h); }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
|
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
|
||||||
|
.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; }
|
||||||
|
.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; }
|
||||||
|
.card-hint { font-size: 0.78rem; color: var(--c-muted); }
|
||||||
|
|
||||||
/* Stats */
|
/* Stats */
|
||||||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
|
@ -103,8 +106,15 @@ input, select, textarea {
|
||||||
width: 100%; font-family: var(--font);
|
width: 100%; font-family: var(--font);
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
input[type="checkbox"] { width: auto; }
|
||||||
|
input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; }
|
||||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
|
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
|
||||||
input::placeholder { color: var(--c-muted); }
|
input::placeholder { color: var(--c-muted); }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
|
||||||
|
.form-grid .full { grid-column: 1 / -1; }
|
||||||
|
.checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; }
|
||||||
|
.checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); }
|
||||||
|
|
||||||
/* Search */
|
/* Search */
|
||||||
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||||
|
|
@ -129,6 +139,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
font-size: 0.72rem; font-weight: 600;
|
font-size: 0.72rem; font-weight: 600;
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
* + .badge { margin-left: 0.3rem; }
|
||||||
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
|
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
|
||||||
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||||
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
|
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
|
||||||
|
|
@ -234,6 +245,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
td { display: inline; padding: 0; border: none; }
|
td { display: inline; padding: 0; border: none; }
|
||||||
td:empty { display: none; }
|
td:empty { display: none; }
|
||||||
|
|
||||||
/* Forms */
|
/* Forms — 16px prevents iOS auto-zoom on focus */
|
||||||
.form-grid { grid-template-columns: 1fr !important; }
|
input, select, textarea { font-size: 16px; }
|
||||||
|
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,18 @@
|
||||||
|
|
||||||
let { session, active, onLogout, navigate, open = false } = $props()
|
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 iconProps = { size: 18, strokeWidth: 1.75 }
|
||||||
|
|
||||||
const links = $derived.by(() => {
|
const links = $derived.by(() => {
|
||||||
if (role === 'colead') return [
|
if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [
|
||||||
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
||||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||||
]
|
]
|
||||||
if (role === 'staffing') return [
|
if (!hasRole('admin') && hasRole('staffing')) return [
|
||||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,17 @@ db.version(5).stores({
|
||||||
participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at',
|
participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
db.version(6).stores({}).upgrade(async tx => {
|
||||||
|
await tx.table('session').clear()
|
||||||
|
await tx.table('meta').clear()
|
||||||
|
await tx.table('participants').clear()
|
||||||
|
await tx.table('tickets').clear()
|
||||||
|
await tx.table('departments').clear()
|
||||||
|
await tx.table('volunteers').clear()
|
||||||
|
await tx.table('shifts').clear()
|
||||||
|
await tx.table('volunteer_shifts').clear()
|
||||||
|
})
|
||||||
|
|
||||||
export async function getLastSync() {
|
export async function getLastSync() {
|
||||||
const m = await db.meta.get('last_sync')
|
const m = await db.meta.get('last_sync')
|
||||||
return m?.value ?? ''
|
return m?.value ?? ''
|
||||||
|
|
@ -69,6 +80,18 @@ export async function saveSession(token, user) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearSession() {
|
export async function clearSession() {
|
||||||
await db.session.clear()
|
await db.transaction('rw',
|
||||||
await db.meta.clear()
|
[db.session, db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||||
|
async () => {
|
||||||
|
await db.session.clear()
|
||||||
|
await db.meta.clear()
|
||||||
|
await db.event.clear()
|
||||||
|
await db.participants.clear()
|
||||||
|
await db.tickets.clear()
|
||||||
|
await db.departments.clear()
|
||||||
|
await db.volunteers.clear()
|
||||||
|
await db.shifts.clear()
|
||||||
|
await db.volunteer_shifts.clear()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@ describe('session', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves and retrieves session', async () => {
|
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()
|
const s = await getSession()
|
||||||
expect(s.token).toBe('tok123')
|
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 () => {
|
it('clears session and meta', async () => {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
import { liveQuery } from 'dexie'
|
import { liveQuery } from 'dexie'
|
||||||
import { db } from '../db.js'
|
import { db } from '../db.js'
|
||||||
|
|
||||||
let { session } = $props()
|
let { session, navigate } = $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 myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
const isTicketing = $derived(['admin', 'ticketing'].includes(role))
|
const isAdmin = $derived(hasRole('admin'))
|
||||||
const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role))
|
const isStaffing = $derived(hasRole('admin', 'staffing'))
|
||||||
const isColead = $derived(role === 'colead')
|
const isColead = $derived(hasRole('colead'))
|
||||||
|
|
||||||
const event = liveQuery(() => db.event.get(1))
|
const event = liveQuery(() => db.event.get(1))
|
||||||
const allTickets = liveQuery(() => db.tickets.toArray())
|
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||||
|
|
@ -76,8 +77,8 @@
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Ticket check-in (admin/ticketing) -->
|
<!-- Ticket check-in (admin) -->
|
||||||
{#if isTicketing}
|
{#if isAdmin}
|
||||||
<h2 class="dash-section">Ticket Check-in</h2>
|
<h2 class="dash-section">Ticket Check-in</h2>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
|
|
@ -105,7 +106,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Volunteer stats (admin/ticketing/staffing/colead) -->
|
<!-- Volunteer stats (admin/staffing/colead) -->
|
||||||
{#if isStaffing || isColead}
|
{#if isStaffing || isColead}
|
||||||
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
|
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
|
|
@ -124,7 +125,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Shift coverage (admin/ticketing/staffing/colead) -->
|
<!-- Shift coverage (admin/staffing/colead) -->
|
||||||
{#if isStaffing || isColead}
|
{#if isStaffing || isColead}
|
||||||
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
|
|
@ -144,22 +145,22 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Quick actions -->
|
<!-- Quick actions -->
|
||||||
{#if isTicketing}
|
{#if isAdmin}
|
||||||
<div class="dash-actions">
|
<div class="dash-actions">
|
||||||
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a>
|
<a href="/import" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/import') }}>Import CSV</a>
|
||||||
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a>
|
<a href="/participants" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/participants') }}>Manage Participants</a>
|
||||||
<a href="/settings" class="btn btn-ghost btn-sm">Settings</a>
|
<a href="/settings" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/settings') }}>Settings</a>
|
||||||
</div>
|
</div>
|
||||||
{:else if isStaffing || isColead}
|
{:else if isStaffing || isColead}
|
||||||
<div class="dash-actions">
|
<div class="dash-actions">
|
||||||
<a href="/schedule" class="btn btn-ghost btn-sm">View Schedule</a>
|
<a href="/schedule" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/schedule') }}>View Schedule</a>
|
||||||
<a href="/volunteers" class="btn btn-ghost btn-sm">Manage Volunteers</a>
|
<a href="/volunteers" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/volunteers') }}>Manage Volunteers</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
|
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
|
||||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong>
|
||||||
· <span class="badge badge-role">{session?.user?.role}</span>
|
· {#each roles as r}<span class="badge badge-role">{r}</span>{/each}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@
|
||||||
let editDesc = $state('')
|
let editDesc = $state('')
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
|
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const roles = $derived(session?.user?.roles ?? [])
|
||||||
const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role))
|
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||||
const canDelete = $derived(['admin', 'ticketing'].includes(role))
|
const canCreate = $derived(hasRole('admin', 'staffing'))
|
||||||
|
const canDelete = $derived(hasRole('admin'))
|
||||||
|
|
||||||
const allDepts = liveQuery(() =>
|
const allDepts = liveQuery(() =>
|
||||||
db.departments.filter(d => !d.deleted_at).toArray()
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
|
|
@ -100,7 +101,7 @@
|
||||||
{#if showAdd && canCreate}
|
{#if showAdd && canCreate}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addDept}>
|
<form onsubmit={addDept}>
|
||||||
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
|
<div class="form-grid-3">
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label for="d-name">Name *</label>
|
<label for="d-name">Name *</label>
|
||||||
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
||||||
|
|
@ -111,7 +112,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label for="d-color">Color</label>
|
<label for="d-color">Color</label>
|
||||||
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" />
|
<input id="d-color" type="color" bind:value={newColor} class="color-input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" style="margin-top:1rem">
|
<div class="actions" style="margin-top:1rem">
|
||||||
|
|
@ -190,6 +191,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.color-input { width: 60px; padding: 0.2rem; height: 2.3rem; cursor: pointer; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.td-name { width: 100%; }
|
.td-name { width: 100%; }
|
||||||
.td-desc { width: 100%; }
|
.td-desc { width: 100%; }
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,32 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
import { saveSession } from '../db.js'
|
import { saveSession } from '../db.js'
|
||||||
|
|
||||||
let { onlogin } = $props()
|
let { onlogin, error: externalError = '' } = $props()
|
||||||
|
|
||||||
let username = $state('')
|
let email = $state('')
|
||||||
let password = $state('')
|
let password = $state('')
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
|
||||||
|
$effect(() => { if (externalError) error = externalError })
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
|
let ssoEnabled = $state(false)
|
||||||
|
let ssoLoading = $state(false)
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.sso.enabled()
|
||||||
|
ssoEnabled = res.enabled
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
error = ''
|
error = ''
|
||||||
loading = true
|
loading = true
|
||||||
try {
|
try {
|
||||||
const { token, user } = await api.login(username, password)
|
const { token, user } = await api.login(email, password)
|
||||||
await saveSession(token, user)
|
await saveSession(token, user)
|
||||||
onlogin({ token, user })
|
onlogin({ token, user })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -23,6 +35,18 @@
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startSSO() {
|
||||||
|
error = ''
|
||||||
|
ssoLoading = true
|
||||||
|
try {
|
||||||
|
const { redirect_url } = await api.sso.init()
|
||||||
|
window.location.href = redirect_url
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message || 'SSO failed'
|
||||||
|
ssoLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
|
|
@ -34,8 +58,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
<form onsubmit={submit}>
|
<form onsubmit={submit}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="email">Email</label>
|
||||||
<input id="username" bind:value={username} autocomplete="username" required />
|
<input id="email" type="email" bind:value={email} autocomplete="email" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
|
|
@ -45,5 +69,28 @@
|
||||||
{loading ? 'Signing in…' : 'Sign in'}
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{#if ssoEnabled}
|
||||||
|
<div class="sso-divider"><span>or</span></div>
|
||||||
|
<button class="btn btn-ghost" style="width:100%" onclick={startSSO} disabled={ssoLoading}>
|
||||||
|
{ssoLoading ? 'Redirecting…' : 'Log in with Discourse'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sso-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1rem 0;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--c-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.sso-divider::before,
|
||||||
|
.sso-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
let showAdd = $state(false)
|
let showAdd = $state(false)
|
||||||
let adding = $state(false)
|
let adding = $state(false)
|
||||||
let newName = $state('')
|
let newName = $state('')
|
||||||
|
let newTicketedName = $state('')
|
||||||
let newEmail = $state('')
|
let newEmail = $state('')
|
||||||
let newPhone = $state('')
|
let newPhone = $state('')
|
||||||
let newPronouns = $state('')
|
let newPronouns = $state('')
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
// Edit participant
|
// Edit participant
|
||||||
let editId = $state(null)
|
let editId = $state(null)
|
||||||
let editName = $state('')
|
let editName = $state('')
|
||||||
|
let editTicketedName = $state('')
|
||||||
let editEmail = $state('')
|
let editEmail = $state('')
|
||||||
let editPhone = $state('')
|
let editPhone = $state('')
|
||||||
let editPronouns = $state('')
|
let editPronouns = $state('')
|
||||||
|
|
@ -40,8 +42,9 @@
|
||||||
let newTicketType = $state('')
|
let newTicketType = $state('')
|
||||||
let newTicketExtId = $state('')
|
let newTicketExtId = $state('')
|
||||||
|
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const roles = $derived(session?.user?.roles ?? [])
|
||||||
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||||
|
const canManage = $derived(hasRole('admin'))
|
||||||
|
|
||||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||||
const allTickets = liveQuery(() => db.tickets.toArray())
|
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||||
|
|
@ -150,12 +153,12 @@
|
||||||
adding = true; error = ''
|
adding = true; error = ''
|
||||||
try {
|
try {
|
||||||
const p = await api.participants.create({
|
const p = await api.participants.create({
|
||||||
preferred_name: newName, email: newEmail, phone: newPhone,
|
preferred_name: newName, ticket_name: newTicketedName, email: newEmail,
|
||||||
pronouns: newPronouns, note: newNote,
|
phone: newPhone, pronouns: newPronouns, note: newNote,
|
||||||
})
|
})
|
||||||
await db.participants.put(p)
|
await db.participants.put(p)
|
||||||
showAdd = false
|
showAdd = false
|
||||||
newName = newEmail = newPhone = newPronouns = newNote = ''
|
newName = newTicketedName = newEmail = newPhone = newPronouns = newNote = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -166,6 +169,7 @@
|
||||||
function startEdit(p) {
|
function startEdit(p) {
|
||||||
editId = p.id
|
editId = p.id
|
||||||
editName = p.preferred_name
|
editName = p.preferred_name
|
||||||
|
editTicketedName = p.ticket_name || ''
|
||||||
editEmail = p.email
|
editEmail = p.email
|
||||||
editPhone = p.phone
|
editPhone = p.phone
|
||||||
editPronouns = p.pronouns
|
editPronouns = p.pronouns
|
||||||
|
|
@ -177,8 +181,8 @@
|
||||||
saving = true; error = ''
|
saving = true; error = ''
|
||||||
try {
|
try {
|
||||||
const p = await api.participants.update(editId, {
|
const p = await api.participants.update(editId, {
|
||||||
preferred_name: editName, email: editEmail, phone: editPhone,
|
preferred_name: editName, ticket_name: editTicketedName, email: editEmail,
|
||||||
pronouns: editPronouns, note: editNote,
|
phone: editPhone, pronouns: editPronouns, note: editNote,
|
||||||
})
|
})
|
||||||
await db.participants.put(p)
|
await db.participants.put(p)
|
||||||
editId = null
|
editId = null
|
||||||
|
|
@ -244,11 +248,15 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addParticipant}>
|
<form onsubmit={addParticipant}>
|
||||||
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="p-name">Name</label>
|
<label for="p-name">Preferred Name</label>
|
||||||
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="p-tname">Ticketed Name</label>
|
||||||
|
<input id="p-tname" bind:value={newTicketedName} placeholder="Legal/ticketed name" />
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="p-email">Email</label>
|
<label for="p-email">Email</label>
|
||||||
<input id="p-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
<input id="p-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||||
|
|
@ -323,7 +331,7 @@
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Preferred Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Tickets</th>
|
<th>Tickets</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
|
@ -343,6 +351,7 @@
|
||||||
<form class="participant-edit-form" onsubmit={saveEdit}>
|
<form class="participant-edit-form" onsubmit={saveEdit}>
|
||||||
<div class="edit-fields">
|
<div class="edit-fields">
|
||||||
<input bind:value={editName} placeholder="Preferred name" />
|
<input bind:value={editName} placeholder="Preferred name" />
|
||||||
|
<input bind:value={editTicketedName} placeholder="Ticketed name" />
|
||||||
<input type="email" bind:value={editEmail} placeholder="Email" />
|
<input type="email" bind:value={editEmail} placeholder="Email" />
|
||||||
<input bind:value={editPhone} placeholder="Phone" />
|
<input bind:value={editPhone} placeholder="Phone" />
|
||||||
<input bind:value={editPronouns} placeholder="Pronouns" />
|
<input bind:value={editPronouns} placeholder="Pronouns" />
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,9 @@
|
||||||
let assignVolID = $state(0)
|
let assignVolID = $state(0)
|
||||||
let assigning = $state(false)
|
let assigning = $state(false)
|
||||||
|
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const roles = $derived(session?.user?.roles ?? [])
|
||||||
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
|
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 myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
|
||||||
const allDepts = liveQuery(() =>
|
const allDepts = liveQuery(() =>
|
||||||
|
|
@ -54,7 +55,7 @@
|
||||||
// Departments visible to this user
|
// Departments visible to this user
|
||||||
const visibleDepts = $derived.by(() => {
|
const visibleDepts = $derived.by(() => {
|
||||||
const depts = $allDepts ?? []
|
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
|
return depts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -134,11 +135,13 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.shifts.reorder(positions)
|
const res = await api.shifts.reorder(positions)
|
||||||
if (res && !res.ok) throw new Error()
|
if (res && !res.ok) throw new Error('Reorder failed')
|
||||||
for (const p of positions) {
|
await db.transaction('rw', db.shifts, async () => {
|
||||||
const s = await db.shifts.get(p.id)
|
for (const p of positions) {
|
||||||
if (s) await db.shifts.put({ ...s, position: p.position })
|
const s = await db.shifts.get(p.id)
|
||||||
}
|
if (s) await db.shifts.put({ ...s, position: p.position })
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +275,7 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addShift}>
|
<form onsubmit={addShift}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="s-dept">Department *</label>
|
<label for="s-dept">Department *</label>
|
||||||
<select id="s-dept" bind:value={newDeptID} required>
|
<select id="s-dept" bind:value={newDeptID} required>
|
||||||
|
|
@ -377,18 +380,20 @@
|
||||||
<span class="board-cap">{assigned.length}</span>
|
<span class="board-cap">{assigned.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasConflict}
|
{#if hasConflict}
|
||||||
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span>
|
<span class="badge badge-lead">⚠ conflict</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="board-shift-actions">
|
{#if canManage}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
|
<div class="board-shift-actions">
|
||||||
<button class="btn btn-ghost btn-sm" title="Move up"
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
|
||||||
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
<button class="btn btn-ghost btn-sm" title="Move up"
|
||||||
<button class="btn btn-ghost btn-sm" title="Move down"
|
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
||||||
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
<button class="btn btn-ghost btn-sm" title="Move down"
|
||||||
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
|
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
||||||
</div>
|
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assigned volunteers -->
|
<!-- Assigned volunteers -->
|
||||||
|
|
@ -403,34 +408,36 @@
|
||||||
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
||||||
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>
|
{#if canManage}<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Assign volunteer -->
|
<!-- Assign volunteer -->
|
||||||
{#if assigningShiftID === shift.id}
|
{#if canManage}
|
||||||
<div class="board-assign-row">
|
{#if assigningShiftID === shift.id}
|
||||||
<select bind:value={assignVolID} style="width:auto">
|
<div class="board-assign-row">
|
||||||
<option value={0}>— Select volunteer —</option>
|
<select bind:value={assignVolID} style="width:auto">
|
||||||
{#each ($allVolunteers ?? [])
|
<option value={0}>— Select volunteer —</option>
|
||||||
.filter(v => v.department_id === shift.department_id)
|
{#each ($allVolunteers ?? [])
|
||||||
.filter(v => !assigned.some(a => a.volunteer.id === v.id))
|
.filter(v => v.department_id === shift.department_id)
|
||||||
as v}
|
.filter(v => !assigned.some(a => a.volunteer.id === v.id))
|
||||||
<option value={v.id}>{v.name}</option>
|
as v}
|
||||||
{/each}
|
<option value={v.id}>{v.name}</option>
|
||||||
</select>
|
{/each}
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => doAssign(shift.id)} disabled={!assignVolID || assigning}>
|
</select>
|
||||||
{assigning ? '…' : 'Assign'}
|
<button class="btn btn-primary btn-sm" onclick={() => doAssign(shift.id)} disabled={!assignVolID || assigning}>
|
||||||
</button>
|
{assigning ? '…' : 'Assign'}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => doAssignForce(shift.id)} disabled={!assignVolID || assigning} title="Assign ignoring conflicts">
|
</button>
|
||||||
Force
|
<button class="btn btn-ghost btn-sm" onclick={() => doAssignForce(shift.id)} disabled={!assignVolID || assigning} title="Assign ignoring conflicts">
|
||||||
</button>
|
Force
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => { assigningShiftID = null; assignVolID = 0 }}>Cancel</button>
|
</button>
|
||||||
</div>
|
<button class="btn btn-ghost btn-sm" onclick={() => { assigningShiftID = null; assignVolID = 0 }}>Cancel</button>
|
||||||
{:else}
|
</div>
|
||||||
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
|
{:else}
|
||||||
|
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
let savingEvent = $state(false)
|
let savingEvent = $state(false)
|
||||||
let testing = $state(false)
|
let testing = $state(false)
|
||||||
|
let resetting = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let success = $state('')
|
let success = $state('')
|
||||||
|
|
||||||
|
|
@ -26,6 +27,8 @@
|
||||||
let eventEndDate = $state('')
|
let eventEndDate = $state('')
|
||||||
let eventTimezone = $state('')
|
let eventTimezone = $state('')
|
||||||
const timezones = Intl.supportedValuesOf('timeZone')
|
const timezones = Intl.supportedValuesOf('timeZone')
|
||||||
|
let discourseSSOUrl = $state('')
|
||||||
|
let discourseSSOSecret = $state('')
|
||||||
let shiftSignupsOpen = $state(false)
|
let shiftSignupsOpen = $state(false)
|
||||||
let togglingSignups = $state(false)
|
let togglingSignups = $state(false)
|
||||||
|
|
||||||
|
|
@ -49,6 +52,8 @@
|
||||||
baseURL = s.base_url ?? ''
|
baseURL = s.base_url ?? ''
|
||||||
noteLabel = s.volunteer_note_label ?? 'Additional note'
|
noteLabel = s.volunteer_note_label ?? 'Additional note'
|
||||||
noteRequired = s.volunteer_note_required ?? false
|
noteRequired = s.volunteer_note_required ?? false
|
||||||
|
discourseSSOUrl = s.discourse_sso_url ?? ''
|
||||||
|
discourseSSOSecret = ''
|
||||||
shiftSignupsOpen = s.shift_signups_open ?? false
|
shiftSignupsOpen = s.shift_signups_open ?? false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
|
|
@ -89,14 +94,17 @@
|
||||||
smtp_host: smtpHost,
|
smtp_host: smtpHost,
|
||||||
smtp_port: smtpPort,
|
smtp_port: smtpPort,
|
||||||
smtp_user: smtpUser,
|
smtp_user: smtpUser,
|
||||||
smtp_password: smtpPassword, // empty = keep existing
|
smtp_password: smtpPassword,
|
||||||
smtp_from: smtpFrom,
|
smtp_from: smtpFrom,
|
||||||
smtp_from_name: smtpFromName,
|
smtp_from_name: smtpFromName,
|
||||||
base_url: baseURL,
|
base_url: baseURL,
|
||||||
volunteer_note_label: noteLabel,
|
volunteer_note_label: noteLabel,
|
||||||
volunteer_note_required: noteRequired,
|
volunteer_note_required: noteRequired,
|
||||||
|
discourse_sso_url: discourseSSOUrl,
|
||||||
|
discourse_sso_secret: discourseSSOSecret,
|
||||||
})
|
})
|
||||||
smtpPassword = ''
|
smtpPassword = ''
|
||||||
|
discourseSSOSecret = ''
|
||||||
success = 'Settings saved.'
|
success = 'Settings saved.'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
|
|
@ -123,7 +131,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetModel(label, fn) {
|
async function resetModel(label, fn) {
|
||||||
|
if (resetting) return
|
||||||
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
|
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
|
||||||
|
resetting = true
|
||||||
error = ''
|
error = ''
|
||||||
success = ''
|
success = ''
|
||||||
try {
|
try {
|
||||||
|
|
@ -131,6 +141,8 @@
|
||||||
success = `Deleted ${result.deleted} ${label}.`
|
success = `Deleted ${result.deleted} ${label}.`
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
resetting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,14 +178,14 @@
|
||||||
<div class="text-muted">Loading…</div>
|
<div class="text-muted">Loading…</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={saveEvent}>
|
<form onsubmit={saveEvent}>
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2>
|
<h2 class="card-title">Event</h2>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid">
|
||||||
<div class="form-group" style="grid-column:1/-1">
|
<div class="form-group full">
|
||||||
<label for="e-name">Event Name *</label>
|
<label for="e-name">Event Name *</label>
|
||||||
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
|
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="grid-column:1/-1">
|
<div class="form-group full">
|
||||||
<label for="e-venue">Venue</label>
|
<label for="e-venue">Venue</label>
|
||||||
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
|
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -185,7 +197,7 @@
|
||||||
<label for="e-end">End Date *</label>
|
<label for="e-end">End Date *</label>
|
||||||
<input id="e-end" type="date" bind:value={eventEndDate} required />
|
<input id="e-end" type="date" bind:value={eventEndDate} required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="grid-column:1/-1">
|
<div class="form-group full">
|
||||||
<label for="e-tz">Timezone</label>
|
<label for="e-tz">Timezone</label>
|
||||||
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
|
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
|
||||||
<datalist id="tz-list">
|
<datalist id="tz-list">
|
||||||
|
|
@ -204,11 +216,11 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form onsubmit={save}>
|
<form onsubmit={save}>
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
<h2 class="card-title">SMTP Email</h2>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid">
|
||||||
<div class="form-group" style="grid-column:1">
|
<div class="form-group">
|
||||||
<label for="s-host">SMTP Host</label>
|
<label for="s-host">SMTP Host</label>
|
||||||
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
|
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -236,10 +248,27 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for kiosk links in emails)</span></label>
|
<label for="s-url">Base URL <span class="card-hint" style="font-weight:400">(for kiosk links in emails)</span></label>
|
||||||
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="card-title" style="margin-top:1.5rem">Discourse SSO</h2>
|
||||||
|
<p class="card-hint" style="margin-bottom:1rem">
|
||||||
|
Enable DiscourseConnect SSO so users can log in with their Discourse account.
|
||||||
|
Set the same secret in your Discourse admin under Connect > discourse connect secret.
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full">
|
||||||
|
<label for="sso-url">Discourse URL</label>
|
||||||
|
<input id="sso-url" bind:value={discourseSSOUrl} placeholder="https://forum.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group full">
|
||||||
|
<label for="sso-secret">SSO Secret</label>
|
||||||
|
<input id="sso-secret" type="password" bind:value={discourseSSOSecret}
|
||||||
|
placeholder="Leave blank to keep existing" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save Settings'}
|
{saving ? 'Saving…' : 'Save Settings'}
|
||||||
|
|
@ -249,8 +278,8 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Test email -->
|
<!-- Test email -->
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
|
<h2 class="card-title">Test Email</h2>
|
||||||
<div style="display:flex;gap:0.5rem;align-items:flex-end">
|
<div style="display:flex;gap:0.5rem;align-items:flex-end">
|
||||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||||
<label for="s-test">Send to</label>
|
<label for="s-test">Send to</label>
|
||||||
|
|
@ -263,24 +292,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Volunteer Signup -->
|
<!-- Volunteer Signup -->
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Volunteer Signup</h2>
|
<h2 class="card-title">Volunteer Signup</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="s-note-label">Note Field Label</label>
|
<label for="s-note-label">Note Field Label</label>
|
||||||
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
|
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
|
||||||
</div>
|
</div>
|
||||||
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;cursor:pointer">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" bind:checked={noteRequired} />
|
<input type="checkbox" bind:checked={noteRequired} />
|
||||||
Note field is required
|
Note field is required
|
||||||
</label>
|
</label>
|
||||||
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
|
<p class="card-hint" style="margin-top:0.75rem">
|
||||||
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
|
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shift Signups -->
|
<!-- Shift Signups -->
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2>
|
<h2 class="card-title">Shift Signups</h2>
|
||||||
<div style="display:flex;align-items:center;gap:1rem">
|
<div style="display:flex;align-items:center;gap:1rem">
|
||||||
<span style="font-size:0.875rem">
|
<span style="font-size:0.875rem">
|
||||||
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
|
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
|
||||||
|
|
@ -295,7 +324,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if !shiftSignupsOpen}
|
{#if !shiftSignupsOpen}
|
||||||
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
|
<p class="card-hint" style="margin-top:0.75rem">
|
||||||
Opening signups will email all confirmed volunteers their shift signup links.
|
Opening signups will email all confirmed volunteers their shift signup links.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -303,24 +332,24 @@
|
||||||
|
|
||||||
<!-- Data Management -->
|
<!-- Data Management -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2>
|
<h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
|
||||||
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem">
|
<p class="card-hint" style="margin-bottom:1rem">
|
||||||
Permanently delete all records of a given type. This cannot be undone.
|
Permanently delete all records of a given type. This cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
|
||||||
<button class="btn btn-danger" onclick={() => resetModel('tickets', api.settings.resetTickets)}>
|
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('tickets', api.settings.resetTickets)}>
|
||||||
Delete All Tickets
|
Delete All Tickets
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
|
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
|
||||||
Delete All Volunteers
|
Delete All Volunteers
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick={() => resetModel('shifts', api.settings.resetShifts)}>
|
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('shifts', api.settings.resetShifts)}>
|
||||||
Delete All Shifts
|
Delete All Shifts
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick={() => resetModel('departments', api.settings.resetDepartments)}>
|
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('departments', api.settings.resetDepartments)}>
|
||||||
Delete All Departments
|
Delete All Departments
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
|
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
|
||||||
Delete All Shift Assignments
|
Delete All Shift Assignments
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@
|
||||||
|
|
||||||
let showAdd = $state(false)
|
let showAdd = $state(false)
|
||||||
let adding = $state(false)
|
let adding = $state(false)
|
||||||
let newUsername = $state('')
|
let newEmail = $state('')
|
||||||
|
let newName = $state('')
|
||||||
let newPassword = $state('')
|
let newPassword = $state('')
|
||||||
let newRole = $state('gate')
|
let newRoles = $state([])
|
||||||
let newDeptIDs = $state([])
|
let newDeptIDs = $state([])
|
||||||
|
|
||||||
let editID = $state(null)
|
let editID = $state(null)
|
||||||
let editRole = $state('')
|
let editRoles = $state([])
|
||||||
let editDeptIDs = $state([])
|
let editDeptIDs = $state([])
|
||||||
let editPassword = $state('')
|
let editPassword = $state('')
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
|
|
@ -28,7 +29,7 @@
|
||||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
.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)
|
const me = $derived(session?.user?.id)
|
||||||
|
|
||||||
|
|
@ -51,15 +52,16 @@
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
const u = await api.users.create({
|
const u = await api.users.create({
|
||||||
username: newUsername,
|
email: newEmail,
|
||||||
|
preferred_name: newName,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
role: newRole,
|
roles: newRoles,
|
||||||
department_ids: newDeptIDs,
|
department_ids: newDeptIDs,
|
||||||
})
|
})
|
||||||
users = [...users, u]
|
users = [...users, u]
|
||||||
showAdd = false
|
showAdd = false
|
||||||
newUsername = newPassword = ''
|
newEmail = newName = newPassword = ''
|
||||||
newRole = 'gate'
|
newRoles = []
|
||||||
newDeptIDs = []
|
newDeptIDs = []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
|
|
@ -70,7 +72,7 @@
|
||||||
|
|
||||||
function startEdit(u) {
|
function startEdit(u) {
|
||||||
editID = u.id
|
editID = u.id
|
||||||
editRole = u.role
|
editRoles = [...(u.roles || [])]
|
||||||
editDeptIDs = [...(u.department_ids || [])]
|
editDeptIDs = [...(u.department_ids || [])]
|
||||||
editPassword = ''
|
editPassword = ''
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +85,7 @@
|
||||||
saving = true
|
saving = true
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
const payload = { role: editRole, department_ids: editDeptIDs }
|
const payload = { roles: editRoles, department_ids: editDeptIDs }
|
||||||
if (editPassword) payload.password = editPassword
|
if (editPassword) payload.password = editPassword
|
||||||
const updated = await api.users.update(u.id, payload)
|
const updated = await api.users.update(u.id, payload)
|
||||||
users = users.map(x => x.id === u.id ? updated : x)
|
users = users.map(x => x.id === u.id ? updated : x)
|
||||||
|
|
@ -96,7 +98,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(u) {
|
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 {
|
try {
|
||||||
await api.users.delete(u.id)
|
await api.users.delete(u.id)
|
||||||
users = users.filter(x => x.id !== 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)
|
const idx = list.indexOf(id)
|
||||||
if (idx === -1) return [...list, id]
|
if (idx === -1) return [...list, id]
|
||||||
return list.filter(x => x !== id)
|
return list.filter(x => x !== id)
|
||||||
|
|
@ -117,7 +119,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleLabel(r) {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -132,7 +134,6 @@
|
||||||
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.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>
|
<strong style="color:var(--c-text)">Roles:</strong>
|
||||||
admin — full access ·
|
admin — full access ·
|
||||||
ticketing — participants, tickets, import ·
|
|
||||||
staffing — volunteers, shifts, departments ·
|
staffing — volunteers, shifts, departments ·
|
||||||
colead — manage assigned departments only ·
|
colead — manage assigned departments only ·
|
||||||
gatekeeper — check-in only
|
gatekeeper — check-in only
|
||||||
|
|
@ -148,22 +149,31 @@
|
||||||
{#if showAdd}
|
{#if showAdd}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addUser}>
|
<form onsubmit={addUser}>
|
||||||
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
<div class="form-grid-3">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="u-username">Username *</label>
|
<label for="u-email">Email *</label>
|
||||||
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
<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">Preferred Name</label>
|
||||||
|
<input id="u-name" bind:value={newName} placeholder="Preferred name" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="u-password">Password *</label>
|
<label for="u-password">Password *</label>
|
||||||
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="u-role">Role *</label>
|
<div class="form-group">
|
||||||
<select id="u-role" bind:value={newRole}>
|
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span>
|
||||||
{#each roles as r}
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||||
<option value={r}>{roleLabel(r)}</option>
|
{#each availableRoles as r}
|
||||||
{/each}
|
<label class="checkbox-label">
|
||||||
</select>
|
<input type="checkbox"
|
||||||
|
checked={newRoles.includes(r)}
|
||||||
|
onchange={() => newRoles = toggleItem(r, newRoles)} />
|
||||||
|
{roleLabel(r)}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if ($allDepts ?? []).length > 0}
|
{#if ($allDepts ?? []).length > 0}
|
||||||
|
|
@ -171,10 +181,10 @@
|
||||||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
|
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||||
{#each $allDepts ?? [] as d}
|
{#each $allDepts ?? [] as d}
|
||||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" style="width:auto"
|
<input type="checkbox"
|
||||||
checked={newDeptIDs.includes(d.id)}
|
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>
|
<span class="dept-dot" style="background:{d.color}"></span>
|
||||||
{d.name}
|
{d.name}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -204,8 +214,8 @@
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>Preferred Name</th>
|
||||||
<th>Role</th>
|
<th>Roles</th>
|
||||||
<th>Departments</th>
|
<th>Departments</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -214,22 +224,27 @@
|
||||||
{#each users as u (u.id)}
|
{#each users as u (u.id)}
|
||||||
{#if editID === u.id}
|
{#if editID === u.id}
|
||||||
<tr class="edit-row">
|
<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>
|
<td>
|
||||||
<select bind:value={editRole} style="width:auto;margin:0">
|
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||||
{#each roles as r}
|
{#each availableRoles as r}
|
||||||
<option value={r}>{roleLabel(r)}</option>
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={editRoles.includes(r)}
|
||||||
|
onchange={() => editRoles = toggleItem(r, editRoles)} />
|
||||||
|
{roleLabel(r)}
|
||||||
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{#if ($allDepts ?? []).length > 0}
|
{#if ($allDepts ?? []).length > 0}
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||||
{#each $allDepts ?? [] as d}
|
{#each $allDepts ?? [] as d}
|
||||||
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
<label class="checkbox-label-sm">
|
||||||
<input type="checkbox" style="width:auto"
|
<input type="checkbox"
|
||||||
checked={editDeptIDs.includes(d.id)}
|
checked={editDeptIDs.includes(d.id)}
|
||||||
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
|
onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
|
||||||
{d.name}
|
{d.name}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -251,18 +266,19 @@
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="td-name">
|
<td class="td-name">
|
||||||
<strong>{u.username}</strong>
|
<strong>{u.preferred_name || u.email}</strong>
|
||||||
{#if u.id === me}
|
{#if u.id === me}
|
||||||
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
<span class="badge badge-role">you</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
<td>{#each u.roles ?? [] as r}<span class="badge badge-role">{roleLabel(r)}</span>{/each}</td>
|
||||||
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||||
<td class="td-actions">
|
<td class="td-actions">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||||
{#if u.id !== me}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,17 @@
|
||||||
let editIsLead = $state(false)
|
let editIsLead = $state(false)
|
||||||
let editNote = $state('')
|
let editNote = $state('')
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
|
let confirmingID = $state(null)
|
||||||
|
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const roles = $derived(session?.user?.roles ?? [])
|
||||||
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
|
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||||
const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role))
|
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||||
|
const canConfirm = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
|
||||||
let deptInitialized = $state(false)
|
let deptInitialized = $state(false)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) {
|
if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) {
|
||||||
filterDept = String(myDeptIDs[0])
|
filterDept = String(myDeptIDs[0])
|
||||||
deptInitialized = true
|
deptInitialized = true
|
||||||
}
|
}
|
||||||
|
|
@ -75,11 +77,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmVolunteer(v) {
|
async function confirmVolunteer(v) {
|
||||||
|
if (confirmingID) return
|
||||||
|
confirmingID = v.id
|
||||||
try {
|
try {
|
||||||
const updated = await api.volunteers.confirm(v.id)
|
const updated = await api.volunteers.confirm(v.id)
|
||||||
await db.volunteers.put(updated)
|
await db.volunteers.put(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
confirmingID = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,7 +186,7 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addVolunteer}>
|
<form onsubmit={addVolunteer}>
|
||||||
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="v-name">Preferred Name *</label>
|
<label for="v-name">Preferred Name *</label>
|
||||||
<input id="v-name" bind:value={newName} required placeholder="What they go by" />
|
<input id="v-name" bind:value={newName} required placeholder="What they go by" />
|
||||||
|
|
@ -208,8 +214,8 @@
|
||||||
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
|
<input type="checkbox" bind:checked={newIsLead} />
|
||||||
Department lead
|
Department lead
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,7 +261,7 @@
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Preferred Name</th>
|
||||||
<th>Department</th>
|
<th>Department</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
|
|
@ -280,8 +286,8 @@
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="td-edit-checks">
|
<td class="td-edit-checks">
|
||||||
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap">
|
<label class="checkbox-label-sm" style="white-space:nowrap">
|
||||||
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead
|
<input type="checkbox" bind:checked={editIsLead} /> Co-Lead
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="td-edit-note">
|
<td class="td-edit-note">
|
||||||
|
|
@ -295,16 +301,20 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
|
{@const participant = participantFor(v.participant_id)}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="td-name">
|
<td class="td-name">
|
||||||
<strong>{v.name}</strong>
|
<strong>{v.name}</strong>
|
||||||
{#if v.is_lead}
|
{#if v.is_lead}
|
||||||
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
<span class="badge badge-lead">Co-Lead</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !v.participant_id}
|
{#if !v.participant_id}
|
||||||
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span>
|
<span class="badge badge-unchecked" title="Not linked to a participant">No ticket</span>
|
||||||
{:else if !participantHasTickets(v.participant_id)}
|
{:else if !participantHasTickets(v.participant_id)}
|
||||||
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span>
|
<span class="badge badge-partial" title="No ticket on file">No ticket</span>
|
||||||
|
{/if}
|
||||||
|
{#if participant?.ticket_name && participant.ticket_name !== v.name}
|
||||||
|
<div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if v.email}
|
{#if v.email}
|
||||||
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
||||||
|
|
@ -344,7 +354,7 @@
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td class="td-actions">
|
<td class="td-actions">
|
||||||
{#if canConfirm && v.email_confirmed && !v.confirmed}
|
{#if canConfirm && v.email_confirmed && !v.confirmed}
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
|
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)} disabled={confirmingID === v.id}>Confirm</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,36 @@ import { api } from './api.js'
|
||||||
let syncing = false
|
let syncing = false
|
||||||
let sseSource = null
|
let sseSource = null
|
||||||
|
|
||||||
|
async function checkBuildChanged() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/version')
|
||||||
|
const { build } = await res.json()
|
||||||
|
if (!build) return
|
||||||
|
const stored = await db.meta.get('build')
|
||||||
|
if (!stored || stored.value !== build) {
|
||||||
|
await db.transaction('rw',
|
||||||
|
[db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||||
|
async () => {
|
||||||
|
await db.meta.clear()
|
||||||
|
await db.event.clear()
|
||||||
|
await db.participants.clear()
|
||||||
|
await db.tickets.clear()
|
||||||
|
await db.departments.clear()
|
||||||
|
await db.volunteers.clear()
|
||||||
|
await db.shifts.clear()
|
||||||
|
await db.volunteer_shifts.clear()
|
||||||
|
await db.meta.put({ key: 'build', value: build })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncPull() {
|
export async function syncPull() {
|
||||||
if (syncing) return
|
if (syncing) return
|
||||||
syncing = true
|
syncing = true
|
||||||
try {
|
try {
|
||||||
|
await checkBuildChanged()
|
||||||
const since = await getLastSync()
|
const since = await getLastSync()
|
||||||
const data = await api.sync.pull(since)
|
const data = await api.sync.pull(since)
|
||||||
|
|
||||||
|
|
@ -51,7 +77,7 @@ export async function syncPull() {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await setLastSync(data.server_time)
|
if (data.server_time) await setLastSync(data.server_time)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Sync pull failed:', err.message)
|
console.warn('Sync pull failed:', err.message)
|
||||||
|
|
@ -97,7 +123,7 @@ export function startSSE(onEvent) {
|
||||||
syncPull()
|
syncPull()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
connect()
|
connect()
|
||||||
|
|
@ -108,18 +134,23 @@ export function stopSSE() {
|
||||||
sseSource = null
|
sseSource = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for sync when online, with exponential backoff on failure
|
|
||||||
let syncInterval = null
|
let syncInterval = null
|
||||||
|
let onlineHandler = null
|
||||||
|
|
||||||
export function startSyncLoop(intervalMs = 30000) {
|
export function startSyncLoop(intervalMs = 30000) {
|
||||||
if (syncInterval) return
|
if (syncInterval) return
|
||||||
syncInterval = setInterval(() => {
|
syncInterval = setInterval(() => {
|
||||||
if (navigator.onLine) syncPull()
|
if (navigator.onLine) syncPull()
|
||||||
}, intervalMs)
|
}, intervalMs)
|
||||||
window.addEventListener('online', () => syncPull())
|
onlineHandler = () => syncPull()
|
||||||
|
window.addEventListener('online', onlineHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSyncLoop() {
|
export function stopSyncLoop() {
|
||||||
clearInterval(syncInterval)
|
clearInterval(syncInterval)
|
||||||
syncInterval = null
|
syncInterval = null
|
||||||
|
if (onlineHandler) {
|
||||||
|
window.removeEventListener('online', onlineHandler)
|
||||||
|
onlineHandler = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ func TestParticipantsListCreateDelete(t *testing.T) {
|
||||||
}
|
}
|
||||||
list := parseJSON(t, w)
|
list := parseJSON(t, w)
|
||||||
participants := list["participants"].([]any)
|
participants := list["participants"].([]any)
|
||||||
if len(participants) != 1 {
|
if len(participants) != 2 { // admin + Titania
|
||||||
t.Errorf("list: got %d, want 1", len(participants))
|
t.Errorf("list: got %d, want 2", len(participants))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
|
|
@ -48,8 +48,8 @@ func TestParticipantsListCreateDelete(t *testing.T) {
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
mux.ServeHTTP(w, req)
|
mux.ServeHTTP(w, req)
|
||||||
list = parseJSON(t, w)
|
list = parseJSON(t, w)
|
||||||
if ps, ok := list["participants"].([]any); ok && len(ps) != 0 {
|
if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains
|
||||||
t.Errorf("after delete: got %d, want 0", len(ps))
|
t.Errorf("after delete: got %d, want 1", len(ps))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ func TestCheckInTicketHandler(t *testing.T) {
|
||||||
|
|
||||||
func TestGatekeeperRoleCanCheckIn(t *testing.T) {
|
func TestGatekeeperRoleCanCheckIn(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{})
|
||||||
token := testToken(t, app, gate)
|
token := testToken(t, app, gate)
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ func TestGatekeeperRoleCanCheckIn(t *testing.T) {
|
||||||
|
|
||||||
func TestGatekeeperRoleCannotDelete(t *testing.T) {
|
func TestGatekeeperRoleCannotDelete(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{})
|
||||||
token := testToken(t, app, gate)
|
token := testToken(t, app, gate)
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
|
|
||||||
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Username string `json:"username"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, hash, err := app.getUserByUsername(body.Username)
|
user, hash, err := app.getLoginParticipant(body.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, "internal error", http.StatusInternalServerError)
|
writeError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
func (app *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
user, err := app.getUserByID(claims.UserID)
|
user, err := app.getUser(claims.ParticipantID)
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, user)
|
writeJSON(w, user)
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
tk, err := app.checkInTicket(id, claims.UserID)
|
tk, err := app.checkInTicket(id, claims.ParticipantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
noteLabel = "Additional note"
|
noteLabel = "Additional note"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ssoURL, ssoSecret string
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
|
||||||
|
maskedSSOSecret := ""
|
||||||
|
if ssoSecret != "" {
|
||||||
|
maskedSSOSecret = "***"
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"smtp_host": cfg.Host,
|
"smtp_host": cfg.Host,
|
||||||
"smtp_port": cfg.Port,
|
"smtp_port": cfg.Port,
|
||||||
|
|
@ -38,6 +46,8 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
"volunteer_note_label": noteLabel,
|
"volunteer_note_label": noteLabel,
|
||||||
"volunteer_note_required": noteRequired == "true",
|
"volunteer_note_required": noteRequired == "true",
|
||||||
"shift_signups_open": signupsOpen == "true",
|
"shift_signups_open": signupsOpen == "true",
|
||||||
|
"discourse_sso_url": ssoURL,
|
||||||
|
"discourse_sso_secret": maskedSSOSecret,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +59,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
|
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
|
||||||
"volunteer_note_label", "volunteer_note_required"}
|
"volunteer_note_label", "volunteer_note_required", "discourse_sso_url", "discourse_sso_secret"}
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
v, ok := body[k]
|
v, ok := body[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -58,7 +68,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
var val string
|
var val string
|
||||||
switch vv := v.(type) {
|
switch vv := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
if k == "smtp_password" && vv == "" {
|
if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val = vv
|
val = vv
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ func TestResetTickets(t *testing.T) {
|
||||||
|
|
||||||
func TestResetTicketsRequiresAdmin(t *testing.T) {
|
func TestResetTicketsRequiresAdmin(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{})
|
gate := testUserWithRoles(t, app, "Snug", []string{"gatekeeper"}, []int{})
|
||||||
token := testToken(t, app, gate)
|
token := testToken(t, app, gate)
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) {
|
||||||
|
|
||||||
func TestSettingsNonAdminRejected(t *testing.T) {
|
func TestSettingsNonAdminRejected(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{})
|
||||||
token := testToken(t, app, gate)
|
token := testToken(t, app, gate)
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,19 @@ import (
|
||||||
|
|
||||||
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
var deptID *int
|
var deptIDs []int
|
||||||
if d := q.Get("dept"); d != "" {
|
if d := q.Get("dept"); d != "" {
|
||||||
id, err := strconv.Atoi(d)
|
if id, err := strconv.Atoi(d); err == nil {
|
||||||
if err == nil {
|
deptIDs = []int{id}
|
||||||
deptID = &id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
||||||
deptID = &claims.DeptIDs[0]
|
deptIDs = claims.DeptIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since"))
|
shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -40,7 +39,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +64,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.Role == "colead" {
|
if isCoLeadOnly(claims) {
|
||||||
existing, _ := app.getShift(id)
|
existing, _ := app.getShift(id)
|
||||||
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
|
@ -87,6 +86,14 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
s, _ := app.getShift(id)
|
||||||
|
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := app.deleteShift(id); err != nil {
|
if err := app.deleteShift(id); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -111,6 +118,14 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques
|
||||||
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
s, _ := app.getShift(shiftID)
|
||||||
|
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !body.Force {
|
if !body.Force {
|
||||||
conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID)
|
conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID)
|
||||||
|
|
@ -149,6 +164,14 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ
|
||||||
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
s, _ := app.getShift(shiftID)
|
||||||
|
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -167,6 +190,16 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
for _, p := range raw {
|
||||||
|
s, _ := app.getShift(p.ID)
|
||||||
|
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
positions := make([]struct{ ID, Position int }, len(raw))
|
positions := make([]struct{ ID, Position int }, len(raw))
|
||||||
for i, p := range raw {
|
for i, p := range raw {
|
||||||
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,86 @@ func TestShiftAssignConflict(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoLeadDeleteShiftOtherDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadDeleteShiftOwnDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||||
|
deptBID := deptB.ID
|
||||||
|
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{
|
||||||
|
"volunteer_id": v.ID,
|
||||||
|
}, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadReorderShiftsOtherDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||||
|
s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{
|
||||||
|
{"id": s1.ID, "position": 2},
|
||||||
|
{"id": s2.ID, "position": 1},
|
||||||
|
}, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for other dept reorder, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShiftReorder(t *testing.T) {
|
func TestShiftReorder(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
admin := testAdminUser(t, app)
|
admin := testAdminUser(t, app)
|
||||||
|
|
|
||||||
190
handle_sso.go
Normal file
190
handle_sso.go
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) getSSOConfig() (ssoURL, ssoSecret string) {
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleSSOEnabled(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ssoURL, ssoSecret := app.getSSOConfig()
|
||||||
|
writeJSON(w, map[string]bool{"enabled": ssoURL != "" && ssoSecret != ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) getBaseURL() string {
|
||||||
|
if app.baseURL != "" {
|
||||||
|
return app.baseURL
|
||||||
|
}
|
||||||
|
var u string
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&u)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleSSOInit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ssoURL, ssoSecret := app.getSSOConfig()
|
||||||
|
if ssoURL == "" || ssoSecret == "" {
|
||||||
|
writeError(w, "SSO not configured", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := app.getBaseURL()
|
||||||
|
if baseURL == "" {
|
||||||
|
writeError(w, "base_url must be configured for SSO", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
nonce := hex.EncodeToString(b)
|
||||||
|
|
||||||
|
app.cleanExpiredNonces()
|
||||||
|
if err := app.createSSONonce(nonce); err != nil {
|
||||||
|
writeError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
returnURL := strings.TrimRight(baseURL, "/") + "/api/sso/callback"
|
||||||
|
|
||||||
|
payload := fmt.Sprintf("nonce=%s&return_sso_url=%s", url.QueryEscape(nonce), url.QueryEscape(returnURL))
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(ssoSecret))
|
||||||
|
mac.Write([]byte(encoded))
|
||||||
|
sig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
redirect := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s",
|
||||||
|
strings.TrimRight(ssoURL, "/"), url.QueryEscape(encoded), url.QueryEscape(sig))
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"redirect_url": redirect})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
baseURL := app.getBaseURL()
|
||||||
|
|
||||||
|
ssoRedirectError := func(msg string) {
|
||||||
|
if baseURL != "" {
|
||||||
|
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_error="+url.QueryEscape(msg), http.StatusFound)
|
||||||
|
} else {
|
||||||
|
writeError(w, msg, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ssoSecret := app.getSSOConfig()
|
||||||
|
if ssoSecret == "" {
|
||||||
|
ssoRedirectError("SSO not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ssoParam := r.URL.Query().Get("sso")
|
||||||
|
sigParam := r.URL.Query().Get("sig")
|
||||||
|
if ssoParam == "" || sigParam == "" {
|
||||||
|
ssoRedirectError("Invalid SSO response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(ssoSecret))
|
||||||
|
mac.Write([]byte(ssoParam))
|
||||||
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expectedSig), []byte(sigParam)) {
|
||||||
|
ssoRedirectError("Invalid SSO signature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(ssoParam)
|
||||||
|
if err != nil {
|
||||||
|
ssoRedirectError("Invalid SSO payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vals, err := url.ParseQuery(string(decoded))
|
||||||
|
if err != nil {
|
||||||
|
ssoRedirectError("Invalid SSO payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := vals.Get("nonce")
|
||||||
|
valid, err := app.consumeSSONonce(nonce)
|
||||||
|
if err != nil || !valid {
|
||||||
|
ssoRedirectError("SSO session expired. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.ToLower(vals.Get("email"))
|
||||||
|
if email == "" {
|
||||||
|
ssoRedirectError("No email in SSO response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := vals.Get("name")
|
||||||
|
if name == "" {
|
||||||
|
name = vals.Get("username")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _, err := app.getLoginParticipant(email)
|
||||||
|
if err != nil {
|
||||||
|
ssoRedirectError("Login failed. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
p, err := app.getParticipantByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
ssoRedirectError("Login failed. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p != nil {
|
||||||
|
if _, err := app.db.Exec(
|
||||||
|
`UPDATE participants SET login_enabled = 1, updated_at = ? WHERE id = ?`,
|
||||||
|
now(), p.ID,
|
||||||
|
); err != nil {
|
||||||
|
ssoRedirectError("Login failed. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err = app.getUser(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
ssoRedirectError("Login failed. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
if name == "" {
|
||||||
|
name = strings.Split(email, "@")[0]
|
||||||
|
}
|
||||||
|
res, err := app.db.Exec(
|
||||||
|
`INSERT INTO participants (email, preferred_name, login_enabled, updated_at) VALUES (?, ?, 1, ?)`,
|
||||||
|
email, name, now(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ssoRedirectError("Login failed. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
user, err = app.getUser(int(id))
|
||||||
|
if err != nil || user == nil {
|
||||||
|
ssoRedirectError("Login failed. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := app.signToken(user)
|
||||||
|
if err != nil {
|
||||||
|
ssoRedirectError("Login failed. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_token="+url.QueryEscape(token), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
@ -13,10 +13,10 @@ func TestSyncPullFull(t *testing.T) {
|
||||||
token := testToken(t, app, admin)
|
token := testToken(t, app, admin)
|
||||||
mux := testMux(app)
|
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"})
|
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
deptID := dept.ID
|
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"})
|
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)
|
req := testAuthRequest("GET", "/api/sync/pull", nil, token)
|
||||||
|
|
@ -32,8 +32,8 @@ func TestSyncPullFull(t *testing.T) {
|
||||||
t.Error("missing server_time")
|
t.Error("missing server_time")
|
||||||
}
|
}
|
||||||
participants := result["participants"].([]any)
|
participants := result["participants"].([]any)
|
||||||
if len(participants) != 1 {
|
if len(participants) != 2 { // admin + Titania
|
||||||
t.Errorf("participants = %d, want 1", len(participants))
|
t.Errorf("participants = %d, want 2", len(participants))
|
||||||
}
|
}
|
||||||
depts := result["departments"].([]any)
|
depts := result["departments"].([]any)
|
||||||
if len(depts) != 1 {
|
if len(depts) != 1 {
|
||||||
|
|
@ -47,14 +47,16 @@ func TestSyncPullIncremental(t *testing.T) {
|
||||||
token := testToken(t, app, admin)
|
token := testToken(t, app, admin)
|
||||||
mux := testMux(app)
|
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"})
|
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)
|
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID)
|
||||||
|
|
||||||
since := "2026-01-01T12:00:00Z"
|
since := "2026-01-01T12:00:00Z"
|
||||||
|
|
||||||
// Oberon created with default updated_at (now), which is after our since
|
// Lysander created with default updated_at (now), which is after our since
|
||||||
app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"})
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -62,14 +64,13 @@ func TestSyncPullIncremental(t *testing.T) {
|
||||||
|
|
||||||
result := parseJSON(t, w)
|
result := parseJSON(t, w)
|
||||||
participants := result["participants"].([]any)
|
participants := result["participants"].([]any)
|
||||||
// Should only include Oberon (created after `since`)
|
|
||||||
if len(participants) != 1 {
|
if len(participants) != 1 {
|
||||||
t.Errorf("incremental: got %d participants, want 1", len(participants))
|
t.Errorf("incremental: got %d participants, want 1", len(participants))
|
||||||
}
|
}
|
||||||
if len(participants) == 1 {
|
if len(participants) == 1 {
|
||||||
p := participants[0].(map[string]any)
|
p := participants[0].(map[string]any)
|
||||||
if p["preferred_name"] != "Oberon" {
|
if p["preferred_name"] != "Lysander" {
|
||||||
t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"])
|
t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,8 +81,10 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) {
|
||||||
token := testToken(t, app, admin)
|
token := testToken(t, app, admin)
|
||||||
mux := testMux(app)
|
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"})
|
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)
|
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID)
|
||||||
|
|
||||||
since := "2026-01-01T12:00:00Z"
|
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) {
|
func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Username string `json:"username"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
PreferredName string `json:"preferred_name"`
|
||||||
Role string `json:"role"`
|
Password string `json:"password"`
|
||||||
DepartmentIDs []int `json:"department_ids"`
|
Roles []string `json:"roles"`
|
||||||
|
DepartmentIDs []int `json:"department_ids"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, "invalid request", http.StatusBadRequest)
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Username == "" || body.Password == "" || body.Role == "" {
|
if body.Email == "" || body.Password == "" || len(body.Roles) == 0 {
|
||||||
writeError(w, "username, password, and role are required", http.StatusBadRequest)
|
writeError(w, "email, password, and at least one role are required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := hashPassword(body.Password)
|
hash, err := hashPassword(body.Password)
|
||||||
|
|
@ -38,7 +39,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
if body.DepartmentIDs == nil {
|
if body.DepartmentIDs == nil {
|
||||||
body.DepartmentIDs = []int{}
|
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 {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -53,10 +54,15 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
target, _ := app.getUser(id)
|
||||||
|
if target == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Role string `json:"role"`
|
Roles []string `json:"roles"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
DepartmentIDs []int `json:"department_ids"`
|
DepartmentIDs []int `json:"department_ids"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, "invalid request", http.StatusBadRequest)
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
|
@ -65,8 +71,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
if body.DepartmentIDs == nil {
|
if body.DepartmentIDs == nil {
|
||||||
body.DepartmentIDs = []int{}
|
body.DepartmentIDs = []int{}
|
||||||
}
|
}
|
||||||
if body.Role != "" {
|
if body.Roles != nil {
|
||||||
if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil {
|
if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +88,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user, _ := app.getUserByID(id)
|
user, _ := app.getUser(id)
|
||||||
writeJSON(w, user)
|
writeJSON(w, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,11 +99,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.UserID == id {
|
if claims.ParticipantID == id {
|
||||||
writeError(w, "cannot delete yourself", http.StatusBadRequest)
|
writeError(w, "cannot delete yourself", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := app.deleteUser(id); err != nil {
|
if err := app.removeUser(id); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,19 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
||||||
search := q.Get("search")
|
search := q.Get("search")
|
||||||
since := q.Get("since")
|
since := q.Get("since")
|
||||||
|
|
||||||
var deptID *int
|
var deptIDs []int
|
||||||
if d := q.Get("dept"); d != "" {
|
if d := q.Get("dept"); d != "" {
|
||||||
id, err := strconv.Atoi(d)
|
if id, err := strconv.Atoi(d); err == nil {
|
||||||
if err == nil {
|
deptIDs = []int{id}
|
||||||
deptID = &id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
||||||
deptID = &claims.DeptIDs[0]
|
deptIDs = claims.DeptIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
volunteers, err := app.listVolunteers(search, deptID, since)
|
volunteers, err := app.listVolunteers(search, deptIDs, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -55,7 +54,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.Role == "colead" {
|
if isCoLeadOnly(claims) {
|
||||||
if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -127,12 +126,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.Role == "colead" {
|
if isCoLeadOnly(claims) {
|
||||||
existing, _ := app.getVolunteer(id)
|
existing, _ := app.getVolunteer(id)
|
||||||
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
v := Volunteer{
|
v := Volunteer{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
|
@ -157,6 +160,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
v, _ := app.getVolunteer(id)
|
||||||
|
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := app.deleteVolunteer(id); err != nil {
|
if err := app.deleteVolunteer(id); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -171,7 +182,14 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
v, err := app.markVolunteerReady(id, claims.UserID)
|
if isCoLeadOnly(claims) {
|
||||||
|
v, _ := app.getVolunteer(id)
|
||||||
|
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v, err := app.markVolunteerReady(id, claims.ParticipantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -186,6 +204,14 @@ func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
v, _ := app.getVolunteer(id)
|
||||||
|
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
v, err := app.confirmVolunteer(id)
|
v, err := app.confirmVolunteer(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
@ -207,7 +233,24 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "shift_id required", http.StatusBadRequest)
|
writeError(w, "shift_id required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := app.assignShift(volunteerID, body.ShiftID); err != nil {
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
v, _ := app.getVolunteer(volunteerID)
|
||||||
|
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shift, err := app.getShift(body.ShiftID)
|
||||||
|
if err != nil || shift == nil {
|
||||||
|
writeError(w, "shift not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil {
|
||||||
|
if err == errShiftFull {
|
||||||
|
writeError(w, "shift is at capacity", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -225,6 +268,14 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid shift id", http.StatusBadRequest)
|
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if isCoLeadOnly(claims) {
|
||||||
|
v, _ := app.getVolunteer(volunteerID)
|
||||||
|
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -232,11 +283,3 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func inSlice(v int, s []int) bool {
|
|
||||||
for _, x := range s {
|
|
||||||
if x == v {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,9 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
// Ticketing role should NOT be able to confirm volunteers.
|
// Gatekeeper role should NOT be able to confirm volunteers.
|
||||||
ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil)
|
gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{})
|
||||||
tok := testToken(t, app, ticketing)
|
tok := testToken(t, app, gatekeeper)
|
||||||
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true})
|
p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true})
|
||||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
||||||
|
|
@ -75,7 +75,131 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||||
if w.Code != http.StatusForbidden {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadDeleteVolunteerOwnDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
deptAID := deptA.ID
|
||||||
|
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok))
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadDeleteVolunteerOtherDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
deptBID := deptB.ID
|
||||||
|
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadConfirmVolunteerOtherDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
deptBID := deptB.ID
|
||||||
|
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadReadyVolunteerOtherDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
deptBID := deptB.ID
|
||||||
|
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/ready", nil, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadAssignShiftOtherDept(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
deptBID := deptB.ID
|
||||||
|
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||||
|
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/shifts", map[string]any{
|
||||||
|
"shift_id": s.ID,
|
||||||
|
}, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoLeadUpdateVolunteerTargetDeptForbidden(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||||
|
tok := testToken(t, app, colead)
|
||||||
|
|
||||||
|
deptAID := deptA.ID
|
||||||
|
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
|
||||||
|
"department_id": deptB.ID,
|
||||||
|
}, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 moving to other dept, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
105
main.go
105
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/me", auth(app.handleMe))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
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("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper"))
|
||||||
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin"))
|
||||||
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing"))
|
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin"))
|
||||||
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gatekeeper"))
|
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper"))
|
||||||
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing"))
|
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin"))
|
||||||
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing"))
|
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin"))
|
||||||
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing"))
|
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("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper"))
|
||||||
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin"))
|
||||||
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper"))
|
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper"))
|
||||||
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin"))
|
||||||
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin"))
|
||||||
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin"))
|
||||||
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin"))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
||||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing"))
|
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing"))
|
||||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing"))
|
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing"))
|
||||||
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing"))
|
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "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", "ticketing", "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}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "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", "ticketing", "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("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "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", "ticketing", "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", "ticketing", "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("GET /api/users", auth(app.handleListUsers, "admin"))
|
||||||
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
|
||||||
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing"))
|
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin"))
|
||||||
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing"))
|
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin"))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing"))
|
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
||||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing"))
|
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
|
||||||
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
|
||||||
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin"))
|
||||||
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin"))
|
||||||
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin"))
|
||||||
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin"))
|
||||||
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing"))
|
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/pull", auth(app.handleSyncPull))
|
||||||
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
|
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
|
||||||
|
|
@ -161,9 +161,12 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
writeJSON(w, map[string]string{"build": buildID})
|
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.
|
// Public endpoints — no JWT required.
|
||||||
|
mux.HandleFunc("GET /api/public/sso-enabled", app.handleSSOEnabled)
|
||||||
|
mux.HandleFunc("GET /api/sso/init", app.handleSSOInit)
|
||||||
|
mux.HandleFunc("GET /api/sso/callback", app.handleSSOCallback)
|
||||||
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
|
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
|
||||||
mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup)
|
mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup)
|
||||||
mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail)
|
mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail)
|
||||||
|
|
@ -196,9 +199,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) bootstrapAdmin() error {
|
func (app *App) bootstrapAdmin() error {
|
||||||
adminUser := os.Getenv("TURNPIKE_ADMIN_USER")
|
adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL")
|
||||||
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
|
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
|
||||||
if adminUser == "" || adminPass == "" {
|
if adminEmail == "" || adminPass == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
n, err := app.countUsers()
|
n, err := app.countUsers()
|
||||||
|
|
@ -209,11 +212,11 @@ func (app *App) bootstrapAdmin() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = app.createUser(adminUser, hash, "admin", []int{})
|
_, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("Created admin user: %s", adminUser)
|
log.Printf("Created admin user: %s", adminEmail)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,7 +17,6 @@ func testApp(t *testing.T) *App {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
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)`)
|
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||||
return &App{
|
return &App{
|
||||||
db: db,
|
db: db,
|
||||||
|
|
@ -29,17 +29,18 @@ func testApp(t *testing.T) *App {
|
||||||
func testAdminUser(t *testing.T, app *App) *User {
|
func testAdminUser(t *testing.T, app *App) *User {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
hash, _ := hashPassword("admin123")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return u
|
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()
|
t.Helper()
|
||||||
hash, _ := hashPassword(username + "123")
|
email := strings.ToLower(name) + "@athens.example"
|
||||||
u, err := app.createUser(username, hash, role, deptIDs)
|
hash, _ := hashPassword(name + "123")
|
||||||
|
u, err := app.createUser(email, name, hash, roles, deptIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue