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

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:
2026-03-26 16:53:18 +00:00
parent 316c9cbfa7
commit e8e45184f7
9 changed files with 38 additions and 283 deletions

View File

@@ -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
--- ---

View File

@@ -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
}

View File

@@ -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 (

View File

@@ -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'))
); );

View File

@@ -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

View File

@@ -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.

View File

@@ -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()

View File

@@ -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()

View File

@@ -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;" />