321 lines
12 KiB
Go
321 lines
12 KiB
Go
package staff
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"service-center/internal/auth"
|
|
"service-center/internal/roles"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type Handler struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
func NewHandler(db *pgxpool.Pool) *Handler {
|
|
return &Handler{db: db}
|
|
}
|
|
|
|
// Create provisions a new staff account. Route-level gate is staffPerm (see
|
|
// Update's own doc comment for why that's deliberately broader than literal
|
|
// owner) — but minting a brand-new role="owner" account this way is the
|
|
// same privilege-escalation shape Update had, one step more direct even
|
|
// (no target account to already exist, no last-active-owner check to dodge):
|
|
// any "staff"-permission holder could otherwise create a fresh owner
|
|
// account with a password of their own choosing. Any other role stays
|
|
// staffPerm-only — onboarding a regular employee is legitimate HR work.
|
|
func (h *Handler) Create(c *fiber.Ctx) error {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if body.Name == "" || body.Email == "" || len(body.Password) < 8 || body.Role == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "name, email, role required, password must be at least 8 characters"})
|
|
}
|
|
if body.Role == "owner" && auth.StaffRole(c) != "owner" {
|
|
return c.Status(403).JSON(fiber.Map{"error": "only the owner role can create another owner account"})
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 12)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
var id string
|
|
err = h.db.QueryRow(context.Background(),
|
|
`INSERT INTO staff_users (name, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
|
|
body.Name, body.Email, string(hash), body.Role,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return c.Status(409).JSON(fiber.Map{"error": "email already taken"})
|
|
}
|
|
// role now has a real FK to roles(name) (migrations/004_roles.sql) —
|
|
// an unknown role name (typo, or a role that was since deleted)
|
|
// surfaces here as a real FK violation instead of the old hardcoded
|
|
// switch statement, same pattern this app already uses for
|
|
// client_id/order_id FK checks elsewhere.
|
|
if isFKViolation(err) {
|
|
return c.Status(400).JSON(fiber.Map{"error": "unknown role"})
|
|
}
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
return c.Status(201).JSON(fiber.Map{"id": id, "name": body.Name, "email": body.Email, "role": body.Role})
|
|
}
|
|
|
|
// List returns all staff accounts, each with its role's base permissions
|
|
// plus the effective set after this staff member's own grants/revokes are
|
|
// applied — the StaffPage permission editor needs both to show which
|
|
// checkboxes come from the role vs. are a point override. Owner-only.
|
|
func (h *Handler) List(c *fiber.Ctx) error {
|
|
rows, err := h.db.Query(context.Background(),
|
|
`SELECT su.id, su.name, su.email, su.role, su.is_active, su.created_at, su.last_login_at,
|
|
su.permission_grants, su.permission_revokes, r.permissions
|
|
FROM staff_users su JOIN roles r ON r.name = su.role
|
|
ORDER BY su.created_at`)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
type staffRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
IsActive bool `json:"is_active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastLoginAt *time.Time `json:"last_login_at"`
|
|
PermissionGrants []string `json:"permission_grants"`
|
|
PermissionRevokes []string `json:"permission_revokes"`
|
|
RolePermissions []string `json:"role_permissions"`
|
|
EffectivePermissions []string `json:"effective_permissions"`
|
|
}
|
|
var out []staffRow
|
|
for rows.Next() {
|
|
var r staffRow
|
|
var rolePerms []string
|
|
if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Role, &r.IsActive, &r.CreatedAt, &r.LastLoginAt,
|
|
&r.PermissionGrants, &r.PermissionRevokes, &rolePerms); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
r.RolePermissions = rolePerms
|
|
r.EffectivePermissions = effectivePermissions(rolePerms, r.PermissionGrants, r.PermissionRevokes)
|
|
out = append(out, r)
|
|
}
|
|
return c.JSON(out)
|
|
}
|
|
|
|
// effectivePermissions is the same union-then-subtract rule Login uses to
|
|
// build the JWT claim (see internal/auth/handler.go) — kept here too so
|
|
// List can show StaffPage what a staff member's permissions actually
|
|
// resolve to without requiring a re-login to preview it.
|
|
func effectivePermissions(rolePerms, grants, revokes []string) []string {
|
|
revoked := make(map[string]bool, len(revokes))
|
|
for _, p := range revokes {
|
|
revoked[p] = true
|
|
}
|
|
seen := make(map[string]bool, len(rolePerms)+len(grants))
|
|
out := []string{}
|
|
for _, p := range append(append([]string{}, rolePerms...), grants...) {
|
|
if revoked[p] || seen[p] {
|
|
continue
|
|
}
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListAssignable is the staff picker feed for modules (e.g. production's
|
|
// order/cartridge-batch master assignment) — any authenticated staff role
|
|
// can call it, unlike List, so it deliberately returns only id/name/role/
|
|
// is_active, never email or last_login_at.
|
|
func (h *Handler) ListAssignable(c *fiber.Ctx) error {
|
|
rows, err := h.db.Query(context.Background(),
|
|
`SELECT id, name, role FROM staff_users WHERE is_active = true ORDER BY name`)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
type assignableRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
}
|
|
out := []assignableRow{}
|
|
for rows.Next() {
|
|
var r assignableRow
|
|
if err := rows.Scan(&r.ID, &r.Name, &r.Role); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return c.JSON(out)
|
|
}
|
|
|
|
// Update changes a staff account's role, active status, and/or point
|
|
// permission overrides — never the password (see ResetPassword) or email
|
|
// (would need a re-verification flow this app doesn't have). Route-level
|
|
// gate is staffPerm (any role holding the "staff" permission, not just the
|
|
// literal owner — see main.go), which is deliberately broad enough for a
|
|
// custom "HR" role to create accounts/reset passwords/toggle is_active.
|
|
// Touching role or the permission overrides is a different tier: those two
|
|
// fields are the entire permission model, so only the literal owner role
|
|
// may set them — otherwise a custom role holding nothing but "staff" could
|
|
// grant itself (or anyone) every permission in the system, or promote
|
|
// itself straight to role=owner, via this same endpoint. This is a real
|
|
// privilege-escalation path that existed before this check: any account
|
|
// with just the "staff" permission could PATCH its own id with
|
|
// permission_grants=[every key] or role="owner" and there was nothing
|
|
// stopping it.
|
|
func (h *Handler) Update(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
var body struct {
|
|
Role *string `json:"role"`
|
|
IsActive *bool `json:"is_active"`
|
|
PermissionGrants []string `json:"permission_grants"`
|
|
PermissionRevokes []string `json:"permission_revokes"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if body.Role != nil && *body.Role == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "role must not be empty"})
|
|
}
|
|
touchesPermissionModel := body.Role != nil || body.PermissionGrants != nil || body.PermissionRevokes != nil
|
|
if touchesPermissionModel && auth.StaffRole(c) != "owner" {
|
|
return c.Status(403).JSON(fiber.Map{"error": "only the owner role can change a staff member's role or permission overrides"})
|
|
}
|
|
// PermissionGrants/Revokes are only set (non-nil) when StaffPage's
|
|
// editor actually submitted an override change — Update's other two
|
|
// fields (role/is_active) are used independently elsewhere (the role
|
|
// dropdown, the enable/disable button) and must not implicitly wipe
|
|
// overrides just because they weren't part of that particular request.
|
|
var grantsParam, revokesParam []string
|
|
if body.PermissionGrants != nil || body.PermissionRevokes != nil {
|
|
grants, revokes := body.PermissionGrants, body.PermissionRevokes
|
|
if grants == nil {
|
|
grants = []string{}
|
|
}
|
|
if revokes == nil {
|
|
revokes = []string{}
|
|
}
|
|
if msg := roles.ValidatePermissions(grants); msg != "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "permission_grants: " + msg})
|
|
}
|
|
if msg := roles.ValidatePermissions(revokes); msg != "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "permission_revokes: " + msg})
|
|
}
|
|
revokeSet := make(map[string]bool, len(revokes))
|
|
for _, p := range revokes {
|
|
revokeSet[p] = true
|
|
}
|
|
for _, p := range grants {
|
|
if revokeSet[p] {
|
|
return c.Status(400).JSON(fiber.Map{"error": "permission cannot be both granted and revoked: " + p})
|
|
}
|
|
}
|
|
grantsParam, revokesParam = grants, revokes
|
|
}
|
|
|
|
ctx := context.Background()
|
|
// Demoting or disabling the last active owner would lock every staff
|
|
// member out of the parts of the app owner-only routes gate (staff
|
|
// management itself included) with no way back in short of a direct DB
|
|
// edit — reject it here instead. "Last" is evaluated against the target
|
|
// row itself, so an owner can always freely edit an account that isn't
|
|
// the sole remaining active owner.
|
|
if (body.Role != nil && *body.Role != "owner") || (body.IsActive != nil && !*body.IsActive) {
|
|
var isSoleActiveOwner bool
|
|
err := h.db.QueryRow(ctx,
|
|
`SELECT role = 'owner' AND is_active AND (
|
|
SELECT COUNT(*) FROM staff_users WHERE role = 'owner' AND is_active
|
|
) = 1 FROM staff_users WHERE id = $1::uuid`, id,
|
|
).Scan(&isSoleActiveOwner)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
if isSoleActiveOwner {
|
|
return c.Status(409).JSON(fiber.Map{"error": "cannot demote or disable the last active owner"})
|
|
}
|
|
}
|
|
|
|
tag, err := h.db.Exec(ctx,
|
|
`UPDATE staff_users SET
|
|
role = COALESCE($1, role),
|
|
is_active = COALESCE($2, is_active),
|
|
permission_grants = COALESCE($3, permission_grants),
|
|
permission_revokes = COALESCE($4, permission_revokes)
|
|
WHERE id = $5::uuid`,
|
|
body.Role, body.IsActive, grantsParam, revokesParam, id)
|
|
if err != nil {
|
|
if isFKViolation(err) {
|
|
return c.Status(400).JSON(fiber.Map{"error": "unknown role"})
|
|
}
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return c.Status(404).JSON(fiber.Map{"error": "staff not found"})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ResetPassword sets a new password directly — there's no email
|
|
// infrastructure in this stack for a self-service "forgot password" link
|
|
// (same reasoning as internal/notify's Telegram-only staff alerts), so an
|
|
// owner resets it and relays the new password to the staff member out of
|
|
// band. Owner-only.
|
|
func (h *Handler) ResetPassword(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
var body struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil || len(body.Password) < 8 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "password must be at least 8 characters"})
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 12)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
tag, err := h.db.Exec(context.Background(),
|
|
`UPDATE staff_users SET password_hash = $1 WHERE id = $2::uuid`, string(hash), id)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return c.Status(404).JSON(fiber.Map{"error": "staff not found"})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
return err != nil && contains(err.Error(), "duplicate key value")
|
|
}
|
|
|
|
func isFKViolation(err error) bool {
|
|
return err != nil && contains(err.Error(), "violates foreign key constraint")
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
for i := 0; i+len(substr) <= len(s); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|