Changeset 0.8.0.1 (#43)
This commit is contained in:
16
ROADMAP.md
16
ROADMAP.md
@@ -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
|
||||
|
||||
76
server/database/migrations/016_teams.sql
Normal file
76
server/database/migrations/016_teams.sql
Normal 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;
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
509
server/handlers/teams.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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;
|
||||
|
||||
97
src/js/ui.js
97
src/js/ui.js
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user