package service import ( "context" "errors" "regexp" "strings" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/rbac" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/mailer" "go.uber.org/zap" ) var ( ErrStaffSelf = errors.New("you can't change your own staff access") ErrLastSuperadmin = errors.New("there must always be at least one active superadmin") ErrRoleSystem = errors.New("system roles can't be edited or deleted") ErrRoleBadPermission = errors.New("unknown permission") ErrRoleName = errors.New("a role needs a name") ErrRoleNoPermissions = errors.New("a role needs at least one permission") ) // StaffService manages who can use the admin panel and with what powers. // Every method here is reachable only with rbac.ManageStaff — superadmins. type StaffService struct { staff *repository.StaffRepo // mail tells newly added staff about their access; nil sends nothing. mail mailer.Mailer logger *zap.Logger adminURL string } // NewStaffService wires staff management. adminURL is the panel's address for // the welcome email; empty falls back to the production panel. func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService { if adminURL == "" { adminURL = defaultAdminURL } if logger == nil { logger = zap.NewNop() } return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL} } // Me describes the caller, for the panel to decide what to show. type Me struct { RoleKey string `json:"role_key"` RoleName string `json:"role_name"` Permissions []string `json:"permissions"` } func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) { return s.staff.ListStaff(ctx) } // isSuperadminRole reports whether a role id is the seeded superadmin. func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) { ro, err := s.staff.Role(ctx, roleID) if err != nil { return false, err } return ro.Key == repository.SuperadminKey, nil } // guardSuperadminLoss refuses a change that would take an ACTIVE superadmin // out of that state if they're the last one — demoting, revoking or blocking // them would leave no one able to manage staff, with no way back from the UI. func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error { if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil { return nil } n, err := s.staff.ActiveSuperadmins(ctx) if err != nil { return err } if n <= 1 { return ErrLastSuperadmin } return nil } // Grant gives an account a role, by handle. func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) { userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@")) if err != nil { return nil, err } if userID == actorID { return nil, ErrStaffSelf } if _, err := s.staff.Role(ctx, roleID); err != nil { return nil, err } // Granting to someone who is already staff is a role change, and must // pass the same last-superadmin check. if existing, err := s.staff.StaffMember(ctx, userID); err == nil { return s.ChangeRole(ctx, actorID, existing.UserID, roleID) } if err := s.staff.SetRole(ctx, userID, &roleID); err != nil { return nil, err } m, err := s.staff.StaffMember(ctx, userID) if err != nil { return nil, err } // Only a NEW staff member is welcomed; role changes above stay quiet. s.sendStaffWelcome(m) return m, nil } // ChangeRole moves a staff member to another role. func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) { if targetID == actorID { return nil, ErrStaffSelf } target, err := s.staff.StaffMember(ctx, targetID) if err != nil { return nil, err } toSuper, err := s.isSuperadminRole(ctx, roleID) if err != nil { return nil, err } if !toSuper { if err := s.guardSuperadminLoss(ctx, target); err != nil { return nil, err } } if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil { return nil, err } return s.staff.StaffMember(ctx, targetID) } // Revoke removes a staff member's access entirely. func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error { if targetID == actorID { return ErrStaffSelf } target, err := s.staff.StaffMember(ctx, targetID) if err != nil { return err } if err := s.guardSuperadminLoss(ctx, target); err != nil { return err } return s.staff.SetRole(ctx, targetID, nil) } // SetBlocked suspends or restores a staff member's panel access, keeping their // role so restoring them is one action rather than a re-grant. func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) { if targetID == actorID { return nil, ErrStaffSelf } target, err := s.staff.StaffMember(ctx, targetID) if err != nil { return nil, err } if blocked { if err := s.guardSuperadminLoss(ctx, target); err != nil { return nil, err } } if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil { return nil, err } return s.staff.StaffMember(ctx, targetID) } // ── Roles ───────────────────────────────────────────────────────────────── func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) { return s.staff.ListRoles(ctx) } // cleanPermissions validates a custom role's permissions against the catalog. // The wildcard and admins:manage aren't in the catalog, so they are refused // here — a custom role can never become a second superadmin. func cleanPermissions(perms []string) ([]string, error) { seen := map[string]bool{} out := []string{} for _, p := range perms { p = strings.TrimSpace(p) if p == "" || seen[p] { continue } if !rbac.Valid(p) { return nil, ErrRoleBadPermission } seen[p] = true out = append(out, p) } if len(out) == 0 { return nil, ErrRoleNoPermissions } return out, nil } var nonKey = regexp.MustCompile(`[^a-z0-9]+`) func roleKeyFor(name string) string { k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_") if len(k) > 40 { k = k[:40] } return k } func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) { name = strings.TrimSpace(name) key := roleKeyFor(name) if name == "" || key == "" { return nil, ErrRoleName } clean, err := cleanPermissions(perms) if err != nil { return nil, err } return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean) } func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) { ro, err := s.staff.Role(ctx, id) if err != nil { return nil, err } if ro.IsSystem { return nil, ErrRoleSystem } name = strings.TrimSpace(name) if name == "" { return nil, ErrRoleName } clean, err := cleanPermissions(perms) if err != nil { return nil, err } if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil { return nil, err } return s.staff.Role(ctx, id) } func (s *StaffService) DeleteRole(ctx context.Context, id string) error { ro, err := s.staff.Role(ctx, id) if err != nil { return err } if ro.IsSystem { return ErrRoleSystem } return s.staff.DeleteRole(ctx, id) }