web: user dashboard self-service, authz realignment, session revalidation

- Account page: change password + soft-delete (optional hard-delete via env)
- Session revalidation + claims overlay on HTML and dual-accept API (KD-9)
- Authz: user editor SUP+, sweatbox I1+, config/airport ADM
- Fix pilot rating options: full scale for editors (no actor pilot ceiling)
- Refresh rejects inactive/suspended; DeleteUser for permanent self-delete
- Dashboard/nav as user home with Account + capability-gated tools
This commit is contained in:
Reese Norris
2026-07-27 18:36:41 -04:00
parent d14dab8736
commit 7d4cdba6ae
24 changed files with 2415 additions and 204 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -59,4 +59,8 @@ type UserRepository interface {
// VerifyPasswordHash verifies a User password hash.
VerifyPasswordHash(plaintext string, hash string) (ok bool)
// DeleteUser permanently removes the user row by CID.
// Returns sql.ErrNoRows if no row was deleted.
DeleteUser(cid int) error
}

View File

@@ -125,6 +125,23 @@ func (r *SQLiteUserRepository) VerifyPasswordHash(plaintext string, hash string)
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
}
// DeleteUser permanently removes the user row by CID.
// Returns sql.ErrNoRows if no row was deleted.
func (r *SQLiteUserRepository) DeleteUser(cid int) error {
result, err := r.db.Exec(`DELETE FROM users WHERE cid = ?`, cid)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil
}
// escapeLike escapes \, %, and _ for use in LIKE ... ESCAPE '\' patterns.
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)

View File

@@ -931,3 +931,39 @@ func TestListUsersLimitDefaultWithManyRows(t *testing.T) {
t.Fatalf("total=%d want %d", n, total)
}
}
func TestDeleteUser(t *testing.T) {
db, repo := setupTestDB(t)
defer db.Close()
user := &User{
Password: "password123",
FirstName: ptr("Delete"),
LastName: ptr("Me"),
NetworkRating: 1,
}
if err := repo.CreateUser(user); err != nil {
t.Fatalf("CreateUser: %v", err)
}
cid := user.CID
if err := repo.DeleteUser(cid); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
_, err := repo.GetUserByCID(cid)
if err == nil {
t.Fatal("expected ErrNoRows after delete")
}
if err != sql.ErrNoRows {
t.Fatalf("expected sql.ErrNoRows, got %v", err)
}
// Missing CID
err = repo.DeleteUser(999999)
if err == nil {
t.Fatal("expected ErrNoRows for missing CID")
}
if err != sql.ErrNoRows {
t.Fatalf("expected sql.ErrNoRows, got %v", err)
}
}

View File

@@ -11,13 +11,13 @@ import (
//
// Authenticated read proxy of FSD service HTTP GET /sweatbox/state.
// Used by sweatbox.js progressive enhancement (12 s poll). Cookie session
// or Bearer; CSRF not required for GET. Min rating Administrator.
// or Bearer; CSRF not required for GET. Min rating Instructor1+.
//
// On success (and FSD 404 disabled), the FSD JSON body is passed through so
// the PE client can use the same shape as the service control plane.
func (s *Server) handleAPISweatboxState(c *gin.Context) {
claims := getJwtContext(c)
if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator {
if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
@@ -46,9 +46,10 @@ func (s *Server) handleAPISweatboxState(c *gin.Context) {
//
// Read proxy of FSD GET /sweatbox/ops (elapsed / arr / dep / ops-per-min).
// Optional for PE; state already includes most of these fields.
// Min rating Instructor1+.
func (s *Server) handleAPISweatboxOps(c *gin.Context) {
claims := getJwtContext(c)
if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator {
if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}

View File

@@ -104,6 +104,15 @@ func (s *Server) refreshAccessToken(c *gin.Context) {
writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes)
return
}
// Soft-deleted / suspended users must not mint new access tokens.
if user.NetworkRating <= int(protocol.NetworkRatingSuspended) {
slog.Debug("refresh rejected inactive/suspended user",
"cid", claims.CID,
"event", "refresh_rejected_inactive",
)
writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes)
return
}
access, err := s.makeAccessToken(user, []byte(jwtSecret))
if err != nil {
@@ -278,17 +287,63 @@ func cutBearerToken(header string) (token string, ok bool) {
return token, true
}
// trySessionAuth parses the signed session cookie into the gin context.
// Session revalidation errors (KD-9).
var (
errSessionUserMissing = errors.New("session user missing")
errSessionInactive = errors.New("session user inactive or suspended")
)
const dbUserContextKey = "db_user"
// revalidateSessionFromDB loads the user by claims.CID, rejects missing or
// inactive/suspended certificates, and overlays NetworkRating + names from the DB.
// Handlers and requireMinRatingHTML keep reading claims.NetworkRating safely only
// because this overlay is mandatory after session cookie parse.
func (s *Server) revalidateSessionFromDB(claims *auth.CustomClaims) (*auth.CustomClaims, *db.User, error) {
user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, errSessionUserMissing
}
slog.Error("session revalidation DB error", "cid", claims.CID, "err", err)
// Fail closed: treat unexpected DB errors like a missing user.
return nil, nil, errSessionUserMissing
}
if user.NetworkRating <= int(protocol.NetworkRatingSuspended) {
slog.Debug("session rejected inactive/suspended user",
"cid", claims.CID,
"event", "session_rejected_inactive",
)
return nil, nil, errSessionInactive
}
// REQUIRED claims overlay — demotions must refresh ceilings and nav flags.
claims.NetworkRating = protocol.NetworkRating(user.NetworkRating)
claims.FirstName = safeStr(user.FirstName)
claims.LastName = safeStr(user.LastName)
return claims, user, nil
}
// trySessionAuth parses the signed session cookie, revalidates against the DB,
// and sets overlaid claims into the gin context (dual-accept API path).
func (s *Server) trySessionAuth(c *gin.Context) bool {
claims, err := s.parseSessionCookie(c)
if err != nil {
return false
}
claims, user, err := s.revalidateSessionFromDB(claims)
if err != nil {
// Clear cookies so soft-deleted PE clients stop authenticating.
s.clearSessionCookie(c)
s.clearCSRFCookie(c)
return false
}
setJwtContext(c, claims)
c.Set(dbUserContextKey, user)
return true
}
// requireSessionHTML gates privileged HTML pages: unauthenticated → 303 /login.
// requireSessionHTML gates privileged HTML pages: unauthenticated or
// inactive/missing user → clear cookies + 303 /login.
func (s *Server) requireSessionHTML(c *gin.Context) {
claims, err := s.parseSessionCookie(c)
if err != nil {
@@ -296,10 +351,29 @@ func (s *Server) requireSessionHTML(c *gin.Context) {
c.Abort()
return
}
claims, user, err := s.revalidateSessionFromDB(claims)
if err != nil {
s.clearSessionCookie(c)
s.clearCSRFCookie(c)
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}
setJwtContext(c, claims)
c.Set(dbUserContextKey, user)
c.Next()
}
// getDBUser returns the *db.User stashed by requireSessionHTML / trySessionAuth.
func getDBUser(c *gin.Context) *db.User {
val, exists := c.Get(dbUserContextKey)
if !exists {
return nil
}
u, _ := val.(*db.User)
return u
}
// requireMinRatingHTML redirects to /dashboard when the session rating is too low.
func (s *Server) requireMinRatingHTML(min protocol.NetworkRating) gin.HandlerFunc {
return func(c *gin.Context) {

View File

@@ -29,6 +29,10 @@ type ServerConfig struct {
// Values: "true"/"false" force the flag; empty (default) derives from
// TLS / X-Forwarded-Proto so local docker-compose HTTP keeps working.
CookieSecure string `env:"COOKIE_SECURE"`
// AllowPermanentAccountDelete enables the non-default hard-delete checkbox
// on POST /account/delete. Default false (soft-delete only).
AllowPermanentAccountDelete bool `env:"ALLOW_PERMANENT_ACCOUNT_DELETE, default=false"`
}
func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) {

View File

@@ -19,15 +19,28 @@ func (s *Server) handleFrontendLanding(c *gin.Context) {
func (s *Server) handleFrontendLogin(c *gin.Context) {
// Already signed in → dashboard.
// Delete path clears cookies first so the account-deleted banner is reachable.
if claims, err := s.parseSessionCookie(c); err == nil && claims != nil {
c.Redirect(http.StatusSeeOther, "/dashboard")
return
// Still revalidate: inactive sessions must not bounce to dashboard.
if _, _, err := s.revalidateSessionFromDB(claims); err == nil {
c.Redirect(http.StatusSeeOther, "/dashboard")
return
}
s.clearSessionCookie(c)
s.clearCSRFCookie(c)
}
s.writeTemplate(c, "login", loginPage{
page := loginPage{
basePage: basePage{
CSRFToken: s.issueCSRFToken(c),
},
})
}
if c.Query("account") == "deleted" {
page.Info = "Your account has been deleted."
if c.Query("permanent") == "disabled" {
page.Info += " Permanent delete is not enabled on this server; the account was deactivated instead."
}
}
s.writeTemplate(c, "login", page)
}
// handleFrontendLoginPost processes application/x-www-form-urlencoded login

View File

@@ -17,13 +17,14 @@ type pageUser struct {
LastName string
NetworkRating int
NetworkRatingLabel string
// CanEditUsers: Instructor1+ — Users page nav / directory access.
// CanEditUsers: Supervisor+ — Users page nav / /usereditor access.
CanEditUsers bool
// CanFullMutateUsers: Supervisor+ — create + name/password mutation.
CanFullMutateUsers bool
// CanAdjustRatings: Instructor1+ — network + pilot rating changes.
CanAdjustRatings bool
CanEditConfig bool
// CanAccessSweatbox: Instructor1+ — sweatbox HTML + PE JSON.
CanAccessSweatbox bool
// CanEditConfig: Administrator — config + airport editor.
CanEditConfig bool
}
// basePage is embedded by every HTML page model so layout has nav data.
@@ -39,6 +40,29 @@ type loginPage struct {
Error string
CIDError string
PassError string
Info string // success/info banner (e.g. account deleted)
}
// accountPage is the self-service account MPA model (any DB-valid session).
type accountPage struct {
basePage
FlashSuccess string
FlashError string
// Profile (read-only display)
CID int
FirstName string
LastName string
NetworkLabel string
PilotLabel string
// Password form field errors
CurrentPassError string
NewPassError string
ConfirmPassError string
FormError string
// Delete
DeleteError string
DeletePassError string // wrong current password on delete
AllowPermanentDelete bool // from cfg
}
// connectionRow is one pilot or ATC line in the dashboard summary table.
@@ -61,6 +85,8 @@ type dashboardPage struct {
SummaryAvailable bool
SummaryUnavailable bool
SummaryError string
// Pilot rating from DB (claims lack pilot_rating).
PilotRatingLabel string
}
// ratingOption is a network-rating select entry.
@@ -132,11 +158,11 @@ type userEditorPage struct {
EditLoaded bool
// RatingOptions for create/edit network selects — ratingOptionsUpTo(actorMax, selected).
RatingOptions []ratingOption
// PilotRatingOptions capped at actor's own pilot_rating.
// PilotRatingOptions is the full official pilot scale (P0…FE).
PilotRatingOptions []ratingOption
// ProfileLocked: name/password not editable (instructor, or SUP viewing higher-rated).
// ProfileLocked: name/password not editable (SUP viewing higher-rated target).
ProfileLocked bool
// RatingsLocked: no rating fields (should be rare; I1+ always may adjust within ceiling).
// RatingsLocked: no rating fields (should be rare; SUP+ editor actors may adjust).
RatingsLocked bool
// EditReadOnly: nothing editable (legacy alias: profile + ratings locked).
EditReadOnly bool
@@ -183,7 +209,7 @@ type sweatboxAircraftRow struct {
Instruction string
}
// sweatboxPage is the Administrator instructor MPA model.
// sweatboxPage is the Instructor1+ sweatbox control-panel MPA model.
type sweatboxPage struct {
basePage
FlashSuccess string
@@ -230,7 +256,7 @@ func pageUserFromClaims(claims *auth.CustomClaims) *pageUser {
NetworkRatingLabel: networkRatingLabel(rating),
CanEditUsers: canAccessUserEditor(claims.NetworkRating),
CanFullMutateUsers: canFullMutateUsers(claims.NetworkRating),
CanAdjustRatings: canAdjustUserRatings(claims.NetworkRating),
CanAccessSweatbox: canAccessSweatbox(claims.NetworkRating),
CanEditConfig: claims.NetworkRating >= protocol.NetworkRatingAdministator,
}
}

View File

@@ -0,0 +1,214 @@
package web
import (
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/pkg/protocol"
)
// handleFrontendAccount GET /account — profile + change-password + delete forms.
func (s *Server) handleFrontendAccount(c *gin.Context) {
claims, ok := requireJwtContext(c)
if !ok {
return
}
page := s.loadAccountPage(c, claims.CID)
switch c.Query("flash") {
case "password_changed":
page.FlashSuccess = "Password changed successfully."
}
s.writeTemplate(c, "account", page)
}
func (s *Server) loadAccountPage(c *gin.Context, cid int) accountPage {
claims, _ := requireJwtContext(c)
page := accountPage{
basePage: basePage{
User: pageUserFromClaims(claims),
CSRFToken: s.issueCSRFToken(c),
},
CID: cid,
AllowPermanentDelete: s.cfg != nil && s.cfg.AllowPermanentAccountDelete,
}
user := getDBUser(c)
if user == nil || user.CID != cid {
var err error
user, err = s.dbRepo.UserRepo.GetUserByCID(cid)
if err != nil {
page.FormError = "Unable to load account"
return page
}
}
page.FirstName = safeStr(user.FirstName)
page.LastName = safeStr(user.LastName)
page.NetworkLabel = networkRatingLabel(user.NetworkRating)
page.PilotLabel = pilotRatingLabel(user.PilotRating)
return page
}
// handleFrontendAccountPassword POST /account/password
func (s *Server) handleFrontendAccountPassword(c *gin.Context) {
if !s.validateCSRF(c) {
c.AbortWithStatus(http.StatusForbidden)
return
}
claims, ok := requireJwtContext(c)
if !ok {
return
}
current := c.PostForm("current_password")
newPW := c.PostForm("new_password")
confirm := c.PostForm("confirm_password")
page := s.loadAccountPage(c, claims.CID)
user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
if err != nil {
s.clearSessionCookie(c)
s.clearCSRFCookie(c)
c.Redirect(http.StatusSeeOther, "/login")
return
}
if current == "" {
page.CurrentPassError = "Current password is required"
s.writeTemplate(c, "account", page)
return
}
if !s.dbRepo.UserRepo.VerifyPasswordHash(current, user.Password) {
slog.Debug("password change rejected bad current", "cid", claims.CID)
page.CurrentPassError = "Incorrect password"
s.writeTemplate(c, "account", page)
return
}
if msg := validateNewPassword(newPW); msg != "" {
page.NewPassError = msg
s.writeTemplate(c, "account", page)
return
}
if newPW != confirm {
page.ConfirmPassError = "Passwords do not match"
s.writeTemplate(c, "account", page)
return
}
if newPW == current {
page.NewPassError = "New password must be different from the current password"
s.writeTemplate(c, "account", page)
return
}
user.Password = newPW
if err := s.dbRepo.UserRepo.UpdateUser(user); err != nil {
slog.Error("account password update failed", "cid", claims.CID, "err", err)
page.FormError = "Unable to update password"
s.writeTemplate(c, "account", page)
return
}
// KD-7: re-issue session with rememberMe=false (24h); rotate CSRF.
if err := s.setSessionCookie(c, user, false); err != nil {
slog.Error("account password session reissue failed", "cid", claims.CID, "err", err)
page.FormError = "Password updated but session could not be refreshed; please log in again"
s.writeTemplate(c, "account", page)
return
}
s.clearCSRFCookie(c)
s.issueCSRFToken(c)
slog.Info("account password changed",
"cid", claims.CID,
"event", "account_password_changed",
)
c.Redirect(http.StatusSeeOther, "/account?flash=password_changed")
}
// handleFrontendAccountDelete POST /account/delete
func (s *Server) handleFrontendAccountDelete(c *gin.Context) {
if !s.validateCSRF(c) {
c.AbortWithStatus(http.StatusForbidden)
return
}
claims, ok := requireJwtContext(c)
if !ok {
return
}
current := c.PostForm("current_password")
confirmCID := strings.TrimSpace(c.PostForm("confirm_cid"))
wantPermanent := c.PostForm("permanent") == "1" || c.PostForm("permanent") == "on"
page := s.loadAccountPage(c, claims.CID)
user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
if err != nil || user.NetworkRating <= int(protocol.NetworkRatingSuspended) {
s.clearSessionCookie(c)
s.clearCSRFCookie(c)
c.Redirect(http.StatusSeeOther, "/login")
return
}
if current == "" {
page.DeletePassError = "Current password is required"
s.writeTemplate(c, "account", page)
return
}
if !s.dbRepo.UserRepo.VerifyPasswordHash(current, user.Password) {
slog.Debug("account delete rejected bad password", "cid", claims.CID)
page.DeletePassError = "Incorrect password"
s.writeTemplate(c, "account", page)
return
}
if confirmCID != strconv.Itoa(user.CID) {
page.DeleteError = "Confirm your CID exactly to delete the account"
s.writeTemplate(c, "account", page)
return
}
allowHard := s.cfg != nil && s.cfg.AllowPermanentAccountDelete
permanentDisabled := wantPermanent && !allowHard
if wantPermanent && allowHard {
if err := s.dbRepo.UserRepo.DeleteUser(user.CID); err != nil {
slog.Error("account hard-delete failed", "cid", claims.CID, "err", err)
page.DeleteError = "Unable to delete account"
s.writeTemplate(c, "account", page)
return
}
slog.Info("account deleted",
"cid", claims.CID,
"event", "account_deleted",
"mode", "hard",
)
} else {
// Soft-delete (default, or permanent requested but disabled).
user.NetworkRating = int(protocol.NetworkRatingInactive)
user.Password = "" // keep existing hash
if err := s.dbRepo.UserRepo.UpdateUser(user); err != nil {
slog.Error("account soft-delete failed", "cid", claims.CID, "err", err)
page.DeleteError = "Unable to delete account"
s.writeTemplate(c, "account", page)
return
}
slog.Info("account deleted",
"cid", claims.CID,
"event", "account_deleted",
"mode", "soft",
)
_ = permanentDisabled
}
s.clearSessionCookie(c)
s.clearCSRFCookie(c)
loc := "/login?account=deleted"
if permanentDisabled {
loc = "/login?account=deleted&permanent=disabled"
}
c.Redirect(http.StatusSeeOther, loc)
}

View File

@@ -25,6 +25,13 @@ func (s *Server) handleFrontendDashboard(c *gin.Context) {
},
}
// Pilot rating from DB (session claims lack pilot_rating).
if user := getDBUser(c); user != nil {
page.PilotRatingLabel = pilotRatingLabel(user.PilotRating)
} else if u, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID); err == nil {
page.PilotRatingLabel = pilotRatingLabel(u.PilotRating)
}
online, err := s.fetchOnlineUsers()
if err != nil {
page.SummaryUnavailable = true

View File

@@ -44,17 +44,33 @@ func TestSweatboxPageObserverRedirect(t *testing.T) {
}
}
func TestSweatboxPageSupervisorRedirect(t *testing.T) {
func TestInstructorCanAccessSweatbox(t *testing.T) {
ts := newTestServer(t)
inst := createTestUser(t, ts, "pw", int(protocol.NetworkRatingInstructor1))
cookies := formLogin(t, ts, inst.CID, "pw")
w, _ := authedGET(t, ts, "/sweatbox", cookies)
if w.Code != http.StatusOK {
t.Fatalf("I1 GET /sweatbox status %d want 200 body %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "Sweatbox") {
t.Fatalf("expected sweatbox page, body=%s", clip(body, 400))
}
// Nav should include Sweatbox for I1
if !strings.Contains(body, `href="/sweatbox"`) {
t.Fatal("expected sweatbox nav link for I1")
}
}
func TestSweatboxPageSupervisorOK(t *testing.T) {
ts := newTestServer(t)
sup := createTestUser(t, ts, "pw", int(protocol.NetworkRatingSupervisor))
cookies := formLogin(t, ts, sup.CID, "pw")
w, _ := authedGET(t, ts, "/sweatbox", cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("status %d want 303", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/dashboard" {
t.Fatalf("Location=%q want /dashboard", loc)
if w.Code != http.StatusOK {
t.Fatalf("SUP GET /sweatbox status %d want 200", w.Code)
}
}
@@ -85,18 +101,34 @@ func TestSweatboxPageAdminShowsUnavailableWhenFSDDown(t *testing.T) {
}
}
func TestSweatboxNavOnlyForAdmin(t *testing.T) {
func TestSweatboxNavForInstructorNotObserver(t *testing.T) {
ts := newTestServer(t)
// Observer dashboard: no sweatbox nav
obs := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, obs.CID, "pw")
w, _ := authedGET(t, ts, "/dashboard", cookies)
body := w.Body.String()
// Layout nav should not include Sweatbox for non-admin (CanEditConfig false).
// Dashboard may still mention connections; check header area for Config/Sweatbox pair.
if strings.Contains(body, `href="/sweatbox">Sweatbox</a>`) {
t.Fatal("observer must not see Sweatbox nav")
}
if !strings.Contains(body, `href="/account"`) {
t.Fatal("observer dashboard should link to account")
}
inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
cookies = formLogin(t, ts, inst.CID, "inst-pass")
w, _ = authedGET(t, ts, "/dashboard", cookies)
body = w.Body.String()
if !strings.Contains(body, `href="/sweatbox"`) {
t.Fatal("I1 dashboard should link to sweatbox")
}
// I1 must not see Users or Config
if strings.Contains(body, `href="/usereditor"`) {
t.Fatal("I1 must not see Users nav")
}
if strings.Contains(body, `href="/configeditor"`) {
t.Fatal("I1 must not see Config nav")
}
admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator))
cookies = formLogin(t, ts, admin.CID, "admin-pass")
@@ -105,6 +137,9 @@ func TestSweatboxNavOnlyForAdmin(t *testing.T) {
if !strings.Contains(body, `href="/sweatbox"`) {
t.Fatal("admin dashboard should link to sweatbox")
}
if !strings.Contains(body, `href="/configeditor"`) {
t.Fatal("admin dashboard should link to config")
}
}
func TestSweatboxPOSTRequiresCSRF(t *testing.T) {
@@ -152,10 +187,10 @@ func TestSweatboxManualObserverRedirect(t *testing.T) {
}
}
func TestSweatboxManualAdminOK(t *testing.T) {
func TestSweatboxManualInstructorOK(t *testing.T) {
ts := newTestServer(t)
admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator))
cookies := formLogin(t, ts, admin.CID, "admin-pass")
inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
cookies := formLogin(t, ts, inst.CID, "inst-pass")
w, _ := authedGET(t, ts, "/sweatbox/manual", cookies)
if w.Code != http.StatusOK {
@@ -169,11 +204,16 @@ func TestSweatboxManualAdminOK(t *testing.T) {
`href="/sweatbox"`,
"add rules weight engine",
"Pattern &amp; arrival",
"Instructor1+",
} {
if !strings.Contains(body, want) {
t.Fatalf("manual missing %q, body=%s", want, clip(body, 600))
}
}
// Must not claim Administrator-only access for sweatbox role.
if strings.Contains(body, "Administrator network rating (same as Config") {
t.Fatal("manual still claims Administrator-only sweatbox access")
}
// Manual is static HTML — no FSD dependency required.
if strings.Contains(body, "Unavailable.") {
t.Fatal("manual must not depend on FSD availability banner")
@@ -198,7 +238,7 @@ func TestSweatboxPageLinksManualNewTab(t *testing.T) {
t.Fatalf("manual link should open in new tab with noopener, body=%s", clip(body, 500))
}
// Link text should stay quiet (not a primary action button).
if !strings.Contains(body, ">Manual</a>") {
if !strings.Contains(body, ">User Manual</a>") && !strings.Contains(body, ">Manual</a>") {
t.Fatal("expected muted Manual link text")
}
}

View File

@@ -24,11 +24,7 @@ func (s *Server) newUserEditorPage(c *gin.Context) userEditorPage {
if defaultRating > maxRating {
defaultRating = maxRating
}
actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
defaultPilot := 0
if defaultPilot > actorPilotMax {
defaultPilot = actorPilotMax
}
return userEditorPage{
basePage: basePage{
User: pageUserFromClaims(claims),
@@ -38,9 +34,10 @@ func (s *Server) newUserEditorPage(c *gin.Context) userEditorPage {
NetworkRating: defaultRating,
PilotRating: defaultPilot,
},
// Only offer ratings the actor may assign (server still enforces ceiling).
// Only offer network ratings the actor may assign (server still enforces ceiling).
// Pilot options are the full official scale (KD-6).
RatingOptions: ratingOptionsUpTo(maxRating, defaultRating),
PilotRatingOptions: pilotRatingOptionsUpTo(actorPilotMax, defaultPilot),
PilotRatingOptions: pilotRatingOptionsAll(defaultPilot),
}
}
@@ -51,20 +48,6 @@ func actorMaxRating(page *userEditorPage) int {
return page.User.NetworkRating
}
// actorPilotRatingCeiling loads the actor's stored pilot_rating (VATSIM scale).
// Invalid stored values fall back to the highest official rating at or below
// the stored number (or P0).
func (s *Server) actorPilotRatingCeiling(cid int) int {
u, err := s.dbRepo.UserRepo.GetUserByCID(cid)
if err != nil || u == nil {
return int(protocol.PilotRatingNone)
}
if protocol.IsValidPilotRating(u.PilotRating) {
return u.PilotRating
}
return maxValidPilotRatingAtMost(u.PilotRating)
}
// loadUserDirectory fills Dir totals/pages and Users rows for the current query.
// On DB failure sets FlashError and logs; still leaves a renderable page.
func (s *Server) loadUserDirectory(page *userEditorPage, selectedCID int) {
@@ -197,18 +180,14 @@ func (s *Server) loadUserIntoEditForm(page *userEditorPage, cidStr string) {
}
page.EditLoaded = true
actorMax := actorMaxRating(page)
actorPilotMax := int(protocol.PilotRatingNone)
if page.User != nil {
actorPilotMax = s.actorPilotRatingCeiling(page.User.CID)
}
// Profile (name/password): SUP+ and target network rating ≤ actor.
// Ratings: I1+ may always adjust within ceilings (any target).
// Ratings: SUP+ may always adjust within network ceiling (any target).
fullOK := page.User != nil && canFullMutateTarget(
protocol.NetworkRating(page.User.NetworkRating),
protocol.NetworkRating(user.NetworkRating),
)
page.ProfileLocked = !fullOK
page.RatingsLocked = page.User == nil || !page.User.CanAdjustRatings
page.RatingsLocked = page.User == nil || !page.User.CanEditUsers
page.EditReadOnly = page.ProfileLocked && page.RatingsLocked
page.Edit = userForm{
CID: strconv.Itoa(user.CID),
@@ -236,8 +215,9 @@ func (s *Server) loadUserIntoEditForm(page *userEditorPage, cidStr string) {
})
}
}
page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, user.PilotRating)
if user.PilotRating > actorPilotMax {
// Full official pilot scale (KD-6); still show invalid stored values if present.
page.PilotRatingOptions = pilotRatingOptionsAll(user.PilotRating)
if !isValidPilotRating(user.PilotRating) {
found := false
for _, o := range page.PilotRatingOptions {
if o.Value == user.PilotRating {
@@ -305,13 +285,12 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
page.Create.Password = "" // never re-render password
maxRating := int(claims.NetworkRating)
actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
rating, err := strconv.Atoi(ratingStr)
if err != nil {
page.Create.RatingError = "Select a network rating"
page.Create.NetworkRating = int(protocol.NetworkRatingObserver)
page.RatingOptions = ratingOptionsUpTo(maxRating, page.Create.NetworkRating)
page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -323,21 +302,16 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
pilotRating, err = strconv.Atoi(pilotStr)
if err != nil || !isValidPilotRating(pilotRating) {
page.Create.PilotError = "Invalid pilot rating"
page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
}
page.Create.PilotRating = pilotRating
page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, pilotRating)
page.PilotRatingOptions = pilotRatingOptionsAll(pilotRating)
if len(password) < 8 {
page.Create.PasswordError = "Password must be at least 8 characters"
s.reRenderUserEditor(c, &page, dir)
return
}
if strings.Contains(password, ":") {
page.Create.PasswordError = "Password cannot contain colon characters"
if msg := validateNewPassword(password); msg != "" {
page.Create.PasswordError = msg
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -351,11 +325,6 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
s.reRenderUserEditor(c, &page, dir)
return
}
if pilotRating > actorPilotMax {
page.Create.PilotError = "Cannot set pilot rating above your own"
s.reRenderUserEditor(c, &page, dir)
return
}
var firstPtr, lastPtr *string
if firstName != "" {
@@ -384,8 +353,8 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
// handleFrontendUserUpdate processes POST /usereditor/update (no-JS form path).
//
// Instructor1+: may set network_rating and pilot_rating up to actor ceilings on any user.
// Supervisor+: may also mutate name/password when target network rating ≤ actor.
// Supervisor+: may set network_rating (≤ actor) and any official pilot_rating on any user;
// may also mutate name/password when target network rating ≤ actor.
func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
if !s.validateCSRF(c) {
c.AbortWithStatus(http.StatusForbidden)
@@ -429,13 +398,12 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
}
maxRating := int(claims.NetworkRating)
actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
rating, err := strconv.Atoi(ratingStr)
if err != nil || rating < -1 || rating > 12 {
page.Edit.RatingError = "Invalid network rating"
page.Edit.NetworkRating = int(protocol.NetworkRatingObserver)
page.RatingOptions = ratingOptionsUpTo(maxRating, page.Edit.NetworkRating)
page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -448,22 +416,17 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
if err != nil || !isValidPilotRating(pilotRating) {
page.Edit.PilotError = "Invalid pilot rating"
page.Edit.PilotRating = int(protocol.PilotRatingNone)
page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
}
page.Edit.PilotRating = pilotRating
page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, pilotRating)
page.PilotRatingOptions = pilotRatingOptionsAll(pilotRating)
if password != "" {
if len(password) < 8 {
page.Edit.PasswordError = "Password must be at least 8 characters"
s.reRenderUserEditor(c, &page, dir)
return
}
if strings.Contains(password, ":") {
page.Edit.PasswordError = "Password cannot contain colon characters"
if msg := validateNewPassword(password); msg != "" {
page.Edit.PasswordError = msg
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -495,20 +458,16 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
page.Edit.LastName = lastName
}
// Ceilings apply when *changing* a rating. Leaving a higher existing value
// Network ceiling applies when *changing* a rating. Leaving a higher existing value
// unchanged is allowed so pilot/network edits can be independent.
if rating > maxRating && rating != targetUser.NetworkRating {
page.Edit.Error = "Cannot set network rating above your own"
s.reRenderUserEditor(c, &page, dir)
return
}
if pilotRating > actorPilotMax && pilotRating != targetUser.PilotRating {
page.Edit.PilotError = "Cannot set pilot rating above your own"
s.reRenderUserEditor(c, &page, dir)
return
}
// Ratings: any target; new values must not exceed actor ceilings.
// Ratings: any target; network new values must not exceed actor ceiling.
// Pilot: full official scale (KD-6) — no actor pilot ceiling.
targetUser.NetworkRating = rating
targetUser.PilotRating = pilotRating
@@ -532,7 +491,7 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
// Empty password means keep current (UpdateUser contract).
targetUser.Password = password
} else {
// Rating-only path: never change name/password; reject attempts that look like full mutate.
// Higher-rated target: never change name/password; reject password attempts.
if password != "" {
page.Edit.Error = "Only supervisors can change passwords"
page.Edit.FirstName = safeStr(targetUser.FirstName)

View File

@@ -0,0 +1,552 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/renorris/openfsd/pkg/protocol"
)
func TestAccountPageRendersForObserver(t *testing.T) {
ts := newTestServer(t)
obs := createTestUser(t, ts, "obs-pass1", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, obs.CID, "obs-pass1")
w, _ := authedGET(t, ts, "/account", cookies)
if w.Code != http.StatusOK {
t.Fatalf("GET /account status %d body %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, want := range []string{
`action="/account/password"`,
`action="/account/delete"`,
`name="csrf_token"`,
"Change password",
"Delete my account",
itoa(obs.CID),
} {
if !strings.Contains(body, want) {
t.Fatalf("account page missing %q, body=%s", want, clip(body, 600))
}
}
// Hard-delete checkbox hidden when flag false (default).
if strings.Contains(body, `name="permanent"`) {
t.Fatal("permanent delete checkbox should be hidden by default")
}
}
func TestObserverDashboardHasAccountLink(t *testing.T) {
ts := newTestServer(t)
obs := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, obs.CID, "pw")
w, _ := authedGET(t, ts, "/dashboard", cookies)
if w.Code != http.StatusOK {
t.Fatalf("dashboard status %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `href="/account"`) {
t.Fatal("dashboard/layout must link to /account")
}
if !strings.Contains(body, "Manage account") {
t.Fatal("dashboard should have Manage account button")
}
}
func TestChangePasswordSuccess(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "oldpassword")
oldCSRF := csrfFromCookies(cookies)
form := url.Values{}
form.Set("current_password", "oldpassword")
form.Set("new_password", "newpassword1")
form.Set("confirm_password", "newpassword1")
w, cookies := formPOST(t, ts, "/account/password", form, cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "flash=password_changed") {
t.Fatalf("Location=%q want password_changed flash", loc)
}
// Session re-issued
if extractCookie(w.Result(), sessionCookieName) == "" {
// mergeCookies may keep existing; check Set-Cookie header
found := false
for _, sc := range w.Result().Header.Values("Set-Cookie") {
if strings.HasPrefix(sc, sessionCookieName+"=") && !strings.Contains(sc, "Max-Age=0") {
found = true
break
}
}
if !found {
t.Fatal("expected session Set-Cookie on password change")
}
}
// CSRF rotated (clear + new issue → different value from old)
newCSRF := csrfFromCookies(cookies)
if newCSRF == "" {
// May need GET after rotate
w2, cookies2 := authedGET(t, ts, "/account", cookies)
_ = w2
newCSRF = csrfFromCookies(cookies2)
cookies = cookies2
}
if newCSRF == "" {
t.Fatal("expected CSRF after password change")
}
if oldCSRF != "" && newCSRF == oldCSRF {
// Rotation: clearCSRF then issueCSRFToken on same response may set empty then new.
// Accept if session still works with new CSRF.
}
// Old password fails login
csrf, loginCookies := getLoginCSRF(t, ts)
bad := url.Values{}
bad.Set("cid", itoa(user.CID))
bad.Set("password", "oldpassword")
bad.Set("csrf_token", csrf)
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(bad.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", cookieHeader(loginCookies))
wr := httptest.NewRecorder()
ts.engine.ServeHTTP(wr, req)
if wr.Code == http.StatusSeeOther {
t.Fatal("old password must not log in")
}
// New password works
csrf, loginCookies = getLoginCSRF(t, ts)
good := url.Values{}
good.Set("cid", itoa(user.CID))
good.Set("password", "newpassword1")
good.Set("csrf_token", csrf)
req = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(good.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", cookieHeader(loginCookies))
wr = httptest.NewRecorder()
ts.engine.ServeHTTP(wr, req)
if wr.Code != http.StatusSeeOther {
t.Fatalf("new password login status %d", wr.Code)
}
}
func TestChangePasswordWrongCurrent(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "correct-pw", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "correct-pw")
form := url.Values{}
form.Set("current_password", "wrong-pw")
form.Set("new_password", "newpassword1")
form.Set("confirm_password", "newpassword1")
w, _ := formPOST(t, ts, "/account/password", form, cookies)
if w.Code != http.StatusOK {
t.Fatalf("status %d want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "Incorrect password") {
t.Fatalf("expected field error, body=%s", clip(w.Body.String(), 400))
}
// Password unchanged
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if !ts.dbRepo.UserRepo.VerifyPasswordHash("correct-pw", u.Password) {
t.Fatal("password must not change on wrong current")
}
}
func TestChangePasswordSameAsCurrent(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "samepass1", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "samepass1")
form := url.Values{}
form.Set("current_password", "samepass1")
form.Set("new_password", "samepass1")
form.Set("confirm_password", "samepass1")
w, _ := formPOST(t, ts, "/account/password", form, cookies)
if w.Code != http.StatusOK {
t.Fatalf("status %d want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "different from the current") {
t.Fatalf("expected new≠current error, body=%s", clip(w.Body.String(), 400))
}
}
func TestChangePasswordShortOrColon(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "oldpassword")
form := url.Values{}
form.Set("current_password", "oldpassword")
form.Set("new_password", "short")
form.Set("confirm_password", "short")
w, cookies := formPOST(t, ts, "/account/password", form, cookies)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "at least 8 characters") {
t.Fatalf("short password: status %d body=%s", w.Code, clip(w.Body.String(), 300))
}
form = url.Values{}
form.Set("current_password", "oldpassword")
form.Set("new_password", "bad:colon1")
form.Set("confirm_password", "bad:colon1")
w, _ = formPOST(t, ts, "/account/password", form, cookies)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "colon") {
t.Fatalf("colon password: status %d body=%s", w.Code, clip(w.Body.String(), 300))
}
}
func TestChangePasswordCSRF(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "pw")
form := url.Values{}
form.Set("current_password", "pw")
form.Set("new_password", "newpassword1")
form.Set("confirm_password", "newpassword1")
// Wrong CSRF
form.Set("csrf_token", "not-the-real-token")
req := httptest.NewRequest(http.MethodPost, "/account/password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", cookieHeader(cookies))
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status %d want 403", w.Code)
}
}
func TestDeleteAccountSoft(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "delete-me1", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "delete-me1")
form := url.Values{}
form.Set("current_password", "delete-me1")
form.Set("confirm_cid", itoa(user.CID))
w, _ := formPOST(t, ts, "/account/delete", form, cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "account=deleted") {
t.Fatalf("Location=%q want account=deleted", loc)
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingInactive) {
t.Fatalf("network_rating=%d want Inactive(-1)", u.NetworkRating)
}
// New login fails
csrf, loginCookies := getLoginCSRF(t, ts)
login := url.Values{}
login.Set("cid", itoa(user.CID))
login.Set("password", "delete-me1")
login.Set("csrf_token", csrf)
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(login.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", cookieHeader(loginCookies))
wr := httptest.NewRecorder()
ts.engine.ServeHTTP(wr, req)
if wr.Code == http.StatusSeeOther {
t.Fatal("soft-deleted user must not log in")
}
}
func TestDeleteAccountWrongPassword(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "keep-me1x", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "keep-me1x")
form := url.Values{}
form.Set("current_password", "wrong")
form.Set("confirm_cid", itoa(user.CID))
w, _ := formPOST(t, ts, "/account/delete", form, cookies)
if w.Code != http.StatusOK {
t.Fatalf("status %d want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "Incorrect password") {
t.Fatalf("expected password error, body=%s", clip(w.Body.String(), 400))
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingObserver) {
t.Fatalf("user must still be active, rating=%d", u.NetworkRating)
}
}
func TestDeleteAccountConfirmCIDMismatch(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "keep-me2x", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "keep-me2x")
form := url.Values{}
form.Set("current_password", "keep-me2x")
form.Set("confirm_cid", "999999")
w, _ := formPOST(t, ts, "/account/delete", form, cookies)
if w.Code != http.StatusOK {
t.Fatalf("status %d want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "Confirm your CID") {
t.Fatalf("expected CID confirm error, body=%s", clip(w.Body.String(), 400))
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingObserver) {
t.Fatal("user must still be active")
}
}
func TestDeleteAccountHardWhenEnabled(t *testing.T) {
ts := newTestServer(t)
ts.cfg.AllowPermanentAccountDelete = true
user := createTestUser(t, ts, "hard-del1", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "hard-del1")
// Checkbox visible
w, cookies := authedGET(t, ts, "/account", cookies)
if !strings.Contains(w.Body.String(), `name="permanent"`) {
t.Fatal("expected permanent checkbox when enabled")
}
form := url.Values{}
form.Set("current_password", "hard-del1")
form.Set("confirm_cid", itoa(user.CID))
form.Set("permanent", "1")
w, _ = formPOST(t, ts, "/account/delete", form, cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
_, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err == nil {
t.Fatal("expected row gone after hard delete")
}
}
func TestDeleteAccountHardWhenDisabled(t *testing.T) {
ts := newTestServer(t)
// flag remains false
user := createTestUser(t, ts, "soft-only1", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "soft-only1")
form := url.Values{}
form.Set("current_password", "soft-only1")
form.Set("confirm_cid", itoa(user.CID))
form.Set("permanent", "1") // client posts permanent but server soft-deletes
w, _ := formPOST(t, ts, "/account/delete", form, cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "account=deleted") || !strings.Contains(loc, "permanent=disabled") {
t.Fatalf("Location=%q want deleted+permanent=disabled", loc)
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingInactive) {
t.Fatalf("must soft-delete, rating=%d", u.NetworkRating)
}
}
func TestSessionRejectedAfterSoftDelete(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "sess-del1", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "sess-del1")
// Soft-delete via repo (simulate admin/self delete while session still held)
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
u.NetworkRating = int(protocol.NetworkRatingInactive)
u.Password = ""
if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
t.Fatal(err)
}
w, cookies2 := authedGET(t, ts, "/dashboard", cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("status %d want 303 to login", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location=%q want /login", loc)
}
// Session cookie cleared
cleared := false
for _, sc := range w.Result().Header.Values("Set-Cookie") {
if strings.HasPrefix(sc, sessionCookieName+"=") &&
(strings.Contains(sc, "Max-Age=0") || strings.Contains(sc, "Max-Age=-1")) {
cleared = true
}
}
if !cleared {
// mergeCookies should drop empty/cleared
if csrfFromCookies(cookies2) != "" || extractCookie(w.Result(), sessionCookieName) != "" {
// extractCookie may still return empty value for cleared cookie
}
}
// Follow-up without re-login fails
w2, _ := authedGET(t, ts, "/dashboard", cookies2)
if w2.Code != http.StatusSeeOther {
t.Fatalf("after clear, dashboard status %d want 303", w2.Code)
}
}
func TestAPISessionRejectedAfterSoftDelete(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "api-del1", int(protocol.NetworkRatingSupervisor))
cookies := formLogin(t, ts, user.CID, "api-del1")
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
u.NetworkRating = int(protocol.NetworkRatingInactive)
u.Password = ""
if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
t.Fatal(err)
}
// Self load via session dual-accept
csrf := csrfFromCookies(cookies)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/load",
strings.NewReader(`{"cid":`+itoa(user.CID)+`}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Cookie", cookieHeader(cookies))
if csrf != "" {
req.Header.Set(csrfHeaderName, csrf)
}
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("API session after soft-delete status %d want 401 body %s", w.Code, w.Body.String())
}
}
func TestClaimsOverlayAfterDemotion(t *testing.T) {
ts := newTestServer(t)
admin := createTestUser(t, ts, "admin-pw1", int(protocol.NetworkRatingAdministator))
cookies := formLogin(t, ts, admin.CID, "admin-pw1")
// Demote to SUP in DB
u, err := ts.dbRepo.UserRepo.GetUserByCID(admin.CID)
if err != nil {
t.Fatal(err)
}
u.NetworkRating = int(protocol.NetworkRatingSupervisor)
u.Password = ""
if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
t.Fatal(err)
}
w, cookies := authedGET(t, ts, "/dashboard", cookies)
if w.Code != http.StatusOK {
t.Fatalf("dashboard status %d", w.Code)
}
body := w.Body.String()
if strings.Contains(body, `href="/configeditor"`) {
t.Fatal("demoted SUP must not see Config nav")
}
if !strings.Contains(body, `href="/usereditor"`) {
t.Fatal("demoted SUP should still see Users")
}
if !strings.Contains(body, "Supervisor") {
t.Fatal("expected overlaid Supervisor label")
}
// Create with network_rating=12 must be rejected (ceiling uses overlaid SUP)
form := url.Values{}
form.Set("first_name", "X")
form.Set("password", "password99")
form.Set("network_rating", "12")
form.Set("pilot_rating", "0")
w, _ = formPOST(t, ts, "/usereditor/create", form, cookies)
if w.Code == http.StatusSeeOther && strings.Contains(w.Header().Get("Location"), "flash=created") {
t.Fatal("SUP must not create ADM-rated user after demotion overlay")
}
}
func TestLoginShowsAccountDeletedBanner(t *testing.T) {
ts := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/login?account=deleted", nil)
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Your account has been deleted.") {
t.Fatalf("expected deleted banner, body=%s", clip(w.Body.String(), 400))
}
req = httptest.NewRequest(http.MethodGet, "/login?account=deleted&permanent=disabled", nil)
w = httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if !strings.Contains(w.Body.String(), "Permanent delete is not enabled") {
t.Fatalf("expected permanent disabled message, body=%s", clip(w.Body.String(), 400))
}
}
func TestRefreshRejectsInactiveUser(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "refresh1x", int(protocol.NetworkRatingObserver))
// JSON login for refresh token
body := `{"cid":` + itoa(user.CID) + `,"password":"refresh1x"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login %d %s", w.Code, w.Body.String())
}
var res APIV1Response
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(res.Data)
var tokens struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.Unmarshal(data, &tokens); err != nil {
t.Fatal(err)
}
// Soft-delete
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
u.NetworkRating = int(protocol.NetworkRatingInactive)
u.Password = ""
if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
t.Fatal(err)
}
refBody := `{"refresh_token":"` + tokens.RefreshToken + `"}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", strings.NewReader(refBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("refresh after inactive status %d want 401 body %s", w.Code, w.Body.String())
}
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
@@ -640,76 +641,85 @@ func TestSupervisorCannotUpdateHigherRatedUserViaForm(t *testing.T) {
}
}
func TestInstructorCanAdjustRatingsButNotCreate(t *testing.T) {
func TestInstructorCannotAccessUserEditor(t *testing.T) {
ts := newTestServer(t)
// Give instructor a pilot ceiling (IR=3) so they can assign P0/PPL/IR.
inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
inst.PilotRating = int(protocol.PilotRatingIR)
inst.Password = ""
if err := ts.dbRepo.UserRepo.UpdateUser(inst); err != nil {
t.Fatal(err)
}
obs := createTestUser(t, ts, "obs-pass", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, inst.CID, "inst-pass")
// Directory accessible.
w, cookies := authedGET(t, ts, "/usereditor", cookies)
if w.Code != http.StatusOK {
t.Fatalf("GET /usereditor as I1 status %d", w.Code)
w, _ := authedGET(t, ts, "/usereditor", cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("GET /usereditor as I1 status %d want 303", w.Code)
}
if strings.Contains(w.Body.String(), `action="/usereditor/create"`) {
t.Fatal("instructor must not see create form")
if loc := w.Header().Get("Location"); loc != "/dashboard" {
t.Fatalf("Location=%q want /dashboard", loc)
}
// Create forbidden.
// POST create also gated by middleware.
form := url.Values{}
form.Set("first_name", "Nope")
form.Set("password", "password99")
form.Set("network_rating", "1")
form.Set("pilot_rating", "0")
w, cookies = formPOST(t, ts, "/usereditor/create", form, cookies)
w, _ = formPOST(t, ts, "/usereditor/create", form, cookies)
if w.Code == http.StatusSeeOther && strings.Contains(w.Header().Get("Location"), "flash=created") {
t.Fatal("instructor must not create users")
}
// Rating adjust allowed (network S1; pilot PPL=1 ≤ IR ceiling).
form = url.Values{}
form.Set("cid", itoa(obs.CID))
form.Set("network_rating", "2") // S1 ≤ I1
form.Set("pilot_rating", itoa(int(protocol.PilotRatingPPL)))
form.Set("password", "")
w, _ = formPOST(t, ts, "/usereditor/update", form, cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("instructor rating update status %d body %s", w.Code, w.Body.String())
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(obs.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != 2 {
t.Fatalf("network = %d want 2", u.NetworkRating)
}
if u.PilotRating != int(protocol.PilotRatingPPL) {
t.Fatalf("pilot = %d want PPL(%d)", u.PilotRating, protocol.PilotRatingPPL)
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/dashboard" {
// Middleware redirect to dashboard is the expected gate.
if w.Code == http.StatusOK && strings.Contains(w.Body.String(), "created") {
t.Fatal("instructor must not create users")
}
}
}
func TestInstructorCannotSetNetworkAboveOwn(t *testing.T) {
func TestSupervisorUserEditorPilotRatingFullScale(t *testing.T) {
ts := newTestServer(t)
inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
obs := createTestUser(t, ts, "obs-pass", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, inst.CID, "inst-pass")
// SUP with pilot P0 only — must still see full pilot scale and assign CMEL=15.
sup := createTestUser(t, ts, "sup-pass", int(protocol.NetworkRatingSupervisor))
if sup.PilotRating != 0 {
sup.PilotRating = 0
sup.Password = ""
if err := ts.dbRepo.UserRepo.UpdateUser(sup); err != nil {
t.Fatal(err)
}
}
cookies := formLogin(t, ts, sup.CID, "sup-pass")
w, cookies := authedGET(t, ts, "/usereditor?new=1", cookies)
if w.Code != http.StatusOK {
t.Fatalf("GET /usereditor?new=1 status %d", w.Code)
}
body := w.Body.String()
for _, v := range []string{"0", "1", "3", "7", "15", "31", "63"} {
if !selectContainsValue(body, "create-pilot-rating", v) {
t.Fatalf("create pilot select missing value %s", v)
}
}
form := url.Values{}
form.Set("cid", itoa(obs.CID))
form.Set("network_rating", "11") // SUP — above I1
form.Set("pilot_rating", "0")
w, _ := formPOST(t, ts, "/usereditor/update", form, cookies)
if w.Code == http.StatusSeeOther {
t.Fatal("must not set network rating above actor")
form.Set("first_name", "Pilot")
form.Set("last_name", "Full")
form.Set("password", "password99")
form.Set("network_rating", "1")
form.Set("pilot_rating", "15") // CMEL — above actor P0
w, _ = formPOST(t, ts, "/usereditor/create", form, cookies)
if w.Code != http.StatusSeeOther {
t.Fatalf("create with pilot_rating=15 status %d body %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "above your own") {
t.Fatalf("expected ceiling error, body=%s", clip(w.Body.String(), 400))
loc := w.Header().Get("Location")
assertUserEditorRedirect(t, loc, "", "created")
// Load created user by CID from redirect.
u, err := url.Parse(loc)
if err != nil {
t.Fatal(err)
}
cid, _ := strconv.Atoi(u.Query().Get("cid"))
created, err := ts.dbRepo.UserRepo.GetUserByCID(cid)
if err != nil {
t.Fatal(err)
}
if created.PilotRating != 15 {
t.Fatalf("pilot_rating=%d want 15", created.PilotRating)
}
}

View File

@@ -118,15 +118,32 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
authed.Use(s.requireSessionHTML)
authed.GET("/dashboard", s.handleFrontendDashboard)
// Instructor1+ user directory: list/load + rating updates (handlers enforce ceilings).
// Create + full profile mutation require Supervisor+ (checked in handlers).
// Account self-service — any DB-valid session (OBS+).
authed.GET("/account", s.handleFrontendAccount)
authed.POST("/account/password", s.handleFrontendAccountPassword)
authed.POST("/account/delete", s.handleFrontendAccountDelete)
// User editor — Supervisor+ (create + full profile + ratings).
userAdmin := authed.Group("")
userAdmin.Use(s.requireMinRatingHTML(protocol.NetworkRatingInstructor1))
userAdmin.Use(s.requireMinRatingHTML(protocol.NetworkRatingSupervisor))
userAdmin.GET("/usereditor", s.handleFrontendUserEditor)
userAdmin.POST("/usereditor/create", s.handleFrontendUserCreate)
userAdmin.POST("/usereditor/update", s.handleFrontendUserUpdate)
// Admin config: form POST mutations with CSRF; no JS required.
// Sweatbox instructor UI: Instructor1+ (HTML forms; proxies FSD /sweatbox/*).
instructor := authed.Group("")
instructor.Use(s.requireMinRatingHTML(protocol.NetworkRatingInstructor1))
instructor.GET("/sweatbox", s.handleFrontendSweatbox)
instructor.GET("/sweatbox/manual", s.handleFrontendSweatboxManual)
instructor.POST("/sweatbox/airport", s.handleFrontendSweatboxAirport)
instructor.POST("/sweatbox/scenario", s.handleFrontendSweatboxScenario)
instructor.POST("/sweatbox/command", s.handleFrontendSweatboxCommand)
instructor.POST("/sweatbox/pause", s.handleFrontendSweatboxPause)
instructor.POST("/sweatbox/unpause", s.handleFrontendSweatboxUnpause)
instructor.POST("/sweatbox/delete", s.handleFrontendSweatboxDelete)
instructor.POST("/sweatbox/delete-all", s.handleFrontendSweatboxDeleteAll)
// Admin config + airport editor: form POST mutations with CSRF; no JS required.
admin := authed.Group("")
admin.Use(s.requireMinRatingHTML(protocol.NetworkRatingAdministator))
admin.GET("/configeditor", s.handleFrontendConfigEditor)
@@ -134,17 +151,6 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
admin.POST("/configeditor/reset-secret", s.handleFrontendConfigResetSecret)
admin.POST("/configeditor/create-token", s.handleFrontendConfigCreateToken)
// Sweatbox instructor UI: server-rendered forms; proxies FSD /sweatbox/* service HTTP.
admin.GET("/sweatbox", s.handleFrontendSweatbox)
admin.GET("/sweatbox/manual", s.handleFrontendSweatboxManual)
admin.POST("/sweatbox/airport", s.handleFrontendSweatboxAirport)
admin.POST("/sweatbox/scenario", s.handleFrontendSweatboxScenario)
admin.POST("/sweatbox/command", s.handleFrontendSweatboxCommand)
admin.POST("/sweatbox/pause", s.handleFrontendSweatboxPause)
admin.POST("/sweatbox/unpause", s.handleFrontendSweatboxUnpause)
admin.POST("/sweatbox/delete", s.handleFrontendSweatboxDelete)
admin.POST("/sweatbox/delete-all", s.handleFrontendSweatboxDeleteAll)
// Airport editor: HTML shell + no-JS echo-download (no disk/DB persistence).
admin.GET("/airport-editor", s.handleFrontendAirportEditor)
admin.POST("/airport-editor/download-apt", s.handleFrontendAirportEditorDownloadAPT)

View File

@@ -22,6 +22,7 @@ var pageTemplateKeys = []string{
"landing",
"login",
"dashboard",
"account",
"usereditor",
"configeditor",
"sweatbox",

View File

@@ -0,0 +1,115 @@
{{ define "title" }}Account{{ end }}
{{ define "body" }}
<main class="container py-4" style="max-width: 36rem">
<h1 class="h4 mb-3">Account</h1>
{{ if .FlashSuccess }}
<div class="alert alert-success" role="status">{{ .FlashSuccess }}</div>
{{ end }}
{{ if .FlashError }}
<div class="alert alert-danger" role="alert">{{ .FlashError }}</div>
{{ end }}
{{ if .FormError }}
<div class="alert alert-danger" role="alert">{{ .FormError }}</div>
{{ end }}
<section class="mb-4" aria-labelledby="account-profile-heading">
<h2 id="account-profile-heading" class="h5">Profile</h2>
<dl class="row mb-0">
<dt class="col-sm-4">CID</dt>
<dd class="col-sm-8">{{ .CID }}</dd>
<dt class="col-sm-4">Name</dt>
<dd class="col-sm-8">{{ .FirstName }} {{ .LastName }}</dd>
<dt class="col-sm-4">Network rating</dt>
<dd class="col-sm-8">{{ .NetworkLabel }}</dd>
<dt class="col-sm-4">Pilot rating</dt>
<dd class="col-sm-8">{{ .PilotLabel }}</dd>
</dl>
<p class="text-muted small mt-2 mb-0">
Network and pilot ratings can only be changed by a supervisor.
</p>
</section>
<section class="mb-4" aria-labelledby="account-password-heading">
<h2 id="account-password-heading" class="h5">Change password</h2>
<p class="text-muted small">
Changing your password does not sign out other devices until their session expires.
Deleting or disabling the account ends other sessions on their next request.
</p>
<form method="post" action="/account/password" novalidate>
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-3">
<label for="account-current-password" class="form-label">Current password</label>
<input type="password" class="form-control{{ if .CurrentPassError }} is-invalid{{ end }}"
id="account-current-password" name="current_password" required
autocomplete="current-password">
{{ if .CurrentPassError }}
<div class="invalid-feedback">{{ .CurrentPassError }}</div>
{{ end }}
</div>
<div class="mb-3">
<label for="account-new-password" class="form-label">New password</label>
<input type="password" class="form-control{{ if .NewPassError }} is-invalid{{ end }}"
id="account-new-password" name="new_password" required minlength="8"
autocomplete="new-password">
{{ if .NewPassError }}
<div class="invalid-feedback">{{ .NewPassError }}</div>
{{ end }}
</div>
<div class="mb-3">
<label for="account-confirm-password" class="form-label">Confirm new password</label>
<input type="password" class="form-control{{ if .ConfirmPassError }} is-invalid{{ end }}"
id="account-confirm-password" name="confirm_password" required minlength="8"
autocomplete="new-password">
{{ if .ConfirmPassError }}
<div class="invalid-feedback">{{ .ConfirmPassError }}</div>
{{ end }}
</div>
<button type="submit" class="btn btn-primary">Update password</button>
</form>
</section>
<section class="mb-4 border border-danger rounded p-3" aria-labelledby="account-delete-heading">
<h2 id="account-delete-heading" class="h5 text-danger">Delete my account</h2>
<p class="small">
This deactivates your certificate. You will not be able to log in again.
A supervisor can restore a soft-deleted account. Hard delete is irreversible
and only available when enabled on this server.
</p>
{{ if .DeleteError }}
<div class="alert alert-danger" role="alert">{{ .DeleteError }}</div>
{{ end }}
<form method="post" action="/account/delete" novalidate>
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-3">
<label for="account-delete-password" class="form-label">Current password</label>
<input type="password" class="form-control{{ if .DeletePassError }} is-invalid{{ end }}"
id="account-delete-password" name="current_password" required
autocomplete="current-password">
{{ if .DeletePassError }}
<div class="invalid-feedback">{{ .DeletePassError }}</div>
{{ end }}
</div>
<div class="mb-3">
<label for="account-confirm-cid" class="form-label">Type your CID to confirm</label>
<input type="text" class="form-control" id="account-confirm-cid"
name="confirm_cid" required inputmode="numeric"
autocomplete="off" placeholder="{{ .CID }}">
</div>
{{ if .AllowPermanentDelete }}
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="account-permanent"
name="permanent" value="1">
<label class="form-check-label text-danger" for="account-permanent">
Permanently delete my account (cannot be undone)
</label>
</div>
{{ end }}
<button type="submit" class="btn btn-outline-danger">Delete account</button>
</form>
</section>
<p class="mb-0"><a href="/dashboard">← Back to dashboard</a></p>
</main>
{{ end }}

View File

@@ -16,6 +16,40 @@
data-js="dashboard">
<h1 class="h4 visually-hidden">Dashboard</h1>
<section class="w-100 mb-3" style="max-width: 600px" aria-labelledby="account-summary-heading">
<h2 id="account-summary-heading" class="h5">Your account</h2>
<div id="dashboard-real-name">
{{ if .User.FirstName }}Welcome, {{ .User.FirstName }}!{{ else }}Welcome!{{ end }}
</div>
<div id="dashboard-cid">CID: {{ .User.CID }}</div>
<div>Network Rating: <span id="dashboard-network-rating">{{ .User.NetworkRatingLabel }}</span></div>
{{ if .PilotRatingLabel }}
<div>Pilot Rating: <span id="dashboard-pilot-rating">{{ .PilotRatingLabel }}</span></div>
{{ end }}
<div class="mt-2">
<a class="btn btn-primary" href="/account">Manage account</a>
</div>
</section>
<section class="w-100 mb-3" style="max-width: 600px" aria-labelledby="tools-heading">
<h2 id="tools-heading" class="h5">Tools</h2>
<ul class="list-unstyled d-flex flex-wrap gap-2 mb-0" id="dashboard-tools">
{{ if .User.CanEditUsers }}
<li><a href="/usereditor" class="btn btn-outline-secondary">Users</a></li>
{{ end }}
{{ if .User.CanAccessSweatbox }}
<li><a href="/sweatbox" class="btn btn-outline-secondary">Sweatbox</a></li>
{{ end }}
{{ if .User.CanEditConfig }}
<li><a href="/configeditor" class="btn btn-outline-secondary">Config</a></li>
<li><a href="/airport-editor" class="btn btn-outline-secondary">Airport Editor</a></li>
{{ end }}
{{ if and (not .User.CanEditUsers) (not .User.CanAccessSweatbox) (not .User.CanEditConfig) }}
<li class="text-muted small">No elevated tools for your rating. Use Account to manage your password.</li>
{{ end }}
</ul>
</section>
<section class="w-100 mb-3" style="max-width: 600px" aria-labelledby="connection-summary-heading">
<h2 id="connection-summary-heading" class="h5">Connections</h2>
{{ if .SummaryUnavailable }}
@@ -68,25 +102,6 @@
<div id="map" class="mb-3 rounded" style="width: 600px; max-width: 100%; height: 400px;"
role="img" aria-label="Map of connected pilots (requires JavaScript)"
data-js="map-root"></div>
<div class="d-flex justify-content-around w-100 mt-auto flex-wrap gap-3" style="max-width: 600px">
<div>
<div id="dashboard-real-name">
{{ if .User.FirstName }}Welcome, {{ .User.FirstName }}!{{ else }}Welcome!{{ end }}
</div>
<div id="dashboard-cid">CID: {{ .User.CID }}</div>
<div>Network Rating: <span id="dashboard-network-rating">{{ .User.NetworkRatingLabel }}</span></div>
</div>
<div id="dashboard-user-editor">
{{ if .User.CanEditUsers }}
<div class="mb-2"><a href="/usereditor" class="btn btn-primary">Edit Users</a></div>
{{ end }}
{{ if .User.CanEditConfig }}
<div class="mb-2"><a href="/configeditor" class="btn btn-primary">Configure Server</a></div>
<div class="mb-2"><a href="/sweatbox" class="btn btn-primary">Sweatbox</a></div>
{{ end }}
</div>
</div>
</main>
<script src="/static/js/leaflet.js"></script>

View File

@@ -46,12 +46,15 @@
</button>
{{ if .User }}
<a class="btn btn-sm btn-outline-secondary" href="/dashboard">Dashboard</a>
<a class="btn btn-sm btn-outline-secondary" href="/account">Account</a>
{{ if .User.CanEditUsers }}
<a class="btn btn-sm btn-outline-secondary" href="/usereditor">Users</a>
{{ end }}
{{ if .User.CanAccessSweatbox }}
<a class="btn btn-sm btn-outline-secondary" href="/sweatbox">Sweatbox</a>
{{ end }}
{{ if .User.CanEditConfig }}
<a class="btn btn-sm btn-outline-secondary" href="/configeditor">Config</a>
<a class="btn btn-sm btn-outline-secondary" href="/sweatbox">Sweatbox</a>
<a class="btn btn-sm btn-outline-secondary" href="/airport-editor">Airport Editor</a>
{{ end }}
<form method="post" action="/logout" class="d-inline m-0">

View File

@@ -4,6 +4,9 @@
<main class="container-fluid d-flex justify-content-center position-absolute top-50 start-50 translate-middle">
<div class="p-4 rounded border border-1 ofs-login-card" style="min-width: 20rem; max-width: 24rem; width: 100%;">
<h1 class="h4 mb-3">Log in</h1>
{{ if .Info }}
<div class="alert alert-info" role="status">{{ .Info }}</div>
{{ end }}
{{ if .Error }}
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}

View File

@@ -41,7 +41,7 @@
<p>
The sweatbox is a <strong>native, in-process simulator</strong> inside the FSD process.
Aircraft are synthetic registry participants (no TCP pilot sockets). You drive them
from the Administrator web page at <code>/sweatbox</code> with text commands that
from the Instructor1+ web page at <code>/sweatbox</code> with text commands that
follow TWRTrainer-style vocabulary.
</p>
<ul>
@@ -60,7 +60,7 @@
<tbody>
<tr>
<td>Role</td>
<td>Administrator network rating (same as Config / Airport Editor).</td>
<td>Instructor1+ network rating (I1, I2, I3, SUP, ADM). Config and Airport Editor remain Administrator-only.</td>
</tr>
<tr>
<td>FSD process</td>

View File

@@ -12,7 +12,7 @@ import (
// getUserByCID returns the user info of the specified CID.
//
// Self always allowed. Other CIDs require Instructor1+ (directory / rating tooling).
// Self always allowed. Other CIDs require Supervisor+ (user editor).
func (s *Server) getUserByCID(c *gin.Context) {
type RequestBody struct {
CID int `json:"cid" binding:"min=1,required"`
@@ -65,8 +65,8 @@ func (s *Server) getUserByCID(c *gin.Context) {
// updateUser updates the user with a specified CID.
//
// The CID itself is immutable and cannot be changed.
// Instructor1+: may set network_rating and pilot_rating up to actor ceilings (any target).
// Supervisor+: may also set name/password when target network rating ≤ actor.
// Supervisor+: may set network_rating (≤ actor) and any official pilot_rating (any target);
// may also set name/password when target network rating ≤ actor.
func (s *Server) updateUser(c *gin.Context) {
claims, ok := requireJwtContext(c)
if !ok {
@@ -97,7 +97,6 @@ func (s *Server) updateUser(c *gin.Context) {
return
}
actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
fullOK := canFullMutateTarget(claims.NetworkRating, protocol.NetworkRating(targetUser.NetworkRating))
// Profile fields require full mutation privilege.
@@ -120,8 +119,9 @@ func (s *Server) updateUser(c *gin.Context) {
}
}
// Rating ceilings: cannot *raise/change to* above actor's own values (any target).
// Network ceiling: cannot raise/change to above actor's own (any target).
// Unchanged higher existing values are allowed when the field is re-sent as-is.
// Pilot: full official scale (KD-6) — no actor pilot ceiling.
if reqBody.NetworkRating != nil {
if *reqBody.NetworkRating > int(claims.NetworkRating) &&
*reqBody.NetworkRating != targetUser.NetworkRating {
@@ -137,12 +137,6 @@ func (s *Server) updateUser(c *gin.Context) {
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if *reqBody.PilotRating > actorPilotMax &&
*reqBody.PilotRating != targetUser.PilotRating {
res := newAPIV1Failure("cannot set pilot rating above your own")
writeAPIV1Response(c, http.StatusForbidden, &res)
return
}
targetUser.PilotRating = *reqBody.PilotRating
}
@@ -205,12 +199,6 @@ func (s *Server) createUser(c *gin.Context) {
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
if reqBody.PilotRating > actorPilotMax {
res := newAPIV1Failure("cannot set pilot rating above your own")
writeAPIV1Response(c, http.StatusForbidden, &res)
return
}
user := &db.User{
Password: reqBody.Password,

View File

@@ -1,26 +1,30 @@
package web
import (
"strings"
"github.com/renorris/openfsd/pkg/protocol"
)
// User-admin privilege helpers.
// User-admin privilege helpers (post user-dashboard self-service design).
//
// - Instructor13 (and above): browse directory; adjust network + pilot ratings
// up to the actor's own ceilings (any target).
// - Supervisor+: full mutation (create, name, password) in addition to ratings.
// - Supervisor+: user editor directory, create, rating + full profile mutation.
// Full profile mutation still cannot target users with a higher network rating.
// - Instructor1+: sweatbox control plane (HTML + PE JSON).
// - Administrator: config + airport editor (unchanged).
func canAccessUserEditor(r protocol.NetworkRating) bool {
return r >= protocol.NetworkRatingInstructor1
return r >= protocol.NetworkRatingSupervisor
}
func canFullMutateUsers(r protocol.NetworkRating) bool {
return r >= protocol.NetworkRatingSupervisor
}
// canAdjustUserRatings gates JSON updateUser and rating POSTs. Same threshold
// as canAccessUserEditor (SUP+) after the I1 rating-only tier was removed.
func canAdjustUserRatings(r protocol.NetworkRating) bool {
return r >= protocol.NetworkRatingInstructor1
return r >= protocol.NetworkRatingSupervisor
}
// canFullMutateTarget is true when the actor may change name/password (and
@@ -29,6 +33,10 @@ func canFullMutateTarget(actor, targetNetworkRating protocol.NetworkRating) bool
return canFullMutateUsers(actor) && targetNetworkRating <= actor
}
func canAccessSweatbox(r protocol.NetworkRating) bool {
return r >= protocol.NetworkRatingInstructor1
}
func pilotRatingLabel(v int) string {
// "PPL — Private Pilot License" style for selects / tooltips.
short := protocol.PilotRatingShort(v)
@@ -63,6 +71,13 @@ func pilotRatingOptionsUpTo(maxInclusive int, selected int) []ratingOption {
return out
}
// pilotRatingOptionsAll returns the full official pilot scale (P0…FE).
// Used by the user editor so SUP+ can assign any pilot rating regardless of
// their own flying quals (KD-6).
func pilotRatingOptionsAll(selected int) []ratingOption {
return pilotRatingOptionsUpTo(int(protocol.PilotRatingFE), selected)
}
// maxValidPilotRatingAtMost returns the highest official pilot rating ID ≤ n.
// If n is below P0, returns P0.
func maxValidPilotRatingAtMost(n int) int {
@@ -79,3 +94,15 @@ func maxValidPilotRatingAtMost(n int) int {
func isValidPilotRating(v int) bool {
return protocol.IsValidPilotRating(v)
}
// validateNewPassword returns a user-visible error string, or "" if OK.
// Used by account change-password and user-editor create/update (when password non-empty).
func validateNewPassword(pw string) string {
if len(pw) < 8 {
return "Password must be at least 8 characters"
}
if strings.Contains(pw, ":") {
return "Password cannot contain colon characters"
}
return ""
}