mirror of
https://github.com/renorris/openfsd
synced 2026-08-10 19:36:08 +08:00
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:
1096
docs/design/user-dashboard-self-service.md
Normal file
1096
docs/design/user-dashboard-self-service.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, `\`, `\\`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
//
|
||||
// Authenticated read proxy of FSD service HTTP GET /sweatbox/state.
|
||||
// Used by sweatbox.js progressive enhancement (1–2 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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
214
internal/web/pages_account.go
Normal file
214
internal/web/pages_account.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
552
internal/web/pe_account_test.go
Normal file
552
internal/web/pe_account_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,6 +22,7 @@ var pageTemplateKeys = []string{
|
||||
"landing",
|
||||
"login",
|
||||
"dashboard",
|
||||
"account",
|
||||
"usereditor",
|
||||
"configeditor",
|
||||
"sweatbox",
|
||||
|
||||
115
internal/web/templates/account.html
Normal file
115
internal/web/templates/account.html
Normal 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 }}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
// - Instructor1–3 (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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user