Changeset 0.8.0.1 (#43)

This commit is contained in:
2026-02-22 00:39:45 +00:00
parent 8925e8dcbf
commit 1adef94617
14 changed files with 902 additions and 16 deletions

View File

@@ -154,23 +154,23 @@ White-label support and UX improvements that exploit existing infrastructure.
The missing middle tier: scoped administration without system-admin access.
**Schema**
- [ ] `teams` table: id, name, description, created_by
- [ ] `team_members` table: team_id, user_id, role ('admin' | 'member')
- [ ] Add `team_id` (nullable) to: model_presets, channels, (future: notes, KBs)
- [ ] Team admin role: scoped per-team, not system-wide
- [x] `teams` table: id, name, description, created_by
- [x] `team_members` table: team_id, user_id, role ('admin' | 'member')
- [x] Add `team_id` (nullable) to: model_presets, channels, notes
- [x] Team admin role: scoped per-team, not system-wide
(one person can be admin of Team A, member of Team B)
**Onboarding**
- [ ] Registration approval → assign role + team(s) during approval
(upgrade from current binary approve → assign-and-activate)
- [ ] System admin creates teams + assigns first team admin
- [x] System admin creates teams + assigns first team admin
- [ ] Team admin self-manages: add/remove members, create team presets,
manage team channels, view team usage
**Private Provider Policy**
- [ ] `is_private` flag on provider configs (marks local/self-hosted endpoints)
- [ ] `require_private_providers` policy per team
- [ ] Completion handler enforces policy — team members restricted to private providers
- [x] `is_private` flag on provider configs (marks local/self-hosted endpoints)
- [x] `require_private_providers` policy per team (via teams.settings JSONB)
- [x] Completion handler enforces policy — team members restricted to private providers
- [ ] Enables HIPAA/compliance posture: provable data boundary per team
## 0.8.x — Audit + Usage Tracking

View File

@@ -1 +1 @@
0.7.4
0.8.0

View File

@@ -0,0 +1,76 @@
-- ==========================================
-- Migration 016: Teams
-- ==========================================
-- Teams are the middle tier between system admin and individual users.
-- A team admin can manage members, create team-scoped presets,
-- and enforce provider policies — without system-wide access.
-- ==========================================
-- ── 1. Teams table ──────────────────────────
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb, -- team-level policies
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true;
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON TABLE teams IS 'Organizational teams with scoped administration';
COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}';
-- ── 2. Team Members table ───────────────────
CREATE TABLE IF NOT EXISTS team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
COMMENT ON TABLE team_members IS 'Team membership with per-team roles (admin or member)';
COMMENT ON COLUMN team_members.role IS 'admin = manages team; member = uses team resources';
-- ── 3. Wire model_presets.team_id FK ────────
-- Column already exists (migration 012), just add the FK constraint.
DO $$ BEGIN
ALTER TABLE model_presets
ADD CONSTRAINT fk_presets_team
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ── 4. Add team_id to channels ──────────────
-- Team channels are visible to all team members.
ALTER TABLE channels ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
-- ── 5. Private provider flag ────────────────
-- Marks a provider as local/self-hosted (data stays on-prem).
ALTER TABLE api_configs ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false;
COMMENT ON COLUMN api_configs.is_private IS 'Private/self-hosted provider — data does not leave network';
-- ── 6. Add team_id to notes ─────────────────
-- Team notes are shared within the team.
ALTER TABLE notes ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;

View File

@@ -435,6 +435,7 @@ type adminGlobalConfigResponse struct {
Endpoint string `json:"endpoint"`
ModelDefault *string `json:"model_default"`
IsActive bool `json:"is_active"`
IsPrivate bool `json:"is_private"`
HasKey bool `json:"has_key"`
CreatedAt string `json:"created_at"`
}
@@ -442,6 +443,7 @@ type adminGlobalConfigResponse struct {
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, model_default, is_active,
COALESCE(is_private, false),
(api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key,
created_at
FROM api_configs
@@ -459,7 +461,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
var cfg adminGlobalConfigResponse
if err := rows.Scan(
&cfg.ID, &cfg.Name, &cfg.Provider, &cfg.Endpoint,
&cfg.ModelDefault, &cfg.IsActive, &cfg.HasKey, &cfg.CreatedAt,
&cfg.ModelDefault, &cfg.IsActive, &cfg.IsPrivate, &cfg.HasKey, &cfg.CreatedAt,
); err != nil {
continue
}
@@ -478,10 +480,10 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
var id string
err := database.DB.QueryRow(`
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id, is_global)
VALUES ($1, $2, $3, $4, $5, NULL, true)
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id, is_global, is_private)
VALUES ($1, $2, $3, $4, $5, NULL, true, $6)
RETURNING id
`, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id)
`, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, req.IsPrivate).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
@@ -498,6 +500,7 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
Endpoint *string `json:"endpoint"`
APIKey *string `json:"api_key"`
ModelDefault *string `json:"model_default"`
IsPrivate *bool `json:"is_private"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -517,6 +520,7 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
if req.Endpoint != nil { add("endpoint", *req.Endpoint) }
if req.APIKey != nil && *req.APIKey != "" { add("api_key_encrypted", *req.APIKey) }
if req.ModelDefault != nil { add("model_default", *req.ModelDefault) }
if req.IsPrivate != nil { add("is_private", *req.IsPrivate) }
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})

View File

@@ -23,6 +23,7 @@ type createAPIConfigRequest struct {
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
}
type updateAPIConfigRequest struct {
@@ -32,6 +33,7 @@ type updateAPIConfigRequest struct {
ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
}
type apiConfigResponse struct {

View File

@@ -109,6 +109,12 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return
}
// ── Team policy: require_private_providers ──
if err := enforcePrivateProviderPolicy(userID, configID); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
provider, err := providers.Get(providerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

509
server/handlers/teams.go Normal file
View File

@@ -0,0 +1,509 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Request types ───────────────────────────
type createTeamRequest struct {
Name string `json:"name" binding:"required,min=1,max=200"`
Description string `json:"description,omitempty"`
}
type updateTeamRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Settings *string `json:"settings,omitempty"` // JSON string
}
type addMemberRequest struct {
UserID string `json:"user_id" binding:"required"`
Role string `json:"role" binding:"required,oneof=admin member"`
}
type updateMemberRequest struct {
Role string `json:"role" binding:"required,oneof=admin member"`
}
// ── Handler ─────────────────────────────────
type TeamHandler struct{}
func NewTeamHandler() *TeamHandler { return &TeamHandler{} }
// ── Admin: List All Teams ───────────────────
func (h *TeamHandler) ListTeams(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 50
}
offset := (page - 1) * perPage
var total int
if err := database.DB.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count teams"})
return
}
rows, err := database.DB.Query(`
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
COALESCE(t.settings::text, '{}'), t.created_at, t.updated_at,
COALESCE(mc.cnt, 0) AS member_count
FROM teams t
LEFT JOIN (
SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id
) mc ON mc.team_id = t.id
ORDER BY t.name ASC
LIMIT $1 OFFSET $2
`, perPage, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
var teams []gin.H
for rows.Next() {
var id, name, desc, createdBy, settings string
var isActive bool
var memberCount int
var createdAt, updatedAt sql.NullTime
if err := rows.Scan(&id, &name, &desc, &createdBy, &isActive, &settings,
&createdAt, &updatedAt, &memberCount); err != nil {
continue
}
teams = append(teams, gin.H{
"id": id,
"name": name,
"description": desc,
"created_by": createdBy,
"is_active": isActive,
"settings": settings,
"member_count": memberCount,
"created_at": createdAt.Time,
"updated_at": updatedAt.Time,
})
}
if teams == nil {
teams = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{
"data": teams,
"total": total,
"page": page,
"per_page": perPage,
})
}
// ── Admin: Create Team ──────────────────────
func (h *TeamHandler) CreateTeam(c *gin.Context) {
adminID := getUserID(c)
var req createTeamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var id string
err := database.DB.QueryRow(`
INSERT INTO teams (name, description, created_by)
VALUES ($1, $2, $3)
RETURNING id
`, req.Name, req.Description, adminID).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name})
}
// ── Admin: Get Team ─────────────────────────
func (h *TeamHandler) GetTeam(c *gin.Context) {
teamID := c.Param("id")
var name, desc, createdBy, settings string
var isActive bool
var memberCount int
var createdAt, updatedAt sql.NullTime
err := database.DB.QueryRow(`
SELECT t.name, t.description, t.created_by, t.is_active,
COALESCE(t.settings::text, '{}'), t.created_at, t.updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id)
FROM teams t WHERE t.id = $1
`, teamID).Scan(&name, &desc, &createdBy, &isActive, &settings,
&createdAt, &updatedAt, &memberCount)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": teamID,
"name": name,
"description": desc,
"created_by": createdBy,
"is_active": isActive,
"settings": settings,
"member_count": memberCount,
"created_at": createdAt.Time,
"updated_at": updatedAt.Time,
})
}
// ── Admin: Update Team ──────────────────────
func (h *TeamHandler) UpdateTeam(c *gin.Context) {
teamID := c.Param("id")
var req updateTeamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic update
sets := []string{}
args := []interface{}{}
argN := 1
if req.Name != nil {
sets = append(sets, "name = $"+strconv.Itoa(argN))
args = append(args, *req.Name)
argN++
}
if req.Description != nil {
sets = append(sets, "description = $"+strconv.Itoa(argN))
args = append(args, *req.Description)
argN++
}
if req.IsActive != nil {
sets = append(sets, "is_active = $"+strconv.Itoa(argN))
args = append(args, *req.IsActive)
argN++
}
if req.Settings != nil {
sets = append(sets, "settings = $"+strconv.Itoa(argN)+"::jsonb")
args = append(args, *req.Settings)
argN++
}
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
query := "UPDATE teams SET "
for i, s := range sets {
if i > 0 {
query += ", "
}
query += s
}
query += " WHERE id = $" + strconv.Itoa(argN)
args = append(args, teamID)
res, err := database.DB.Exec(query, args...)
if err != nil {
if isUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Admin: Delete Team ──────────────────────
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
teamID := c.Param("id")
res, err := database.DB.Exec(`DELETE FROM teams WHERE id = $1`, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Members: List ───────────────────────────
func (h *TeamHandler) ListMembers(c *gin.Context) {
teamID := c.Param("id")
rows, err := database.DB.Query(`
SELECT tm.id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, '') AS display_name, u.role AS user_role
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = $1
ORDER BY tm.role ASC, u.email ASC
`, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
var members []gin.H
for rows.Next() {
var id, userID, role, email, displayName, userRole string
var joinedAt sql.NullTime
if err := rows.Scan(&id, &userID, &role, &joinedAt, &email, &displayName, &userRole); err != nil {
continue
}
members = append(members, gin.H{
"id": id,
"user_id": userID,
"role": role,
"joined_at": joinedAt.Time,
"email": email,
"display_name": displayName,
"user_role": userRole,
})
}
if members == nil {
members = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"data": members})
}
// ── Members: Add ────────────────────────────
func (h *TeamHandler) AddMember(c *gin.Context) {
teamID := c.Param("id")
var req addMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify team exists
var exists bool
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`, teamID).Scan(&exists)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
// Verify user exists
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, req.UserID).Scan(&exists)
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
return
}
var id string
err := database.DB.QueryRow(`
INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3)
RETURNING id
`, teamID, req.UserID, req.Role).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "user is already a member"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
}
// ── Members: Update Role ────────────────────
func (h *TeamHandler) UpdateMember(c *gin.Context) {
memberID := c.Param("memberId")
var req updateMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := database.DB.Exec(`
UPDATE team_members SET role = $1 WHERE id = $2
`, req.Role, memberID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Members: Remove ─────────────────────────
func (h *TeamHandler) RemoveMember(c *gin.Context) {
memberID := c.Param("memberId")
res, err := database.DB.Exec(`DELETE FROM team_members WHERE id = $1`, memberID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── User: My Teams ──────────────────────────
func (h *TeamHandler) MyTeams(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT t.id, t.name, t.description, t.is_active,
COALESCE(t.settings::text, '{}'),
tm.role AS my_role,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
FROM teams t
JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = $1
WHERE t.is_active = true
ORDER BY t.name ASC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
var teams []gin.H
for rows.Next() {
var id, name, desc, settings, myRole string
var isActive bool
var memberCount int
if err := rows.Scan(&id, &name, &desc, &isActive, &settings, &myRole, &memberCount); err != nil {
continue
}
teams = append(teams, gin.H{
"id": id,
"name": name,
"description": desc,
"is_active": isActive,
"settings": settings,
"my_role": myRole,
"member_count": memberCount,
})
}
if teams == nil {
teams = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"data": teams})
}
// ── Helpers ─────────────────────────────────
// isUniqueViolation checks if a PG error is a unique constraint violation.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "duplicate key")
}
// IsTeamAdmin checks if a user is an admin of the given team.
func IsTeamAdmin(userID, teamID string) bool {
var role string
err := database.DB.QueryRow(`
SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2
`, teamID, userID).Scan(&role)
return err == nil && role == "admin"
}
// IsTeamMember checks if a user belongs to the given team (any role).
func IsTeamMember(userID, teamID string) bool {
var exists bool
database.DB.QueryRow(`
SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)
`, teamID, userID).Scan(&exists)
return exists
}
// enforcePrivateProviderPolicy checks if a user belongs to any team that
// requires private providers, and if so, verifies the resolved config is
// marked as private. Returns nil if allowed, error if blocked.
func enforcePrivateProviderPolicy(userID, configID string) error {
if configID == "" {
return nil
}
// Check if user belongs to any team with require_private_providers policy
var requiresPrivate bool
err := database.DB.QueryRow(`
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = $1
AND t.is_active = true
AND t.settings->>'require_private_providers' = 'true'
)
`, userID).Scan(&requiresPrivate)
if err != nil || !requiresPrivate {
return nil
}
// User is in a restricted team — verify the config is private
var isPrivate bool
err = database.DB.QueryRow(`
SELECT COALESCE(is_private, false) FROM api_configs WHERE id = $1
`, configID).Scan(&isPrivate)
if err != nil {
return nil // config lookup failed, allow (fail open)
}
if !isPrivate {
return fmt.Errorf("your team requires private providers — this provider sends data externally")
}
return nil
}

View File

@@ -151,6 +151,10 @@ func main() {
// Notes
notes := handlers.NewNoteHandler()
protected.GET("/notes", notes.List)
// Teams (user: my teams)
teams := handlers.NewTeamHandler()
protected.GET("/teams/mine", teams.MyTeams)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
@@ -208,6 +212,18 @@ func main() {
admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset)
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Teams (admin)
teamAdm := handlers.NewTeamHandler()
admin.GET("/teams", teamAdm.ListTeams)
admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam)
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
admin.DELETE("/teams/:id", teamAdm.DeleteTeam)
admin.GET("/teams/:id/members", teamAdm.ListMembers)
admin.POST("/teams/:id/members", teamAdm.AddMember)
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
}
}

View File

@@ -177,6 +177,35 @@ type ModelPreset struct {
Avatar string `json:"avatar,omitempty" db:"avatar"`
}
// ── Teams ───────────────────────────────────
const (
TeamRoleAdmin = "admin"
TeamRoleMember = "member"
)
type Team struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
Settings string `json:"settings,omitempty" db:"settings"` // JSON
MemberCount int `json:"member_count,omitempty"` // computed, not stored
}
type TeamMember struct {
ID string `json:"id" db:"id"`
TeamID string `json:"team_id" db:"team_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
JoinedAt string `json:"joined_at" db:"joined_at"`
// Joined fields (from user)
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
UserRole string `json:"user_role,omitempty"` // system role (user/admin)
}
// ── Settings ────────────────────────────────
type Settings struct {

View File

@@ -1003,6 +1003,7 @@ button { font-family: var(--font); cursor: pointer; }
.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); }
.admin-user-actions .btn-danger { color: var(--danger); }
.admin-user-actions .btn-danger:hover { border-color: var(--danger); }
.inline-select { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 3px 8px; font-size: 12px; cursor: pointer; }
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; }
.admin-model-row .model-name { flex: 1; font-weight: 500; }
@@ -1077,6 +1078,7 @@ button { font-family: var(--font); cursor: pointer; }
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.admin-user-row.user-inactive { opacity: 0.7; }
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }

View File

@@ -317,6 +317,7 @@
<button class="admin-tab" data-tab="providers">Providers</button>
<button class="admin-tab" data-tab="models">Models</button>
<button class="admin-tab" data-tab="presets">Presets</button>
<button class="admin-tab" data-tab="teams">Teams</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="stats">Stats</button>
</div>
@@ -352,6 +353,7 @@
<div class="form-group"><label>API Key</label><input type="password" id="adminProvKey" placeholder="sk-..."></div>
<div class="form-group"><label>Default Model</label><input type="text" id="adminProvModel" placeholder="gpt-4o"></div>
</div>
<label class="checkbox-label"><input type="checkbox" id="adminProvPrivate"> Private provider (data stays on-prem)</label>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateProvBtn">Save</button><button class="btn-small" id="adminCancelProvBtn">Cancel</button></div>
</div>
<div id="adminProviderList"></div>
@@ -399,6 +401,39 @@
</div>
<div id="adminPresetList"></div>
</div>
<div class="admin-tab-content" id="adminTeamsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddTeamBtn">+ New Team</button>
</div>
<div id="adminAddTeamForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Team Name</label><input type="text" id="adminTeamName" placeholder="Engineering"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Core engineering team"></div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateTeamBtn">Create</button><button class="btn-small" id="adminCancelTeamBtn">Cancel</button></div>
</div>
<div id="adminTeamList"></div>
<!-- Team detail / member management (shown when editing a team) -->
<div id="adminTeamDetail" style="display:none">
<button class="notes-back-btn" id="adminTeamBackBtn">← Back to teams</button>
<h3 id="adminTeamDetailName" style="margin:8px 0 12px;font-size:15px"></h3>
<section class="settings-section" style="margin-bottom:12px;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (data stays on-prem)</label>
<p class="section-hint" style="margin:4px 0 0">Team members can only use providers marked as "private".</p>
</section>
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddMemberBtn">+ Add Member</button>
</div>
<div id="adminAddMemberForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>User</label><select id="adminMemberUser"></select></div>
<div class="form-group"><label>Team Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminAddMemberSubmit">Add</button><button class="btn-small" id="adminCancelMemberBtn">Cancel</button></div>
</div>
<div id="adminMemberList"></div>
</div>
</div>
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<section class="settings-section">
<h3>Registration</h3>

View File

@@ -263,9 +263,9 @@ const API = {
adminGetStats() { return this._get('/api/v1/admin/stats'); },
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
return this._post('/api/v1/admin/configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
@@ -285,6 +285,20 @@ const API = {
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
// ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/admin/teams'); },
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
// ── User Presets ─────────────────────────
listPresets() { return this._get('/api/v1/presets'); },
createPreset(preset) { return this._post('/api/v1/presets', preset); },

View File

@@ -980,6 +980,7 @@ function initListeners() {
document.getElementById('adminProvEndpoint').value = '';
document.getElementById('adminProvKey').value = '';
document.getElementById('adminProvModel').value = '';
document.getElementById('adminProvPrivate').checked = false;
const f = document.getElementById('adminAddProviderForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
@@ -1061,6 +1062,60 @@ function initListeners() {
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
});
// Admin — Teams
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = '';
document.getElementById('adminTeamName').focus();
});
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = 'none';
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
});
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminTeamName').value.trim();
const desc = document.getElementById('adminTeamDesc').value.trim();
if (!name) return UI.toast('Team name required', 'error');
try {
await API.adminCreateTeam(name, desc);
document.getElementById('adminAddTeamForm').style.display = 'none';
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
UI.toast('Team created');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => {
document.getElementById('adminTeamDetail').style.display = 'none';
document.getElementById('adminTeamList').style.display = '';
});
document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddMemberForm').style.display = '';
await UI.loadMemberUserDropdown(UI._teamEditId);
});
document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ require_private_providers: this.checked })
});
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddMemberForm').style.display = 'none';
});
document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => {
const userId = document.getElementById('adminMemberUser').value;
const role = document.getElementById('adminMemberRole').value;
if (!userId) return UI.toast('Select a user', 'error');
try {
await API.adminAddMember(UI._teamEditId, userId, role);
document.getElementById('adminAddMemberForm').style.display = 'none';
UI.toast('Member added');
await UI.loadTeamMembers(UI._teamEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
@@ -1424,6 +1479,7 @@ function editGlobalProvider(id) {
document.getElementById('adminProvKey').value = '';
document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...';
document.getElementById('adminProvModel').value = c.model_default || '';
document.getElementById('adminProvPrivate').checked = !!c.is_private;
document.getElementById('adminCreateProvBtn').textContent = 'Update';
}
@@ -1434,6 +1490,7 @@ async function createGlobalProvider() {
const ep = document.getElementById('adminProvEndpoint').value.trim();
const key = document.getElementById('adminProvKey').value;
const model = document.getElementById('adminProvModel').value.trim();
const isPrivate = document.getElementById('adminProvPrivate').checked;
if (_editingProviderId) {
// Update mode
@@ -1442,12 +1499,13 @@ async function createGlobalProvider() {
if (ep) updates.endpoint = ep;
if (key) updates.api_key = key;
updates.model_default = model;
updates.is_private = isPrivate;
await API.adminUpdateGlobalConfig(_editingProviderId, updates);
UI.toast('Provider updated', 'success');
} else {
// Create mode
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(name, prov, ep, key, model);
await API.adminCreateGlobalConfig(name, prov, ep, key, model, isPrivate);
UI.toast('Provider added', 'success');
}
_editingProviderId = null;
@@ -1585,6 +1643,44 @@ async function deleteAdminPreset(id, name) {
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Team Admin Functions ────────────────────
async function toggleTeamActive(id, active) {
try {
await API.adminUpdateTeam(id, { is_active: active });
UI.toast(active ? 'Team activated' : 'Team deactivated');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteTeam(id, name) {
if (!confirm(`Delete team "${name}"? All members will be removed.`)) return;
try {
await API.adminDeleteTeam(id);
UI.toast('Team deleted');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function updateTeamMember(teamId, memberId, role) {
try {
await API.adminUpdateMember(teamId, memberId, role);
UI.toast('Role updated');
} catch (e) {
UI.toast(e.message, 'error');
await UI.loadTeamMembers(teamId);
}
}
async function removeTeamMember(teamId, memberId, email) {
if (!confirm(`Remove ${email} from team?`)) return;
try {
await API.adminRemoveMember(teamId, memberId);
UI.toast('Member removed');
await UI.loadTeamMembers(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Notes Panel ─────────────────────────────
var _editingNoteId = null;

View File

@@ -745,6 +745,7 @@ const UI = {
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
},
@@ -799,6 +800,7 @@ const UI = {
return `<div class="admin-provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
${c.is_private ? '<span class="badge-private">private</span>' : ''}
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
@@ -895,6 +897,101 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Teams Admin ─────────────────────────
_teamEditId: null,
async loadAdminTeams(quiet) {
const el = document.getElementById('adminTeamList');
const detail = document.getElementById('adminTeamDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddTeamForm').style.display = 'none';
}
try {
const resp = await API.adminListTeams();
const teams = resp.data || [];
el.innerHTML = teams.map(t => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(t.name)}</strong>
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
</div>
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openTeamDetail(teamId, teamName) {
this._teamEditId = teamId;
document.getElementById('adminTeamList').style.display = 'none';
document.getElementById('adminAddTeamForm').style.display = 'none';
const detail = document.getElementById('adminTeamDetail');
detail.style.display = '';
document.getElementById('adminTeamDetailName').textContent = teamName;
document.getElementById('adminAddMemberForm').style.display = 'none';
// Load team settings for policy checkbox
try {
const team = await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
} catch (e) { /* proceed with unchecked default */ }
await this.loadTeamMembers(teamId);
},
async loadTeamMembers(teamId) {
const el = document.getElementById('adminMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
</div>
<div class="text-muted">${esc(m.email)}</div>
</div>
<div class="admin-user-actions">
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
</select>
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMemberUserDropdown(teamId) {
const sel = document.getElementById('adminMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.data || [];
// Also get existing members to exclude
const memberResp = await API.adminListMembers(teamId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();