mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 21:25:59 -04:00
feat: add quota system (#9090)
* feat: add quota system Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Fix tests Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
f38e91d80b
commit
4b183b7bb6
@@ -221,6 +221,7 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||
if application.AuthDB() != nil {
|
||||
e.Use(auth.RequireRouteFeature(application.AuthDB()))
|
||||
e.Use(auth.RequireModelAccess(application.AuthDB()))
|
||||
e.Use(auth.RequireQuota(application.AuthDB()))
|
||||
}
|
||||
|
||||
// CORS middleware
|
||||
|
||||
@@ -33,10 +33,14 @@ func InitDB(databaseURL string) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("failed to open auth database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Session{}, &UserAPIKey{}, &UsageRecord{}, &UserPermission{}, &InviteCode{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Session{}, &UserAPIKey{}, &UsageRecord{}, &UserPermission{}, &InviteCode{}, &QuotaRule{}); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate auth tables: %w", err)
|
||||
}
|
||||
|
||||
// Backfill: users created before the provider column existed have an empty
|
||||
// provider — treat them as local accounts so the UI can identify them.
|
||||
db.Exec("UPDATE users SET provider = ? WHERE provider = '' OR provider IS NULL", ProviderLocal)
|
||||
|
||||
// Create composite index on users(provider, subject) for fast OAuth lookups
|
||||
if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_users_provider_subject ON users(provider, subject)").Error; err != nil {
|
||||
// Ignore error on postgres if index already exists
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -370,6 +371,55 @@ func extractModelFromRequest(c echo.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireQuota returns a global middleware that enforces per-user quota rules.
|
||||
// If no auth DB is provided, it's a no-op. Admin users always bypass quotas.
|
||||
// Only inference routes (those listed in RouteFeatureRegistry) count toward quota.
|
||||
func RequireQuota(db *gorm.DB) echo.MiddlewareFunc {
|
||||
if db == nil {
|
||||
return NoopMiddleware()
|
||||
}
|
||||
// Pre-build lookup set from RouteFeatureRegistry — only these routes
|
||||
// should count toward quota. Mirrors RequireRouteFeature's approach.
|
||||
inferenceRoutes := map[string]bool{}
|
||||
for _, rf := range RouteFeatureRegistry {
|
||||
inferenceRoutes[rf.Method+":"+rf.Pattern] = true
|
||||
}
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Only enforce quotas on inference routes
|
||||
path := c.Path()
|
||||
method := c.Request().Method
|
||||
if !inferenceRoutes[method+":"+path] && !inferenceRoutes["*:"+path] {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
user := GetUser(c)
|
||||
if user == nil {
|
||||
return next(c)
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
model := extractModelFromRequest(c)
|
||||
|
||||
exceeded, retryAfter, msg := QuotaExceeded(db, user.ID, model)
|
||||
if exceeded {
|
||||
c.Response().Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
|
||||
return c.JSON(http.StatusTooManyRequests, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: msg,
|
||||
Code: http.StatusTooManyRequests,
|
||||
Type: "quota_exceeded",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryAuthenticate attempts to authenticate the request using the database.
|
||||
func tryAuthenticate(c echo.Context, db *gorm.DB, appConfig *config.ApplicationConfig) *User {
|
||||
hmacSecret := appConfig.Auth.APIKeyHMACSecret
|
||||
|
||||
366
core/http/auth/quota.go
Normal file
366
core/http/auth/quota.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// QuotaRule defines a rate/token limit for a user, optionally scoped to a model.
|
||||
type QuotaRule struct {
|
||||
ID string `gorm:"primaryKey;size:36"`
|
||||
UserID string `gorm:"size:36;uniqueIndex:idx_quota_user_model"`
|
||||
Model string `gorm:"size:255;uniqueIndex:idx_quota_user_model"` // "" = all models
|
||||
MaxRequests *int64 // nil = no request limit
|
||||
MaxTotalTokens *int64 // nil = no token limit
|
||||
WindowSeconds int64 // e.g., 3600 = 1h
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
// QuotaStatus is returned to clients with current usage included.
|
||||
type QuotaStatus struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
MaxRequests *int64 `json:"max_requests"`
|
||||
MaxTotalTokens *int64 `json:"max_total_tokens"`
|
||||
Window string `json:"window"`
|
||||
CurrentRequests int64 `json:"current_requests"`
|
||||
CurrentTokens int64 `json:"current_total_tokens"`
|
||||
ResetsAt string `json:"resets_at,omitempty"`
|
||||
}
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
// CreateOrUpdateQuotaRule upserts a quota rule for the given user+model.
|
||||
func CreateOrUpdateQuotaRule(db *gorm.DB, userID, model string, maxReqs, maxTokens *int64, windowSecs int64) (*QuotaRule, error) {
|
||||
var existing QuotaRule
|
||||
err := db.Where("user_id = ? AND model = ?", userID, model).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
rule := QuotaRule{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Model: model,
|
||||
MaxRequests: maxReqs,
|
||||
MaxTotalTokens: maxTokens,
|
||||
WindowSeconds: windowSecs,
|
||||
}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quotaCache.invalidateUser(userID)
|
||||
return &rule, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.MaxRequests = maxReqs
|
||||
existing.MaxTotalTokens = maxTokens
|
||||
existing.WindowSeconds = windowSecs
|
||||
if err := db.Save(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quotaCache.invalidateUser(userID)
|
||||
return &existing, nil
|
||||
}
|
||||
|
||||
// ListQuotaRules returns all quota rules for a user.
|
||||
func ListQuotaRules(db *gorm.DB, userID string) ([]QuotaRule, error) {
|
||||
var rules []QuotaRule
|
||||
if err := db.Where("user_id = ?", userID).Order("model ASC").Find(&rules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// DeleteQuotaRule removes a quota rule by ID (scoped to user for safety).
|
||||
func DeleteQuotaRule(db *gorm.DB, ruleID, userID string) error {
|
||||
result := db.Where("id = ? AND user_id = ?", ruleID, userID).Delete(&QuotaRule{})
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("quota rule not found")
|
||||
}
|
||||
quotaCache.invalidateUser(userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Usage queries ──
|
||||
|
||||
type usageCounts struct {
|
||||
RequestCount int64
|
||||
TotalTokens int64
|
||||
}
|
||||
|
||||
// getUsageSince counts requests and tokens for a user since the given time.
|
||||
func getUsageSince(db *gorm.DB, userID string, since time.Time, model string) (usageCounts, error) {
|
||||
var result usageCounts
|
||||
q := db.Model(&UsageRecord{}).
|
||||
Select("COUNT(*) as request_count, COALESCE(SUM(total_tokens), 0) as total_tokens").
|
||||
Where("user_id = ? AND created_at >= ?", userID, since)
|
||||
if model != "" {
|
||||
q = q.Where("model = ?", model)
|
||||
}
|
||||
if err := q.Row().Scan(&result.RequestCount, &result.TotalTokens); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetQuotaStatuses returns all quota rules for a user with current usage.
|
||||
func GetQuotaStatuses(db *gorm.DB, userID string) ([]QuotaStatus, error) {
|
||||
rules, err := ListQuotaRules(db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statuses := make([]QuotaStatus, 0, len(rules))
|
||||
now := time.Now()
|
||||
for _, r := range rules {
|
||||
windowStart := now.Add(-time.Duration(r.WindowSeconds) * time.Second)
|
||||
counts, err := getUsageSince(db, userID, windowStart, r.Model)
|
||||
if err != nil {
|
||||
counts = usageCounts{}
|
||||
}
|
||||
statuses = append(statuses, QuotaStatus{
|
||||
ID: r.ID,
|
||||
Model: r.Model,
|
||||
MaxRequests: r.MaxRequests,
|
||||
MaxTotalTokens: r.MaxTotalTokens,
|
||||
Window: formatWindowDuration(r.WindowSeconds),
|
||||
CurrentRequests: counts.RequestCount,
|
||||
CurrentTokens: counts.TotalTokens,
|
||||
ResetsAt: windowStart.Add(time.Duration(r.WindowSeconds) * time.Second).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return statuses, nil
|
||||
}
|
||||
|
||||
// ── Quota check (used by middleware) ──
|
||||
|
||||
// QuotaExceeded checks whether the user has exceeded any applicable quota rule.
|
||||
// Returns (exceeded bool, retryAfterSeconds int64, message string).
|
||||
func QuotaExceeded(db *gorm.DB, userID, model string) (bool, int64, string) {
|
||||
rules := quotaCache.getRules(db, userID)
|
||||
if len(rules) == 0 {
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, r := range rules {
|
||||
// Check if rule applies: model-specific rules match that model, global (empty) applies to all.
|
||||
if r.Model != "" && r.Model != model {
|
||||
continue
|
||||
}
|
||||
|
||||
windowStart := now.Add(-time.Duration(r.WindowSeconds) * time.Second)
|
||||
retryAfter := r.WindowSeconds // worst case: full window
|
||||
|
||||
// Try cache first
|
||||
counts, ok := quotaCache.getUsage(userID, r.Model, windowStart)
|
||||
if !ok {
|
||||
var err error
|
||||
counts, err = getUsageSince(db, userID, windowStart, r.Model)
|
||||
if err != nil {
|
||||
continue // on error, don't block the request
|
||||
}
|
||||
quotaCache.setUsage(userID, r.Model, windowStart, counts)
|
||||
}
|
||||
|
||||
if r.MaxRequests != nil && counts.RequestCount >= *r.MaxRequests {
|
||||
scope := "all models"
|
||||
if r.Model != "" {
|
||||
scope = "model " + r.Model
|
||||
}
|
||||
return true, retryAfter, fmt.Sprintf(
|
||||
"Request quota exceeded for %s: %d/%d requests in %s window",
|
||||
scope, counts.RequestCount, *r.MaxRequests, formatWindowDuration(r.WindowSeconds),
|
||||
)
|
||||
}
|
||||
if r.MaxTotalTokens != nil && counts.TotalTokens >= *r.MaxTotalTokens {
|
||||
scope := "all models"
|
||||
if r.Model != "" {
|
||||
scope = "model " + r.Model
|
||||
}
|
||||
return true, retryAfter, fmt.Sprintf(
|
||||
"Token quota exceeded for %s: %d/%d tokens in %s window",
|
||||
scope, counts.TotalTokens, *r.MaxTotalTokens, formatWindowDuration(r.WindowSeconds),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic increment: bump cached counters so subsequent requests in the
|
||||
// same cache window see an updated count without re-querying the DB.
|
||||
for _, r := range rules {
|
||||
if r.Model != "" && r.Model != model {
|
||||
continue
|
||||
}
|
||||
windowStart := now.Add(-time.Duration(r.WindowSeconds) * time.Second)
|
||||
quotaCache.incrementUsage(userID, r.Model, windowStart)
|
||||
}
|
||||
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
// ── In-memory cache ──
|
||||
|
||||
var quotaCache = newQuotaCacheStore()
|
||||
|
||||
type quotaCacheStore struct {
|
||||
mu sync.RWMutex
|
||||
rules map[string]cachedRules // userID -> rules
|
||||
usage map[string]cachedUsage // "userID|model|windowStart" -> counts
|
||||
}
|
||||
|
||||
type cachedRules struct {
|
||||
rules []QuotaRule
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
type cachedUsage struct {
|
||||
counts usageCounts
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
func newQuotaCacheStore() *quotaCacheStore {
|
||||
c := "aCacheStore{
|
||||
rules: make(map[string]cachedRules),
|
||||
usage: make(map[string]cachedUsage),
|
||||
}
|
||||
go c.cleanupLoop()
|
||||
return c
|
||||
}
|
||||
|
||||
const (
|
||||
rulesCacheTTL = 30 * time.Second
|
||||
usageCacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
func (c *quotaCacheStore) getRules(db *gorm.DB, userID string) []QuotaRule {
|
||||
c.mu.RLock()
|
||||
cached, ok := c.rules[userID]
|
||||
c.mu.RUnlock()
|
||||
if ok && time.Since(cached.fetchedAt) < rulesCacheTTL {
|
||||
return cached.rules
|
||||
}
|
||||
|
||||
rules, err := ListQuotaRules(db, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.rules[userID] = cachedRules{rules: rules, fetchedAt: time.Now()}
|
||||
c.mu.Unlock()
|
||||
return rules
|
||||
}
|
||||
|
||||
func (c *quotaCacheStore) invalidateUser(userID string) {
|
||||
c.mu.Lock()
|
||||
delete(c.rules, userID)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func usageKey(userID, model string, windowStart time.Time) string {
|
||||
return userID + "|" + model + "|" + windowStart.Truncate(time.Second).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (c *quotaCacheStore) getUsage(userID, model string, windowStart time.Time) (usageCounts, bool) {
|
||||
key := usageKey(userID, model, windowStart)
|
||||
c.mu.RLock()
|
||||
cached, ok := c.usage[key]
|
||||
c.mu.RUnlock()
|
||||
if ok && time.Since(cached.fetchedAt) < usageCacheTTL {
|
||||
return cached.counts, true
|
||||
}
|
||||
return usageCounts{}, false
|
||||
}
|
||||
|
||||
func (c *quotaCacheStore) setUsage(userID, model string, windowStart time.Time, counts usageCounts) {
|
||||
key := usageKey(userID, model, windowStart)
|
||||
c.mu.Lock()
|
||||
c.usage[key] = cachedUsage{counts: counts, fetchedAt: time.Now()}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *quotaCacheStore) incrementUsage(userID, model string, windowStart time.Time) {
|
||||
key := usageKey(userID, model, windowStart)
|
||||
c.mu.Lock()
|
||||
if cached, ok := c.usage[key]; ok {
|
||||
cached.counts.RequestCount++
|
||||
c.usage[key] = cached
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *quotaCacheStore) cleanupLoop() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range c.rules {
|
||||
if now.Sub(v.fetchedAt) > rulesCacheTTL*2 {
|
||||
delete(c.rules, k)
|
||||
}
|
||||
}
|
||||
for k, v := range c.usage {
|
||||
if now.Sub(v.fetchedAt) > usageCacheTTL*2 {
|
||||
delete(c.usage, k)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
// ParseWindowDuration converts a human-friendly window string to seconds.
|
||||
func ParseWindowDuration(s string) (int64, error) {
|
||||
switch s {
|
||||
case "1m":
|
||||
return 60, nil
|
||||
case "5m":
|
||||
return 300, nil
|
||||
case "1h":
|
||||
return 3600, nil
|
||||
case "6h":
|
||||
return 21600, nil
|
||||
case "1d":
|
||||
return 86400, nil
|
||||
case "7d":
|
||||
return 604800, nil
|
||||
case "30d":
|
||||
return 2592000, nil
|
||||
}
|
||||
// Try Go duration parsing as fallback
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid window duration: %s", s)
|
||||
}
|
||||
return int64(d.Seconds()), nil
|
||||
}
|
||||
|
||||
// formatWindowDuration converts seconds to a human-friendly string.
|
||||
func formatWindowDuration(secs int64) string {
|
||||
switch secs {
|
||||
case 60:
|
||||
return "1m"
|
||||
case 300:
|
||||
return "5m"
|
||||
case 3600:
|
||||
return "1h"
|
||||
case 21600:
|
||||
return "6h"
|
||||
case 86400:
|
||||
return "1d"
|
||||
case 604800:
|
||||
return "7d"
|
||||
case 2592000:
|
||||
return "30d"
|
||||
default:
|
||||
d := time.Duration(secs) * time.Second
|
||||
return d.String()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ test.describe('Navigation', () => {
|
||||
|
||||
test('sidebar traces link navigates to /app/traces', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
// Expand the "System" collapsible section so the traces link is visible
|
||||
const systemSection = page.locator('button.sidebar-section-toggle', { hasText: 'System' })
|
||||
await systemSection.click()
|
||||
const tracesLink = page.locator('a.nav-item[href="/app/traces"]')
|
||||
await expect(tracesLink).toBeVisible()
|
||||
await tracesLink.click()
|
||||
|
||||
@@ -52,6 +52,7 @@ function PermissionSummary({ user, onClick }) {
|
||||
const generalOn = generalFeatures.filter(f => perms[f]).length
|
||||
|
||||
const modelRestricted = user.allowed_models?.enabled
|
||||
const quotaCount = (user.quotas || []).length
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -62,13 +63,26 @@ function PermissionSummary({ user, onClick }) {
|
||||
<i className="fas fa-shield-halved" />
|
||||
{apiOn}/{apiFeatures.length} API, {agentOn}/{agentFeatures.length} Agent, {generalOn}/{generalFeatures.length} Features
|
||||
{modelRestricted && ' | Models restricted'}
|
||||
{quotaCount > 0 && ` · ${quotaCount} quota${quotaCount !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const WINDOW_OPTIONS = [
|
||||
{ value: '1m', label: '1 minute' },
|
||||
{ value: '5m', label: '5 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '30d', label: '30 days' },
|
||||
]
|
||||
|
||||
function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave, addToast }) {
|
||||
const [permissions, setPermissions] = useState({ ...(user.permissions || {}) })
|
||||
const [allowedModels, setAllowedModels] = useState(user.allowed_models || { enabled: false, models: [] })
|
||||
const [quotas, setQuotas] = useState((user.quotas || []).map(q => ({ ...q, _dirty: false })))
|
||||
const [deletedQuotaIds, setDeletedQuotaIds] = useState([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const apiFeatures = featureMeta?.api_features || []
|
||||
@@ -114,12 +128,57 @@ function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave,
|
||||
}
|
||||
}
|
||||
|
||||
const addQuota = () => {
|
||||
setQuotas(prev => [...prev, {
|
||||
id: null, model: '', max_requests: null, max_total_tokens: null, window: '1h',
|
||||
current_requests: 0, current_total_tokens: 0, _dirty: true, _new: true,
|
||||
}])
|
||||
}
|
||||
|
||||
const updateQuota = (idx, field, value) => {
|
||||
setQuotas(prev => prev.map((q, i) => i === idx ? { ...q, [field]: value, _dirty: true } : q))
|
||||
}
|
||||
|
||||
const removeQuota = (idx) => {
|
||||
const q = quotas[idx]
|
||||
if (q.id && !q._new) {
|
||||
setDeletedQuotaIds(prev => [...prev, q.id])
|
||||
}
|
||||
setQuotas(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminUsersApi.setPermissions(user.id, permissions)
|
||||
await adminUsersApi.setModels(user.id, allowedModels)
|
||||
onSave(user.id, permissions, allowedModels)
|
||||
|
||||
// Delete removed quotas
|
||||
for (const qid of deletedQuotaIds) {
|
||||
await adminUsersApi.deleteQuota(user.id, qid)
|
||||
}
|
||||
// Upsert dirty quotas
|
||||
for (const q of quotas) {
|
||||
if (q._dirty || q._new) {
|
||||
await adminUsersApi.setQuota(user.id, {
|
||||
model: q.model,
|
||||
max_requests: q.max_requests || null,
|
||||
max_total_tokens: q.max_total_tokens || null,
|
||||
window: q.window,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch quotas so caller gets fresh state (including server-assigned IDs and current usage)
|
||||
let freshQuotas = []
|
||||
try {
|
||||
const qData = await adminUsersApi.getQuotas(user.id)
|
||||
freshQuotas = Array.isArray(qData) ? qData : qData.quotas || []
|
||||
} catch {
|
||||
// Fall back to local state if refetch fails
|
||||
freshQuotas = quotas.map(q => ({ ...q, _dirty: false, _new: false }))
|
||||
}
|
||||
onSave(user.id, permissions, allowedModels, freshQuotas)
|
||||
addToast(`Permissions updated for ${user.name || user.email}`, 'success')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
@@ -276,6 +335,111 @@ function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quotas */}
|
||||
<div className="perm-section">
|
||||
<div className="perm-section-header">
|
||||
<strong className="perm-section-title">
|
||||
<i className="fas fa-gauge-high" />
|
||||
Quotas
|
||||
</strong>
|
||||
<button className="btn btn-sm btn-primary" onClick={addQuota}>
|
||||
<i className="fas fa-plus" /> Add rule
|
||||
</button>
|
||||
</div>
|
||||
{quotas.length === 0 ? (
|
||||
<div className="quota-empty">
|
||||
<i className="fas fa-infinity quota-empty-icon" />
|
||||
<span>No quota rules — unlimited access</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="quota-rules-list">
|
||||
{quotas.map((q, idx) => {
|
||||
const reqPct = (q.max_requests && !q._new) ? Math.min(100, Math.round(((q.current_requests ?? 0) / q.max_requests) * 100)) : null
|
||||
const tokPct = (q.max_total_tokens && !q._new) ? Math.min(100, Math.round(((q.current_total_tokens ?? 0) / q.max_total_tokens) * 100)) : null
|
||||
return (
|
||||
<div key={q.id || `new-${idx}`} className="quota-card">
|
||||
<div className="quota-card-header">
|
||||
<select
|
||||
className="quota-select quota-select--model"
|
||||
value={q.model}
|
||||
onChange={e => updateQuota(idx, 'model', e.target.value)}
|
||||
>
|
||||
<option value="">All models</option>
|
||||
{(availableModels || []).map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="quota-select"
|
||||
value={q.window}
|
||||
onChange={e => updateQuota(idx, 'window', e.target.value)}
|
||||
>
|
||||
{WINDOW_OPTIONS.map(w => (
|
||||
<option key={w.value} value={w.value}>per {w.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-sm btn-danger quota-remove-btn"
|
||||
onClick={() => removeQuota(idx)}
|
||||
title="Remove rule"
|
||||
aria-label="Remove quota rule"
|
||||
>
|
||||
<i className="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="quota-card-fields">
|
||||
<div className="quota-field">
|
||||
<label className="quota-field-label">Max requests</label>
|
||||
<input
|
||||
type="number"
|
||||
className="quota-input"
|
||||
placeholder="Unlimited"
|
||||
value={q.max_requests ?? ''}
|
||||
onChange={e => updateQuota(idx, 'max_requests', e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||
min="0"
|
||||
/>
|
||||
{reqPct !== null && (
|
||||
<div className="quota-usage">
|
||||
<div className="quota-progress">
|
||||
<div
|
||||
className={`quota-progress-fill${reqPct >= 90 ? ' quota-progress-fill--danger' : reqPct >= 70 ? ' quota-progress-fill--warning' : ''}`}
|
||||
style={{ width: `${reqPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="quota-usage-label">{q.current_requests ?? 0} / {q.max_requests}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="quota-field">
|
||||
<label className="quota-field-label">Max tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
className="quota-input"
|
||||
placeholder="Unlimited"
|
||||
value={q.max_total_tokens ?? ''}
|
||||
onChange={e => updateQuota(idx, 'max_total_tokens', e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||
min="0"
|
||||
/>
|
||||
{tokPct !== null && (
|
||||
<div className="quota-usage">
|
||||
<div className="quota-progress">
|
||||
<div
|
||||
className={`quota-progress-fill${tokPct >= 90 ? ' quota-progress-fill--danger' : tokPct >= 70 ? ' quota-progress-fill--warning' : ''}`}
|
||||
style={{ width: `${tokPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="quota-usage-label">{(q.current_total_tokens ?? 0).toLocaleString()} / {q.max_total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="perm-modal-actions">
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
@@ -502,6 +666,9 @@ export default function Users() {
|
||||
const [featureMeta, setFeatureMeta] = useState(null)
|
||||
const [availableModels, setAvailableModels] = useState([])
|
||||
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||
const [passwordResetUser, setPasswordResetUser] = useState(null)
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [resettingPassword, setResettingPassword] = useState(false)
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -594,14 +761,34 @@ export default function Users() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetPassword = (u) => {
|
||||
setPasswordResetUser(u)
|
||||
setNewPassword('')
|
||||
}
|
||||
|
||||
const confirmResetPassword = async () => {
|
||||
if (!passwordResetUser || newPassword.length < 8) return
|
||||
setResettingPassword(true)
|
||||
try {
|
||||
await adminUsersApi.resetPassword(passwordResetUser.id, newPassword)
|
||||
addToast(`Password reset for ${passwordResetUser.name || passwordResetUser.email}`, 'success')
|
||||
setPasswordResetUser(null)
|
||||
setNewPassword('')
|
||||
} catch (err) {
|
||||
addToast(`Failed to reset password: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setResettingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = users.filter(u => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
const handlePermissionSave = (userId, newPerms, newModels) => {
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: newPerms, allowed_models: newModels } : u))
|
||||
const handlePermissionSave = (userId, newPerms, newModels, newQuotas) => {
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: newPerms, allowed_models: newModels, quotas: newQuotas } : u))
|
||||
}
|
||||
|
||||
const isSelf = (u) => currentUser && (u.id === currentUser.id || u.email === currentUser.email)
|
||||
@@ -727,6 +914,15 @@ export default function Users() {
|
||||
>
|
||||
<i className={`fas fa-${u.role === 'admin' ? 'arrow-down' : 'arrow-up'}`} />
|
||||
</button>
|
||||
{(!u.provider || u.provider === 'local') && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleResetPassword(u)}
|
||||
title="Reset password"
|
||||
>
|
||||
<i className="fas fa-key" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(u)}
|
||||
@@ -756,6 +952,36 @@ export default function Users() {
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{passwordResetUser && (
|
||||
<Modal onClose={() => setPasswordResetUser(null)} maxWidth="400px">
|
||||
<div className="perm-modal-body">
|
||||
<h3>Reset Password</h3>
|
||||
<p style={{ margin: 'var(--spacing-sm) 0' }}>
|
||||
Set a new password for <strong>{passwordResetUser.name || passwordResetUser.email}</strong>.
|
||||
All existing sessions will be invalidated.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
placeholder="New password (min 8 characters)"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newPassword.length >= 8) confirmResetPassword() }}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="perm-modal-actions" style={{ marginTop: 'var(--spacing-md)' }}>
|
||||
<button className="btn btn-secondary" onClick={() => setPasswordResetUser(null)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={confirmResetPassword}
|
||||
disabled={resettingPassword || newPassword.length < 8}
|
||||
>
|
||||
{resettingPassword ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDialog}
|
||||
title={confirmDialog?.title}
|
||||
|
||||
@@ -254,11 +254,16 @@
|
||||
}
|
||||
|
||||
.perm-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.perm-section + .perm-section {
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.perm-section-header {
|
||||
@@ -269,12 +274,16 @@
|
||||
}
|
||||
|
||||
.perm-section-title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.perm-section-title i {
|
||||
margin-right: var(--spacing-xs);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.perm-grid {
|
||||
@@ -284,7 +293,7 @@
|
||||
}
|
||||
|
||||
.perm-btn-all-none {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
@@ -319,7 +328,13 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
margin: 0 calc(-1 * var(--spacing-lg));
|
||||
margin-bottom: calc(-1 * var(--spacing-lg));
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
background: var(--color-bg-secondary);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* ─── Invite link cell ─── */
|
||||
@@ -557,3 +572,182 @@
|
||||
overflow: auto;
|
||||
animation: slideUp 150ms ease;
|
||||
}
|
||||
|
||||
/* ─── Model list in permissions modal ─── */
|
||||
.model-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.model-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
.model-item input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.model-item-checked {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light, rgba(59,130,246,0.08));
|
||||
}
|
||||
|
||||
.model-item-check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.model-item-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ─── Quota section ─── */
|
||||
.quota-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.quota-empty-icon {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.quota-rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.quota-card {
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.quota-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.quota-card-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.quota-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quota-field-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.quota-input {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quota-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.quota-select {
|
||||
padding: 5px 8px;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.quota-select--model {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quota-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.quota-remove-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Quota usage progress ─── */
|
||||
.quota-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.quota-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--color-border-subtle);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quota-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 300ms ease-out;
|
||||
}
|
||||
|
||||
.quota-progress-fill--warning {
|
||||
background: var(--color-warning, #eab308);
|
||||
}
|
||||
|
||||
.quota-progress-fill--danger {
|
||||
background: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.quota-usage-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
10
core/http/react-ui/src/utils/api.js
vendored
10
core/http/react-ui/src/utils/api.js
vendored
@@ -350,6 +350,16 @@ export const adminUsersApi = {
|
||||
setModels: (id, allowlist) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/models`, {
|
||||
method: 'PUT', body: JSON.stringify(allowlist), headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
getQuotas: (id) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/quotas`),
|
||||
setQuota: (id, quota) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/quotas`, {
|
||||
method: 'PUT', body: JSON.stringify(quota), headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
deleteQuota: (id, quotaId) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/quotas/${encodeURIComponent(quotaId)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
resetPassword: (id, password) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/password`, {
|
||||
method: 'PUT', body: JSON.stringify({ password }), headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
}
|
||||
|
||||
// Profile API
|
||||
|
||||
@@ -496,7 +496,7 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
resp := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
@@ -504,7 +504,24 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
||||
"role": user.Role,
|
||||
"provider": user.Provider,
|
||||
"permissions": auth.GetPermissionMapForUser(db, user),
|
||||
})
|
||||
}
|
||||
if quotas, err := auth.GetQuotaStatuses(db, user.ID); err == nil {
|
||||
resp["quotas"] = quotas
|
||||
}
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// GET /api/auth/quota - view own quota status
|
||||
e.GET("/api/auth/quota", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
quotas, err := auth.GetQuotaStatuses(db, user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quota status"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"quotas": quotas})
|
||||
})
|
||||
|
||||
// PUT /api/auth/profile - update user profile (name, avatar_url)
|
||||
@@ -805,6 +822,9 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
||||
}
|
||||
entry["permissions"] = auth.GetPermissionMapForUser(db, &u)
|
||||
entry["allowed_models"] = auth.GetModelAllowlist(db, u.ID)
|
||||
if quotas, err := auth.GetQuotaStatuses(db, u.ID); err == nil && len(quotas) > 0 {
|
||||
entry["quotas"] = quotas
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
@@ -859,6 +879,49 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "status updated"})
|
||||
}, adminMw)
|
||||
|
||||
// PUT /api/auth/admin/users/:id/password - admin reset user password
|
||||
e.PUT("/api/auth/admin/users/:id/password", func(c echo.Context) error {
|
||||
currentUser := auth.GetUser(c)
|
||||
targetID := c.Param("id")
|
||||
|
||||
if currentUser.ID == targetID {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot reset your own password via this endpoint, use self-service password change"})
|
||||
}
|
||||
|
||||
var target auth.User
|
||||
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
|
||||
if target.Provider != auth.ProviderLocal {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password reset is only available for local accounts"})
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
|
||||
if len(body.Password) < 8 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"})
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
if err := db.Model(&auth.User{}).Where("id = ?", targetID).Update("password_hash", hash).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update password"})
|
||||
}
|
||||
|
||||
auth.DeleteUserSessions(db, targetID)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "password reset successfully"})
|
||||
}, adminMw)
|
||||
|
||||
// DELETE /api/auth/admin/users/:id - delete user
|
||||
e.DELETE("/api/auth/admin/users/:id", func(c echo.Context) error {
|
||||
currentUser := auth.GetUser(c)
|
||||
@@ -942,6 +1005,67 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
||||
})
|
||||
}, adminMw)
|
||||
|
||||
// GET /api/auth/admin/users/:id/quotas - list user's quota rules
|
||||
e.GET("/api/auth/admin/users/:id/quotas", func(c echo.Context) error {
|
||||
targetID := c.Param("id")
|
||||
var target auth.User
|
||||
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
quotas, err := auth.GetQuotaStatuses(db, targetID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quotas"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"quotas": quotas})
|
||||
}, adminMw)
|
||||
|
||||
// PUT /api/auth/admin/users/:id/quotas - upsert quota rule (by user+model)
|
||||
e.PUT("/api/auth/admin/users/:id/quotas", func(c echo.Context) error {
|
||||
targetID := c.Param("id")
|
||||
var target auth.User
|
||||
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
MaxRequests *int64 `json:"max_requests"`
|
||||
MaxTotalTokens *int64 `json:"max_total_tokens"`
|
||||
Window string `json:"window"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
if body.Window == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "window is required"})
|
||||
}
|
||||
|
||||
windowSecs, err := auth.ParseWindowDuration(body.Window)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
rule, err := auth.CreateOrUpdateQuotaRule(db, targetID, body.Model, body.MaxRequests, body.MaxTotalTokens, windowSecs)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save quota rule"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "quota rule saved",
|
||||
"quota": rule,
|
||||
})
|
||||
}, adminMw)
|
||||
|
||||
// DELETE /api/auth/admin/users/:id/quotas/:quota_id - delete a quota rule
|
||||
e.DELETE("/api/auth/admin/users/:id/quotas/:quota_id", func(c echo.Context) error {
|
||||
targetID := c.Param("id")
|
||||
quotaID := c.Param("quota_id")
|
||||
if err := auth.DeleteQuotaRule(db, quotaID, targetID); err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "quota rule not found"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "quota rule deleted"})
|
||||
}, adminMw)
|
||||
|
||||
// GET /api/auth/admin/usage - all users' usage (admin only)
|
||||
e.GET("/api/auth/admin/usage", func(c echo.Context) error {
|
||||
period := c.QueryParam("period")
|
||||
|
||||
Reference in New Issue
Block a user