diff --git a/ROADMAP.md b/ROADMAP.md index 8b1014c..8304f8a 100644 --- a/ROADMAP.md +++ b/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 diff --git a/VERSION b/VERSION index 0a1ffad..8adc70f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.4 +0.8.0 \ No newline at end of file diff --git a/server/database/migrations/016_teams.sql b/server/database/migrations/016_teams.sql new file mode 100644 index 0000000..c7ddfac --- /dev/null +++ b/server/database/migrations/016_teams.sql @@ -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; diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 9be9d76..98655e7 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -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"}) diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 8513698..0d4f882 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -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 { diff --git a/server/handlers/completion.go b/server/handlers/completion.go index a3b5ffc..fc73d9c 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -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()}) diff --git a/server/handlers/teams.go b/server/handlers/teams.go new file mode 100644 index 0000000..3bfaa1d --- /dev/null +++ b/server/handlers/teams.go @@ -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 +} diff --git a/server/main.go b/server/main.go index 5ad54c5..cb5cd21 100644 --- a/server/main.go +++ b/server/main.go @@ -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) } } diff --git a/server/models/models.go b/server/models/models.go index 0dd8479..9a9639a 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -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 { diff --git a/src/css/styles.css b/src/css/styles.css index 30ce47f..818cb29 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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; } diff --git a/src/index.html b/src/index.html index 6929978..6ad5fb6 100644 --- a/src/index.html +++ b/src/index.html @@ -317,6 +317,7 @@ + @@ -352,6 +353,7 @@
+
@@ -399,6 +401,39 @@
+