Feat v0.9.3 team user roles (#77)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / build-and-deploy (push) Successful in 27s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #77.
This commit is contained in:
2026-04-03 15:51:31 +00:00
committed by xcaliber
parent 0cae963480
commit 0661e1d768
17 changed files with 852 additions and 12 deletions

View File

@@ -360,6 +360,11 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
return
}
// Clean up additional roles from team_user_roles
if affectedUID != "" {
_ = h.stores.Teams.RemoveAllUserRoles(c.Request.Context(), teamID, affectedUID)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
if affectedUID != "" {
h.notifyAuthChanged(affectedUID, "team_member_removed")
@@ -449,6 +454,106 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"actions": actions})
}
// ── Member User Roles (many-to-many) ─────────
// ListMemberRoles returns the full effective role set for a team member.
// GET /api/v1/teams/:teamId/members/:memberId/roles
func (h *TeamHandler) ListMemberRoles(c *gin.Context) {
teamID := getTeamID(c)
memberID := c.Param("memberId")
uid := h.memberUserID(c.Request.Context(), teamID, memberID)
if uid == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
roles, err := h.stores.Teams.GetMemberRoles(c.Request.Context(), teamID, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// Also fetch the primary role for the response
member, _ := h.stores.Teams.GetMember(c.Request.Context(), teamID, uid)
primary := ""
if member != nil {
primary = member.Role
}
c.JSON(http.StatusOK, gin.H{"data": roles, "primary": primary})
}
// AddMemberRole assigns an additional role to a team member.
// POST /api/v1/teams/:teamId/members/:memberId/roles
func (h *TeamHandler) AddMemberRole(c *gin.Context) {
teamID := getTeamID(c)
memberID := c.Param("memberId")
var req struct {
Role string `json:"role" binding:"required,min=1,max=50"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid := h.memberUserID(c.Request.Context(), teamID, memberID)
if uid == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
assignedBy := getUserID(c)
if err := h.stores.Teams.AddUserRole(c.Request.Context(), teamID, uid, req.Role, assignedBy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add role"})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true})
h.notifyAuthChanged(uid, "team_role_added")
AuditLog(h.stores.Audit, c, "team.add_member_role", "team", teamID, map[string]interface{}{
"member_id": memberID, "user_id": uid, "role": req.Role,
})
}
// RemoveMemberRole removes an additional role from a team member.
// DELETE /api/v1/teams/:teamId/members/:memberId/roles/:role
func (h *TeamHandler) RemoveMemberRole(c *gin.Context) {
teamID := getTeamID(c)
memberID := c.Param("memberId")
role := c.Param("role")
uid := h.memberUserID(c.Request.Context(), teamID, memberID)
if uid == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
// Don't allow removing the primary role via this endpoint
member, _ := h.stores.Teams.GetMember(c.Request.Context(), teamID, uid)
if member != nil && member.Role == role {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot remove primary role; use member update instead"})
return
}
if err := h.stores.Teams.RemoveUserRole(c.Request.Context(), teamID, uid, role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove role"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
h.notifyAuthChanged(uid, "team_role_removed")
AuditLog(h.stores.Audit, c, "team.remove_member_role", "team", teamID, map[string]interface{}{
"member_id": memberID, "user_id": uid, "role": role,
})
}
// ── Team Roles API ─────────────────
// builtinRoles are always present in every team's role list.