remove token budgets + allowed models from groups
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m12s
CI/CD / test-sqlite (pull_request) Successful in 2m45s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m12s
CI/CD / test-sqlite (pull_request) Successful in 2m45s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s
Provider-era cruft: token_budget_daily, token_budget_monthly, and allowed_models columns removed from groups table (both dialects). ResolveTokenBudget() and ResolveModelAllowlist() deleted. GroupPatch trimmed to name/description/permissions. Admin groups UI simplified to permissions-only. -283 lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -27,9 +27,21 @@ All notable changes to Switchboard Core are documented here.
|
|||||||
- Demotion/deletion safeguards count Admins group members instead of `CountByRole`
|
- Demotion/deletion safeguards count Admins group members instead of `CountByRole`
|
||||||
- Kernel permissions: 6 → 7 (added `surface.admin.access`)
|
- Kernel permissions: 6 → 7 (added `surface.admin.access`)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Token budgets** from groups: `token_budget_daily`, `token_budget_monthly`
|
||||||
|
columns, `ResolveTokenBudget()`, and all store/handler/UI code. Provider-era
|
||||||
|
cruft — token budgets belong in a future provider extension, not the kernel.
|
||||||
|
- **Allowed models** from groups: `allowed_models` column,
|
||||||
|
`ResolveModelAllowlist()`, `toggleModel` UI. Same rationale.
|
||||||
|
- `GroupPatch` fields: `ClearBudgetDaily`, `ClearBudgetMonthly`,
|
||||||
|
`ClearAllowedModels` — no longer needed
|
||||||
|
- Admin groups UI: Token Budgets section, Allowed Models section, models API call
|
||||||
|
|
||||||
### Migration notes
|
### Migration notes
|
||||||
|
|
||||||
- 002_teams.sql (both dialects): added Admins group seed with all permissions
|
- 002_teams.sql (both dialects): added Admins group seed, removed
|
||||||
|
`token_budget_daily`, `token_budget_monthly`, `allowed_models` columns
|
||||||
- No new migration files — edited in place per pre-MVP policy
|
- No new migration files — edited in place per pre-MVP policy
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/store"
|
"switchboard-core/store"
|
||||||
)
|
)
|
||||||
@@ -80,91 +79,3 @@ func SyncAdminsGroupMembership(ctx context.Context, stores store.Stores, userID,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Token Budget ────────────────────────────
|
|
||||||
|
|
||||||
// TokenBudget holds the resolved ceiling for a user and the time window it covers.
|
|
||||||
type TokenBudget struct {
|
|
||||||
Limit int64
|
|
||||||
Period string // "daily" or "monthly"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveTokenBudget returns the most restrictive token budget across all of the
|
|
||||||
// user's groups. Returns nil if no group has a budget set (unrestricted).
|
|
||||||
// Callers should skip budget enforcement when providerScope == "personal" (BYOK).
|
|
||||||
func ResolveTokenBudget(ctx context.Context, stores store.Stores, userID string) (*TokenBudget, error) {
|
|
||||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
isNewDay := now.Hour() < 1
|
|
||||||
_ = isNewDay // used by callers for cache invalidation hints if needed
|
|
||||||
|
|
||||||
var daily, monthly *int64
|
|
||||||
for _, g := range groups {
|
|
||||||
if g.TokenBudgetDaily != nil {
|
|
||||||
if daily == nil || *g.TokenBudgetDaily < *daily {
|
|
||||||
daily = g.TokenBudgetDaily
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if g.TokenBudgetMonthly != nil {
|
|
||||||
if monthly == nil || *g.TokenBudgetMonthly < *monthly {
|
|
||||||
monthly = g.TokenBudgetMonthly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the most restrictive window. Daily wins when both are set
|
|
||||||
// and daily is the binding constraint.
|
|
||||||
if daily != nil {
|
|
||||||
return &TokenBudget{Limit: *daily, Period: "daily"}, nil
|
|
||||||
}
|
|
||||||
if monthly != nil {
|
|
||||||
return &TokenBudget{Limit: *monthly, Period: "monthly"}, nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Model Allowlist ─────────────────────────
|
|
||||||
|
|
||||||
// ResolveModelAllowlist returns the union of allowed model IDs across all of the
|
|
||||||
// user's groups. Returns nil if the user is unrestricted (any group has a nil
|
|
||||||
// allowlist, which means "no restriction").
|
|
||||||
func ResolveModelAllowlist(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
|
||||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// If any group has nil AllowedModels, the user is unrestricted.
|
|
||||||
for _, g := range groups {
|
|
||||||
if g.AllowedModels == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All groups have explicit allowlists — union them.
|
|
||||||
allowed := make(map[string]bool)
|
|
||||||
for _, g := range groups {
|
|
||||||
for _, m := range g.AllowedModels {
|
|
||||||
allowed[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No groups at all → fall through to Everyone group check.
|
|
||||||
if len(groups) == 0 {
|
|
||||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
|
||||||
if everyone.AllowedModels == nil {
|
|
||||||
return nil, nil // Everyone has no restriction
|
|
||||||
}
|
|
||||||
for _, m := range everyone.AllowedModels {
|
|
||||||
allowed[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allowed) == 0 {
|
|
||||||
return nil, nil // empty allowlists across all groups = unrestricted
|
|
||||||
}
|
|
||||||
return allowed, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
source VARCHAR(20) NOT NULL DEFAULT 'manual'
|
source VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||||
CHECK (source IN ('manual', 'oidc', 'system')),
|
CHECK (source IN ('manual', 'oidc', 'system')),
|
||||||
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
token_budget_daily BIGINT,
|
|
||||||
token_budget_monthly BIGINT,
|
|
||||||
allowed_models JSONB,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
CONSTRAINT groups_scope_team CHECK (
|
CONSTRAINT groups_scope_team CHECK (
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
created_by TEXT REFERENCES users(id),
|
created_by TEXT REFERENCES users(id),
|
||||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')),
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')),
|
||||||
permissions TEXT NOT NULL DEFAULT '[]',
|
permissions TEXT NOT NULL DEFAULT '[]',
|
||||||
token_budget_daily INTEGER,
|
|
||||||
token_budget_monthly INTEGER,
|
|
||||||
allowed_models TEXT,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,15 +25,9 @@ type createGroupRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type updateGroupRequest struct {
|
type updateGroupRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Permissions *[]string `json:"permissions,omitempty"`
|
Permissions *[]string `json:"permissions,omitempty"`
|
||||||
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"`
|
|
||||||
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"`
|
|
||||||
AllowedModels *[]string `json:"allowed_models,omitempty"`
|
|
||||||
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
|
|
||||||
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
|
|
||||||
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type addGroupMemberRequest struct {
|
type addGroupMemberRequest struct {
|
||||||
@@ -144,15 +138,9 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
patch := models.GroupPatch{
|
patch := models.GroupPatch{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Permissions: req.Permissions,
|
Permissions: req.Permissions,
|
||||||
TokenBudgetDaily: req.TokenBudgetDaily,
|
|
||||||
TokenBudgetMonthly: req.TokenBudgetMonthly,
|
|
||||||
AllowedModels: req.AllowedModels,
|
|
||||||
ClearBudgetDaily: req.ClearBudgetDaily,
|
|
||||||
ClearBudgetMonthly: req.ClearBudgetMonthly,
|
|
||||||
ClearAllowedModels: req.ClearAllowedModels,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate permissions if provided
|
// Validate permissions if provided
|
||||||
|
|||||||
@@ -316,24 +316,15 @@ type Group struct {
|
|||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||||
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
|
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
|
||||||
Source string `json:"source" db:"source"` // manual (default), oidc, system
|
Source string `json:"source" db:"source"` // manual (default), oidc, system
|
||||||
Permissions []string `json:"permissions" db:"permissions"`
|
Permissions []string `json:"permissions" db:"permissions"`
|
||||||
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty" db:"token_budget_daily"`
|
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
|
||||||
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty" db:"token_budget_monthly"`
|
|
||||||
AllowedModels []string `json:"allowed_models,omitempty" db:"allowed_models"` // nil = unrestricted
|
|
||||||
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupPatch holds optional fields for updating a group.
|
// GroupPatch holds optional fields for updating a group.
|
||||||
type GroupPatch struct {
|
type GroupPatch struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Permissions *[]string `json:"permissions,omitempty"`
|
Permissions *[]string `json:"permissions,omitempty"`
|
||||||
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"`
|
|
||||||
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"`
|
|
||||||
AllowedModels *[]string `json:"allowed_models,omitempty"` // &[]string{} = restrict to none; nil = no change
|
|
||||||
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
|
|
||||||
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
|
|
||||||
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupMember links a user to a group.
|
// GroupMember links a user to a group.
|
||||||
|
|||||||
@@ -43,17 +43,14 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
|||||||
g.created_at, g.updated_at,
|
g.created_at, g.updated_at,
|
||||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||||
COALESCE(g.source, 'manual'),
|
COALESCE(g.source, 'manual'),
|
||||||
COALESCE(g.permissions, '[]'::jsonb),
|
COALESCE(g.permissions, '[]'::jsonb)
|
||||||
g.token_budget_daily,
|
|
||||||
g.token_budget_monthly,
|
|
||||||
g.allowed_models
|
|
||||||
FROM groups g WHERE g.id = $1`, id)
|
FROM groups g WHERE g.id = $1`, id)
|
||||||
return scanGroup(row)
|
return scanGroup(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update applies a patch to a group.
|
// Update applies a patch to a group.
|
||||||
// The Everyone group (source=system) allows permission/budget edits but not
|
// System groups (source=system) allow permission edits but not name,
|
||||||
// name, description, or structural changes — those fields are silently ignored.
|
// description, or structural changes — those fields are silently ignored.
|
||||||
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
|
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
|
||||||
var source string
|
var source string
|
||||||
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
|
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
|
||||||
@@ -76,25 +73,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa
|
|||||||
}
|
}
|
||||||
b.Set("permissions", permsJSON)
|
b.Set("permissions", permsJSON)
|
||||||
}
|
}
|
||||||
if patch.ClearBudgetDaily {
|
|
||||||
b.SetNull("token_budget_daily")
|
|
||||||
} else if patch.TokenBudgetDaily != nil {
|
|
||||||
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
|
|
||||||
}
|
|
||||||
if patch.ClearBudgetMonthly {
|
|
||||||
b.SetNull("token_budget_monthly")
|
|
||||||
} else if patch.TokenBudgetMonthly != nil {
|
|
||||||
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
|
|
||||||
}
|
|
||||||
if patch.ClearAllowedModels {
|
|
||||||
b.SetNull("allowed_models")
|
|
||||||
} else if patch.AllowedModels != nil {
|
|
||||||
modelsJSON, err := json.Marshal(*patch.AllowedModels)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal allowed_models: %w", err)
|
|
||||||
}
|
|
||||||
b.Set("allowed_models", modelsJSON)
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
if !b.HasSets() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -138,10 +116,7 @@ const groupSelectCols = `
|
|||||||
g.created_at, g.updated_at,
|
g.created_at, g.updated_at,
|
||||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||||
COALESCE(g.source, 'manual'),
|
COALESCE(g.source, 'manual'),
|
||||||
COALESCE(g.permissions, '[]'::jsonb),
|
COALESCE(g.permissions, '[]'::jsonb)`
|
||||||
g.token_budget_daily,
|
|
||||||
g.token_budget_monthly,
|
|
||||||
g.allowed_models`
|
|
||||||
|
|
||||||
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||||
return queryGroups(ctx, `SELECT`+groupSelectCols+`
|
return queryGroups(ctx, `SELECT`+groupSelectCols+`
|
||||||
@@ -250,28 +225,17 @@ func scanGroup(row groupScanner) (*models.Group, error) {
|
|||||||
var teamID sql.NullString
|
var teamID sql.NullString
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
var permsJSON []byte
|
var permsJSON []byte
|
||||||
var budgetDaily, budgetMonthly sql.NullInt64
|
|
||||||
var allowedJSON []byte
|
|
||||||
|
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||||
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
|
&permsJSON,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g.TeamID = NullableStringPtr(teamID)
|
g.TeamID = NullableStringPtr(teamID)
|
||||||
g.CreatedBy = NullableStringPtr(createdBy)
|
g.CreatedBy = NullableStringPtr(createdBy)
|
||||||
g.Permissions = unmarshalStringSlice(permsJSON)
|
g.Permissions = unmarshalStringSlice(permsJSON)
|
||||||
if budgetDaily.Valid {
|
|
||||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
|
||||||
}
|
|
||||||
if budgetMonthly.Valid {
|
|
||||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
|
||||||
}
|
|
||||||
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
|
|
||||||
g.AllowedModels = unmarshalStringSlice(allowedJSON)
|
|
||||||
}
|
|
||||||
return &g, nil
|
return &g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,28 +252,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
|||||||
var teamID sql.NullString
|
var teamID sql.NullString
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
var permsJSON []byte
|
var permsJSON []byte
|
||||||
var budgetDaily, budgetMonthly sql.NullInt64
|
|
||||||
var allowedJSON []byte
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||||
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
|
&permsJSON,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g.TeamID = NullableStringPtr(teamID)
|
g.TeamID = NullableStringPtr(teamID)
|
||||||
g.CreatedBy = NullableStringPtr(createdBy)
|
g.CreatedBy = NullableStringPtr(createdBy)
|
||||||
g.Permissions = unmarshalStringSlice(permsJSON)
|
g.Permissions = unmarshalStringSlice(permsJSON)
|
||||||
if budgetDaily.Valid {
|
|
||||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
|
||||||
}
|
|
||||||
if budgetMonthly.Valid {
|
|
||||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
|
||||||
}
|
|
||||||
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
|
|
||||||
g.AllowedModels = unmarshalStringSlice(allowedJSON)
|
|
||||||
}
|
|
||||||
result = append(result, g)
|
result = append(result, g)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
|
|||||||
@@ -48,22 +48,17 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
|||||||
var teamID sql.NullString
|
var teamID sql.NullString
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
var permsStr sql.NullString
|
var permsStr sql.NullString
|
||||||
var budgetDaily, budgetMonthly sql.NullInt64
|
|
||||||
var allowedStr sql.NullString
|
|
||||||
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||||
g.created_at, g.updated_at,
|
g.created_at, g.updated_at,
|
||||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||||
COALESCE(g.source, 'manual'),
|
COALESCE(g.source, 'manual'),
|
||||||
COALESCE(g.permissions, '[]'),
|
COALESCE(g.permissions, '[]')
|
||||||
g.token_budget_daily,
|
|
||||||
g.token_budget_monthly,
|
|
||||||
g.allowed_models
|
|
||||||
FROM groups g WHERE g.id = ?`, id).Scan(
|
FROM groups g WHERE g.id = ?`, id).Scan(
|
||||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||||
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
|
&permsStr,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -71,20 +66,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
|||||||
g.TeamID = NullableStringPtr(teamID)
|
g.TeamID = NullableStringPtr(teamID)
|
||||||
g.CreatedBy = NullableStringPtr(createdBy)
|
g.CreatedBy = NullableStringPtr(createdBy)
|
||||||
g.Permissions = unmarshalStringSlice(permsStr.String)
|
g.Permissions = unmarshalStringSlice(permsStr.String)
|
||||||
if budgetDaily.Valid {
|
|
||||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
|
||||||
}
|
|
||||||
if budgetMonthly.Valid {
|
|
||||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
|
||||||
}
|
|
||||||
if allowedStr.Valid && allowedStr.String != "" {
|
|
||||||
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
|
|
||||||
}
|
|
||||||
return &g, nil
|
return &g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update applies a patch to a group.
|
// Update applies a patch to a group.
|
||||||
// system groups allow permission/budget edits but not structural field changes.
|
// System groups allow permission edits but not structural field changes.
|
||||||
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
|
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
|
||||||
var source string
|
var source string
|
||||||
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
|
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
|
||||||
@@ -107,25 +93,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa
|
|||||||
}
|
}
|
||||||
b.Set("permissions", string(permsJSON))
|
b.Set("permissions", string(permsJSON))
|
||||||
}
|
}
|
||||||
if patch.ClearBudgetDaily {
|
|
||||||
b.SetNull("token_budget_daily")
|
|
||||||
} else if patch.TokenBudgetDaily != nil {
|
|
||||||
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
|
|
||||||
}
|
|
||||||
if patch.ClearBudgetMonthly {
|
|
||||||
b.SetNull("token_budget_monthly")
|
|
||||||
} else if patch.TokenBudgetMonthly != nil {
|
|
||||||
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
|
|
||||||
}
|
|
||||||
if patch.ClearAllowedModels {
|
|
||||||
b.SetNull("allowed_models")
|
|
||||||
} else if patch.AllowedModels != nil {
|
|
||||||
modelsJSON, err := json.Marshal(*patch.AllowedModels)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal allowed_models: %w", err)
|
|
||||||
}
|
|
||||||
b.Set("allowed_models", string(modelsJSON))
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
if !b.HasSets() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -169,10 +136,7 @@ const groupSelectCols = `
|
|||||||
g.created_at, g.updated_at,
|
g.created_at, g.updated_at,
|
||||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||||
COALESCE(g.source, 'manual'),
|
COALESCE(g.source, 'manual'),
|
||||||
COALESCE(g.permissions, '[]'),
|
COALESCE(g.permissions, '[]')`
|
||||||
g.token_budget_daily,
|
|
||||||
g.token_budget_monthly,
|
|
||||||
g.allowed_models`
|
|
||||||
|
|
||||||
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||||
return queryGroups(ctx, `SELECT `+groupSelectCols+`
|
return queryGroups(ctx, `SELECT `+groupSelectCols+`
|
||||||
@@ -285,28 +249,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
|||||||
var teamID sql.NullString
|
var teamID sql.NullString
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
var permsStr sql.NullString
|
var permsStr sql.NullString
|
||||||
var budgetDaily, budgetMonthly sql.NullInt64
|
|
||||||
var allowedStr sql.NullString
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||||
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
|
&permsStr,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g.TeamID = NullableStringPtr(teamID)
|
g.TeamID = NullableStringPtr(teamID)
|
||||||
g.CreatedBy = NullableStringPtr(createdBy)
|
g.CreatedBy = NullableStringPtr(createdBy)
|
||||||
g.Permissions = unmarshalStringSlice(permsStr.String)
|
g.Permissions = unmarshalStringSlice(permsStr.String)
|
||||||
if budgetDaily.Valid {
|
|
||||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
|
||||||
}
|
|
||||||
if budgetMonthly.Valid {
|
|
||||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
|
||||||
}
|
|
||||||
if allowedStr.Valid && allowedStr.String != "" {
|
|
||||||
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
|
|
||||||
}
|
|
||||||
result = append(result, g)
|
result = append(result, g)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export default function GroupsSection() {
|
|||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [allPerms, setAllPerms] = useState([]);
|
const [allPerms, setAllPerms] = useState([]);
|
||||||
const [allUsers, setAllUsers] = useState([]);
|
const [allUsers, setAllUsers] = useState([]);
|
||||||
const [allModels, setAllModels] = useState([]);
|
|
||||||
const [groupData, setGroupData] = useState({});
|
const [groupData, setGroupData] = useState({});
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showAddMember, setShowAddMember] = useState(false);
|
const [showAddMember, setShowAddMember] = useState(false);
|
||||||
@@ -30,21 +29,16 @@ export default function GroupsSection() {
|
|||||||
setDetail(group);
|
setDetail(group);
|
||||||
setGroupData({
|
setGroupData({
|
||||||
permissions: group.permissions || [],
|
permissions: group.permissions || [],
|
||||||
token_budget_daily: group.token_budget_daily ?? '',
|
|
||||||
token_budget_monthly: group.token_budget_monthly ?? '',
|
|
||||||
allowed_models: group.allowed_models || [],
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const [m, p, u, models] = await Promise.all([
|
const [m, p, u] = await Promise.all([
|
||||||
sw.api.admin.groups.members(group.id),
|
sw.api.admin.groups.members(group.id),
|
||||||
sw.api.admin.permissions.list(),
|
sw.api.admin.permissions.list(),
|
||||||
sw.api.admin.users.list(),
|
sw.api.admin.users.list(),
|
||||||
sw.api.admin.models.list().catch(() => []),
|
|
||||||
]);
|
]);
|
||||||
setMembers(m || []);
|
setMembers(m || []);
|
||||||
setAllPerms(p.permissions || []);
|
setAllPerms(p.permissions || []);
|
||||||
setAllUsers(u || []);
|
setAllUsers(u || []);
|
||||||
setAllModels(models || []);
|
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +57,10 @@ export default function GroupsSection() {
|
|||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePermsAndBudgets() {
|
async function savePermissions() {
|
||||||
try {
|
try {
|
||||||
await sw.api.admin.groups.update(detail.id, {
|
await sw.api.admin.groups.update(detail.id, {
|
||||||
permissions: groupData.permissions,
|
permissions: groupData.permissions,
|
||||||
token_budget_daily: groupData.token_budget_daily || null,
|
|
||||||
token_budget_monthly: groupData.token_budget_monthly || null,
|
|
||||||
allowed_models: groupData.allowed_models.length ? groupData.allowed_models : null,
|
|
||||||
});
|
});
|
||||||
sw.toast('Saved', 'success');
|
sw.toast('Saved', 'success');
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
@@ -84,15 +75,6 @@ export default function GroupsSection() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleModel(modelId) {
|
|
||||||
setGroupData(prev => {
|
|
||||||
const list = prev.allowed_models.includes(modelId)
|
|
||||||
? prev.allowed_models.filter(m => m !== modelId)
|
|
||||||
: [...prev.allowed_models, modelId];
|
|
||||||
return { ...prev, allowed_models: list };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addMember(e) {
|
async function addMember(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const uid = e.target.userId.value;
|
const uid = e.target.userId.value;
|
||||||
@@ -151,37 +133,8 @@ export default function GroupsSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section" style="margin-bottom:16px;">
|
|
||||||
<h5 style="margin:0 0 8px;">Token Budgets</h5>
|
|
||||||
<div class="form-row" style="gap:12px;">
|
|
||||||
<div class="form-group" style="flex:1;"><label>Daily limit</label>
|
|
||||||
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_daily}
|
|
||||||
onInput=${e => setGroupData(p => ({ ...p, token_budget_daily: e.target.value ? Number(e.target.value) : '' }))} />
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="flex:1;"><label>Monthly limit</label>
|
|
||||||
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_monthly}
|
|
||||||
onInput=${e => setGroupData(p => ({ ...p, token_budget_monthly: e.target.value ? Number(e.target.value) : '' }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section" style="margin-bottom:16px;">
|
|
||||||
<h5 style="margin:0 0 8px;">Allowed Models</h5>
|
|
||||||
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">Leave empty for unrestricted.</p>
|
|
||||||
<div class="admin-models-grid">
|
|
||||||
${allModels.map(m => html`
|
|
||||||
<label class="toggle-label" key=${m.id || m.model_id}>
|
|
||||||
<input type="checkbox" checked=${groupData.allowed_models.includes(m.model_id || m.id)}
|
|
||||||
onChange=${() => toggleModel(m.model_id || m.id)} />
|
|
||||||
<span class="toggle-track"></span>
|
|
||||||
<span>${m.display_name || m.model_id || m.id}</span>
|
|
||||||
</label>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom:16px;">
|
<div style="margin-bottom:16px;">
|
||||||
<button class="btn-small btn-primary" onClick=${savePermsAndBudgets}>Save Permissions & Budgets</button>
|
<button class="btn-small btn-primary" onClick=${savePermissions}>Save Permissions</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" />
|
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" />
|
||||||
|
|||||||
Reference in New Issue
Block a user