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:
Ettore Di Giacinto
2026-03-21 10:09:49 +01:00
committed by GitHub
parent f38e91d80b
commit 4b183b7bb6
9 changed files with 993 additions and 15 deletions

View File

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

View File

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

View File

@@ -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
View 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 := &quotaCacheStore{
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()
}
}

View File

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

View File

@@ -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 &mdash; 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}

View File

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

View File

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

View File

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