Feat v0.3.4 team roles signoff (#18)
Some checks failed
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Failing after 2m45s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #18.
This commit is contained in:
2026-03-28 01:15:33 +00:00
committed by xcaliber
parent dba718b914
commit 0773c86c27
24 changed files with 1039 additions and 20 deletions

View File

@@ -1,7 +1,10 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -29,11 +32,11 @@ type updateTeamRequest struct {
type addMemberRequest struct {
UserID string `json:"user_id" binding:"required"`
Role string `json:"role" binding:"required,oneof=admin member"`
Role string `json:"role" binding:"required,min=1,max=50"`
}
type updateMemberRequest struct {
Role string `json:"role" binding:"required,oneof=admin member"`
Role string `json:"role" binding:"required,min=1,max=50"`
}
// ── Handler ─────────────────────────────────
@@ -258,6 +261,12 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
return
}
// Validate role against team's configured roles
if err := validateTeamRole(ctx, h.stores, teamID, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role)
if err != nil {
if database.IsUniqueViolation(err) {
@@ -286,6 +295,12 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
return
}
// Validate role against team's configured roles
if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
@@ -403,3 +418,101 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"actions": actions})
}
// ── Team Roles API (v0.3.4) ─────────────────
// builtinRoles are always present in every team's role list.
var builtinRoles = []string{"admin", "member"}
// ListRoles returns the team's configured roles (builtins + custom).
// GET /api/v1/teams/:teamId/roles
func (h *TeamHandler) ListRoles(c *gin.Context) {
teamID := getTeamID(c)
roles := getTeamRoles(c.Request.Context(), h.stores, teamID)
c.JSON(http.StatusOK, gin.H{"data": roles})
}
// UpdateRoles replaces the team's roles array. Builtins (admin, member) are always included.
// PUT /api/v1/teams/:teamId/roles
func (h *TeamHandler) UpdateRoles(c *gin.Context) {
teamID := getTeamID(c)
var body struct {
Roles []string `json:"roles" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure builtins are present
roleSet := map[string]bool{}
for _, r := range builtinRoles {
roleSet[r] = true
}
for _, r := range body.Roles {
if r != "" && len(r) <= 50 {
roleSet[r] = true
}
}
roles := make([]string, 0, len(roleSet))
for r := range roleSet {
roles = append(roles, r)
}
rolesJSON, _ := json.Marshal(map[string]any{"roles": roles})
if err := h.stores.Teams.MergeSettings(c.Request.Context(), teamID, string(rolesJSON)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update roles"})
return
}
c.JSON(http.StatusOK, gin.H{"data": roles})
AuditLog(h.stores.Audit, c, "team.update_roles", "team", teamID, map[string]interface{}{"roles": roles})
}
// getTeamRoles reads the roles array from team settings, falling back to builtins.
func getTeamRoles(ctx context.Context, stores store.Stores, teamID string) []string {
team, err := stores.Teams.GetByID(ctx, teamID)
if err != nil || team.Settings == nil {
return builtinRoles
}
rolesRaw, ok := team.Settings["roles"]
if !ok {
return builtinRoles
}
// rolesRaw is []interface{} from JSONMap
arr, ok := rolesRaw.([]interface{})
if !ok {
return builtinRoles
}
roles := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
roles = append(roles, s)
}
}
if len(roles) == 0 {
return builtinRoles
}
return roles
}
// validateTeamRole checks that a role string is valid for the given team.
func validateTeamRole(ctx context.Context, stores store.Stores, teamID, role string) error {
roles := getTeamRoles(ctx, stores, teamID)
for _, r := range roles {
if r == role {
return nil
}
}
// Also allow builtins unconditionally (in case settings are empty)
for _, r := range builtinRoles {
if r == role {
return nil
}
}
return fmt.Errorf("role %q is not configured for this team", role)
}