mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-09 22:54:42 -04:00
feat(auth): let users pause and resume their API keys (#12521)
A key can now be paused until the owner resumes it, or until a given
time. A paused key is rejected by validation before last_used is
updated, and a pause time that has passed lifts the pause by itself.
Existing keys stay active.
PATCH /api/auth/api-keys/:id takes {"disabled": bool, "paused_until":
RFC 3339 string or null}. Only the key owner can change it, and a
paused_until in the past is rejected. The key list returns the pause
fields. The Account page gets a Pause and Resume button for each key
and a Paused badge that shows the resume time.
Assisted-by: Claude Code:claude-sonnet-5-5
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
1cd96e496b
commit
690f3994b0
18 files changed
+630
-30
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrPauseInPast is returned when a pause end time is not in the future.
|
||||
var ErrPauseInPast = errors.New("paused_until must be in the future")
|
||||
|
||||
const (
|
||||
apiKeyPrefix = "lai-"
|
||||
apiKeyRandBytes = 32 // 32 bytes = 64 hex chars
|
||||
@@ -90,8 +94,12 @@ func ValidateAPIKey(db *gorm.DB, plaintext, hmacSecret string) (*UserAPIKey, err
|
||||
return nil, fmt.Errorf("user account is not active")
|
||||
}
|
||||
|
||||
// Update LastUsed
|
||||
now := time.Now()
|
||||
if key.IsPaused(now) {
|
||||
return nil, fmt.Errorf("API key is paused")
|
||||
}
|
||||
|
||||
// Update LastUsed
|
||||
db.Model(&key).Update("last_used", now)
|
||||
|
||||
return &key, nil
|
||||
@@ -115,6 +123,27 @@ func RevokeAPIKey(db *gorm.DB, keyID, userID string) error {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// SetAPIKeyPause pauses or resumes an API key. Only the owner can change it.
|
||||
// A paused key is rejected by ValidateAPIKey. Pass disabled=true to pause
|
||||
// until resumed, or a pausedUntil in the future to pause until that time.
|
||||
// Pass disabled=false and a nil pausedUntil to resume.
|
||||
func SetAPIKeyPause(db *gorm.DB, keyID, userID string, disabled bool, pausedUntil *time.Time) error {
|
||||
if pausedUntil != nil && !pausedUntil.After(time.Now()) {
|
||||
return ErrPauseInPast
|
||||
}
|
||||
|
||||
result := db.Model(&UserAPIKey{}).
|
||||
Where("id = ? AND user_id = ?", keyID, userID).
|
||||
Updates(map[string]any{"disabled": disabled, "paused_until": pausedUntil})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("API key not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExpiredAPIKeys removes all API keys that have passed their expiry time.
|
||||
func CleanExpiredAPIKeys(db *gorm.DB) error {
|
||||
return db.Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&UserAPIKey{}).Error
|
||||
|
||||
@@ -4,6 +4,7 @@ package auth_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -209,4 +210,74 @@ var _ = Describe("API Keys", func() {
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetAPIKeyPause", func() {
|
||||
var (
|
||||
plaintext string
|
||||
record *auth.UserAPIKey
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
plaintext, record, err = auth.CreateAPIKey(db, user.ID, "pausable", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("keeps new keys active", func() {
|
||||
Expect(record.Disabled).To(BeFalse())
|
||||
Expect(record.PausedUntil).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects a key paused indefinitely and accepts it after resume", func() {
|
||||
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, true, nil)).To(Succeed())
|
||||
|
||||
_, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).To(MatchError(ContainSubstring("paused")))
|
||||
|
||||
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, false, nil)).To(Succeed())
|
||||
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a key paused until a future time", func() {
|
||||
until := time.Now().Add(time.Hour)
|
||||
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, false, &until)).To(Succeed())
|
||||
|
||||
_, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).To(MatchError(ContainSubstring("paused")))
|
||||
})
|
||||
|
||||
It("does not update last_used for a paused key", func() {
|
||||
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, true, nil)).To(Succeed())
|
||||
_, _ = auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
|
||||
keys, err := auth.ListAPIKeys(db, user.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(keys[0].LastUsed).To(BeNil())
|
||||
})
|
||||
|
||||
It("treats a pause time that has passed as active again", func() {
|
||||
past := time.Now().Add(-time.Minute)
|
||||
Expect(db.Model(&auth.UserAPIKey{}).Where("id = ?", record.ID).
|
||||
Update("paused_until", past).Error).To(Succeed())
|
||||
|
||||
_, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a pause time in the past", func() {
|
||||
past := time.Now().Add(-time.Minute)
|
||||
err := auth.SetAPIKeyPause(db, record.ID, user.ID, false, &past)
|
||||
Expect(err).To(MatchError(auth.ErrPauseInPast))
|
||||
})
|
||||
|
||||
It("only allows the owner to pause a key", func() {
|
||||
other := createTestUser(db, "other-pauser@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
err := auth.SetAPIKeyPause(db, record.ID, other.ID, true, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,17 @@ type UserAPIKey struct {
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
LastUsed *time.Time
|
||||
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
// Disabled pauses the key until the owner resumes it.
|
||||
Disabled bool
|
||||
// PausedUntil pauses the key until the given time; the key becomes
|
||||
// active again by itself once that time has passed.
|
||||
PausedUntil *time.Time
|
||||
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
// IsPaused reports whether the key is paused at the given time.
|
||||
func (k *UserAPIKey) IsPaused(now time.Time) bool {
|
||||
return k.Disabled || (k.PausedUntil != nil && k.PausedUntil.After(now))
|
||||
}
|
||||
|
||||
// PermissionMap is a flexible map of feature -> enabled, stored as JSON text.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Account > API Keys: pause and resume a key without deleting it.
|
||||
|
||||
const soon = new Date(Date.now() + 3 * 3600 * 1000).toISOString()
|
||||
|
||||
function json(body) {
|
||||
return { contentType: 'application/json', body: JSON.stringify(body) }
|
||||
}
|
||||
|
||||
test.describe('Account API keys pause', () => {
|
||||
let keys
|
||||
let patches
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
patches = []
|
||||
keys = [
|
||||
{ id: 'k1', name: 'active-key', keyPrefix: 'lai-aaaaaaaa', role: 'user', createdAt: new Date().toISOString(), disabled: false },
|
||||
{ id: 'k2', name: 'paused-key', keyPrefix: 'lai-bbbbbbbb', role: 'user', createdAt: new Date().toISOString(), disabled: true },
|
||||
{ id: 'k3', name: 'timed-key', keyPrefix: 'lai-cccccccc', role: 'user', createdAt: new Date().toISOString(), disabled: false, pausedUntil: soon },
|
||||
]
|
||||
|
||||
await page.route('**/api/auth/status', (route) =>
|
||||
route.fulfill(json({
|
||||
authEnabled: true,
|
||||
providers: ['local'],
|
||||
hasUsers: true,
|
||||
user: { id: 'u1', email: 'u@example.com', name: 'U', role: 'user' },
|
||||
}))
|
||||
)
|
||||
await page.route('**/api/auth/api-keys', (route) => route.fulfill(json({ keys })))
|
||||
await page.route('**/api/auth/api-keys/*', async (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'PATCH') {
|
||||
const id = req.url().split('/').pop()
|
||||
const body = req.postDataJSON()
|
||||
patches.push({ id, body })
|
||||
const key = keys.find((k) => k.id === id)
|
||||
key.disabled = body.disabled
|
||||
key.pausedUntil = body.paused_until || undefined
|
||||
return route.fulfill(json({ message: 'API key updated' }))
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await page.goto('/app/account')
|
||||
await page.getByRole('button', { name: /API Keys/ }).click()
|
||||
})
|
||||
|
||||
test('shows paused badges and resume buttons', async ({ page }) => {
|
||||
await expect(page.locator('.apikey-item')).toHaveCount(3)
|
||||
await expect(page.locator('.apikey-item').nth(0).locator('.apikey-paused-badge')).toHaveCount(0)
|
||||
await expect(page.locator('.apikey-item').nth(1).locator('.apikey-paused-badge')).toHaveText('Paused')
|
||||
await expect(page.locator('.apikey-item').nth(2).locator('.apikey-paused-badge')).toContainText('Paused until')
|
||||
})
|
||||
|
||||
test('pauses a key indefinitely', async ({ page }) => {
|
||||
const item = page.locator('.apikey-item').nth(0)
|
||||
await item.getByRole('button', { name: 'Pause' }).click()
|
||||
await item.getByRole('button', { name: 'Pause key' }).click()
|
||||
await expect(item.locator('.apikey-paused-badge')).toHaveText('Paused')
|
||||
expect(patches).toEqual([{ id: 'k1', body: { disabled: true, paused_until: null } }])
|
||||
})
|
||||
|
||||
test('pauses a key until a chosen time', async ({ page }) => {
|
||||
const item = page.locator('.apikey-item').nth(0)
|
||||
await item.getByRole('button', { name: 'Pause' }).click()
|
||||
await item.getByLabel('Until', { exact: true }).first().check()
|
||||
await item.locator('input[type="datetime-local"]').fill('2099-01-02T03:04')
|
||||
await item.getByRole('button', { name: 'Pause key' }).click()
|
||||
await expect(item.locator('.apikey-paused-badge')).toContainText('Paused until')
|
||||
expect(patches[0].body.disabled).toBe(false)
|
||||
expect(patches[0].body.paused_until).toMatch(/^2099-01-0[12]T/)
|
||||
})
|
||||
|
||||
test('resumes a paused key', async ({ page }) => {
|
||||
const item = page.locator('.apikey-item').nth(1)
|
||||
await item.getByRole('button', { name: 'Resume' }).click()
|
||||
await expect(item.locator('.apikey-paused-badge')).toHaveCount(0)
|
||||
expect(patches).toEqual([{ id: 'k2', body: { disabled: false, paused_until: null } }])
|
||||
})
|
||||
})
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "In die Zwischenablage kopiert",
|
||||
"copyFailed": "Kopieren fehlgeschlagen",
|
||||
"empty": "Noch keine API-Schlüssel. Erstellen Sie oben einen für programmgesteuerten Zugriff.",
|
||||
"lastUsed": "zuletzt verwendet {{date}}"
|
||||
"lastUsed": "zuletzt verwendet {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "Copied to clipboard",
|
||||
"copyFailed": "Failed to copy",
|
||||
"empty": "No API keys yet. Create one above to get programmatic access.",
|
||||
"lastUsed": "last used {{date}}"
|
||||
"lastUsed": "last used {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "Copiado al portapapeles",
|
||||
"copyFailed": "Error al copiar",
|
||||
"empty": "Aún no hay claves API. Crea una arriba para obtener acceso programático.",
|
||||
"lastUsed": "último uso {{date}}"
|
||||
"lastUsed": "último uso {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "Berhasil disalin ke papan klip",
|
||||
"copyFailed": "Gagal menyalin",
|
||||
"empty": "Belum ada API key. Buat satu di atas untuk akses terprogram.",
|
||||
"lastUsed": "terakhir digunakan {{date}}"
|
||||
"lastUsed": "terakhir digunakan {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
@@ -109,4 +119,4 @@
|
||||
"text": "Sepertinya halaman yang Anda cari tidak ditemukan. Mari kembalikan ke halaman sebelumnya.",
|
||||
"goHome": "Kembali ke Beranda"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "Copiato negli appunti",
|
||||
"copyFailed": "Copia non riuscita",
|
||||
"empty": "Nessuna chiave API. Creane una sopra per ottenere l'accesso programmatico.",
|
||||
"lastUsed": "ultimo utilizzo {{date}}"
|
||||
"lastUsed": "ultimo utilizzo {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "클립보드에 복사되었습니다",
|
||||
"copyFailed": "복사하지 못했습니다",
|
||||
"empty": "아직 API 키가 없습니다. 위에서 하나를 만들어 프로그래밍 방식 접근을 시작하세요.",
|
||||
"lastUsed": "마지막 사용 {{date}}"
|
||||
"lastUsed": "마지막 사용 {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "Copiado para a área de transferência",
|
||||
"copyFailed": "Falha ao copiar",
|
||||
"empty": "Nenhuma chave de API ainda. Crie uma acima para obter acesso programático.",
|
||||
"lastUsed": "último uso em {{date}}"
|
||||
"lastUsed": "último uso em {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -101,7 +101,17 @@
|
||||
"copiedToast": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"empty": "尚无 API 密钥。在上方创建一个以获得程序化访问。",
|
||||
"lastUsed": "上次使用 {{date}}"
|
||||
"lastUsed": "上次使用 {{date}}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"paused": "Paused",
|
||||
"pausedUntil": "Paused until {{date}}",
|
||||
"pauseIndefinitely": "Indefinitely",
|
||||
"pauseUntil": "Until",
|
||||
"pauseConfirm": "Pause key",
|
||||
"pausedToast": "API key paused",
|
||||
"resumedToast": "API key resumed",
|
||||
"pauseFailed": "Failed to update API key: {{message}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -253,6 +253,10 @@ function ApiKeysTab({ addToast }) {
|
||||
const [newKeyPlaintext, setNewKeyPlaintext] = useState(null)
|
||||
const [revokingId, setRevokingId] = useState(null)
|
||||
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||
const [pauseFormId, setPauseFormId] = useState(null)
|
||||
const [pauseMode, setPauseMode] = useState('indefinite')
|
||||
const [pauseUntil, setPauseUntil] = useState('')
|
||||
const [pauseBusyId, setPauseBusyId] = useState(null)
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -307,6 +311,38 @@ function ApiKeysTab({ addToast }) {
|
||||
})
|
||||
}
|
||||
|
||||
const isPaused = (k) => k.disabled || (k.pausedUntil && new Date(k.pausedUntil) > new Date())
|
||||
|
||||
const applyPause = async (id, disabled, pausedUntil) => {
|
||||
setPauseBusyId(id)
|
||||
try {
|
||||
await apiKeysApi.setPause(id, disabled, pausedUntil)
|
||||
setPauseFormId(null)
|
||||
setPauseUntil('')
|
||||
await fetchKeys()
|
||||
addToast(t(disabled || pausedUntil ? 'account.apiKeys.pausedToast' : 'account.apiKeys.resumedToast'), 'success')
|
||||
} catch (err) {
|
||||
addToast(t('account.apiKeys.pauseFailed', { message: err.message }), 'error')
|
||||
} finally {
|
||||
setPauseBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitPause = (id) => {
|
||||
if (pauseMode === 'until') {
|
||||
if (!pauseUntil) return
|
||||
applyPause(id, false, new Date(pauseUntil).toISOString())
|
||||
} else {
|
||||
applyPause(id, true, null)
|
||||
}
|
||||
}
|
||||
|
||||
const openPauseForm = (id) => {
|
||||
setPauseMode('indefinite')
|
||||
setPauseUntil('')
|
||||
setPauseFormId(id)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
@@ -394,23 +430,88 @@ function ApiKeysTab({ addToast }) {
|
||||
) : (
|
||||
<div className="card">
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="apikey-row">
|
||||
<i className="fas fa-key apikey-icon" />
|
||||
<div className="apikey-info">
|
||||
<div className="apikey-name">{k.name}</div>
|
||||
<div className="apikey-details">
|
||||
{k.keyPrefix}... · {formatDate(k.createdAt)}
|
||||
{k.lastUsed && <> · {t('account.apiKeys.lastUsed', { date: formatDate(k.lastUsed) })}</>}
|
||||
<div key={k.id} className="apikey-item">
|
||||
<div className="apikey-row">
|
||||
<i className="fas fa-key apikey-icon" />
|
||||
<div className="apikey-info">
|
||||
<div className="apikey-name">
|
||||
{k.name}
|
||||
{isPaused(k) && (
|
||||
<span className="apikey-paused-badge">
|
||||
{k.pausedUntil && !k.disabled
|
||||
? t('account.apiKeys.pausedUntil', { date: formatDate(k.pausedUntil) })
|
||||
: t('account.apiKeys.paused')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="apikey-details">
|
||||
{k.keyPrefix}... · {formatDate(k.createdAt)}
|
||||
{k.lastUsed && <> · {t('account.apiKeys.lastUsed', { date: formatDate(k.lastUsed) })}</>}
|
||||
</div>
|
||||
</div>
|
||||
{isPaused(k) ? (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => applyPause(k.id, false, null)}
|
||||
disabled={pauseBusyId === k.id}
|
||||
>
|
||||
{pauseBusyId === k.id ? <LoadingSpinner size="sm" /> : <><i className="fas fa-play" /> {t('account.apiKeys.resume')}</>}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => (pauseFormId === k.id ? setPauseFormId(null) : openPauseForm(k.id))}
|
||||
>
|
||||
<i className="fas fa-pause" /> {t('account.apiKeys.pause')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm apikey-revoke-btn"
|
||||
onClick={() => handleRevoke(k.id, k.name)}
|
||||
disabled={revokingId === k.id}
|
||||
title={t('account.apiKeys.revokeKey')}
|
||||
>
|
||||
{revokingId === k.id ? <LoadingSpinner size="sm" /> : <i className="fas fa-trash" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm apikey-revoke-btn"
|
||||
onClick={() => handleRevoke(k.id, k.name)}
|
||||
disabled={revokingId === k.id}
|
||||
title={t('account.apiKeys.revokeKey')}
|
||||
>
|
||||
{revokingId === k.id ? <LoadingSpinner size="sm" /> : <i className="fas fa-trash" />}
|
||||
</button>
|
||||
{pauseFormId === k.id && !isPaused(k) && (
|
||||
<div className="apikey-pause-form">
|
||||
<label className="apikey-pause-option">
|
||||
<input
|
||||
type="radio"
|
||||
name={`pause-mode-${k.id}`}
|
||||
checked={pauseMode === 'indefinite'}
|
||||
onChange={() => setPauseMode('indefinite')}
|
||||
/>
|
||||
{t('account.apiKeys.pauseIndefinitely')}
|
||||
</label>
|
||||
<label className="apikey-pause-option">
|
||||
<input
|
||||
type="radio"
|
||||
name={`pause-mode-${k.id}`}
|
||||
checked={pauseMode === 'until'}
|
||||
onChange={() => setPauseMode('until')}
|
||||
/>
|
||||
{t('account.apiKeys.pauseUntil')}
|
||||
</label>
|
||||
{pauseMode === 'until' && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input apikey-pause-date"
|
||||
aria-label={t('account.apiKeys.pauseUntil')}
|
||||
value={pauseUntil}
|
||||
onChange={(e) => setPauseUntil(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => submitPause(k.id)}
|
||||
disabled={pauseBusyId === k.id || (pauseMode === 'until' && !pauseUntil)}
|
||||
>
|
||||
{t('account.apiKeys.pauseConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -468,10 +468,39 @@
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.apikey-row:not(:last-child) {
|
||||
.apikey-item:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.apikey-paused-badge {
|
||||
margin-left: var(--spacing-sm);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-warning-light);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.apikey-pause-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
padding: 0 0 var(--spacing-sm) 24px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.apikey-pause-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.apikey-pause-date {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.apikey-icon {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
Vendored
+6
@@ -567,6 +567,12 @@ export const apiKeysApi = {
|
||||
list: () => fetchJSON('/api/auth/api-keys'),
|
||||
create: (name) => postJSON('/api/auth/api-keys', { name }),
|
||||
revoke: (id) => fetchJSON(`/api/auth/api-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
// pausedUntil is an RFC3339 string or null; disabled pauses until resumed.
|
||||
setPause: (id, disabled, pausedUntil = null) => fetchJSON(`/api/auth/api-keys/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ disabled, paused_until: pausedUntil }),
|
||||
}),
|
||||
}
|
||||
|
||||
// Fine-tuning API
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@@ -732,10 +733,14 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
||||
"role": k.Role,
|
||||
"createdAt": k.CreatedAt,
|
||||
"lastUsed": k.LastUsed,
|
||||
"disabled": k.Disabled,
|
||||
}
|
||||
if k.ExpiresAt != nil {
|
||||
entry["expiresAt"] = k.ExpiresAt
|
||||
}
|
||||
if k.PausedUntil != nil {
|
||||
entry["pausedUntil"] = k.PausedUntil
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
@@ -757,6 +762,40 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
|
||||
})
|
||||
|
||||
// PATCH /api/auth/api-keys/:id - pause or resume an API key
|
||||
e.PATCH("/api/auth/api-keys/:id", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
PausedUntil *string `json:"paused_until"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
var pausedUntil *time.Time
|
||||
if body.PausedUntil != nil && *body.PausedUntil != "" {
|
||||
t, err := time.Parse(time.RFC3339, *body.PausedUntil)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid paused_until format, use RFC3339"})
|
||||
}
|
||||
pausedUntil = &t
|
||||
}
|
||||
|
||||
if err := auth.SetAPIKeyPause(db, c.Param("id"), user.ID, body.Disabled, pausedUntil); err != nil {
|
||||
if errors.Is(err, auth.ErrPauseInPast) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "API key updated"})
|
||||
})
|
||||
|
||||
// Usage endpoints
|
||||
// GET /api/auth/usage - user's own usage
|
||||
e.GET("/api/auth/usage", func(c echo.Context) error {
|
||||
|
||||
@@ -5,6 +5,7 @@ package routes_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -217,9 +218,11 @@ func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
|
||||
result := make([]map[string]any, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
result = append(result, map[string]any{
|
||||
"id": k.ID,
|
||||
"name": k.Name,
|
||||
"keyPrefix": k.KeyPrefix,
|
||||
"id": k.ID,
|
||||
"name": k.Name,
|
||||
"keyPrefix": k.KeyPrefix,
|
||||
"disabled": k.Disabled,
|
||||
"pausedUntil": k.PausedUntil,
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"keys": result})
|
||||
@@ -238,6 +241,40 @@ func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
|
||||
})
|
||||
|
||||
// PATCH /api/auth/api-keys/:id - pause or resume an API key
|
||||
e.PATCH("/api/auth/api-keys/:id", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
PausedUntil *string `json:"paused_until"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
var pausedUntil *time.Time
|
||||
if body.PausedUntil != nil && *body.PausedUntil != "" {
|
||||
t, err := time.Parse(time.RFC3339, *body.PausedUntil)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid paused_until format, use RFC3339"})
|
||||
}
|
||||
pausedUntil = &t
|
||||
}
|
||||
|
||||
if err := auth.SetAPIKeyPause(db, c.Param("id"), user.ID, body.Disabled, pausedUntil); err != nil {
|
||||
if errors.Is(err, auth.ErrPauseInPast) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "API key updated"})
|
||||
})
|
||||
|
||||
// Admin: GET /api/auth/admin/users
|
||||
adminMw := auth.RequireAdmin()
|
||||
e.GET("/api/auth/admin/users", func(c echo.Context) error {
|
||||
@@ -619,6 +656,97 @@ var _ = Describe("Auth Routes", Label("auth"), func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("PATCH /api/auth/api-keys/:id", func() {
|
||||
patchKey := func(app *echo.Echo, id, sessionID string, body map[string]any) *httptest.ResponseRecorder {
|
||||
b, _ := json.Marshal(body)
|
||||
return doAuthRequest(app, "PATCH", "/api/auth/api-keys/"+id, b, withSession(sessionID))
|
||||
}
|
||||
|
||||
It("pauses a key indefinitely and resumes it", func() {
|
||||
user := createRouteTestUser(db, "pause@test.com", auth.RoleUser)
|
||||
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "pausable", auth.RoleUser, "", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := patchKey(app, record.ID, sessionID, map[string]any{"disabled": true, "paused_until": nil})
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
|
||||
rec = doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
|
||||
var resp map[string]any
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
|
||||
entry := resp["keys"].([]any)[0].(map[string]any)
|
||||
Expect(entry["disabled"]).To(BeTrue())
|
||||
|
||||
rec = patchKey(app, record.ID, sessionID, map[string]any{"disabled": false, "paused_until": nil})
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("pauses a key until a future time and lists the resume time", func() {
|
||||
user := createRouteTestUser(db, "pause-until@test.com", auth.RoleUser)
|
||||
plaintext, record, _ := auth.CreateAPIKey(db, user.ID, "timed", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
until := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
||||
rec := patchKey(app, record.ID, sessionID, map[string]any{"disabled": false, "paused_until": until})
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
|
||||
rec = doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
|
||||
var resp map[string]any
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
|
||||
entry := resp["keys"].([]any)[0].(map[string]any)
|
||||
Expect(entry["pausedUntil"]).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("rejects a pause time in the past", func() {
|
||||
user := createRouteTestUser(db, "pause-past@test.com", auth.RoleUser)
|
||||
_, record, _ := auth.CreateAPIKey(db, user.ID, "k", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
rec := patchKey(app, record.ID, sessionID, map[string]any{"paused_until": past})
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("rejects a malformed pause time", func() {
|
||||
user := createRouteTestUser(db, "pause-bad@test.com", auth.RoleUser)
|
||||
_, record, _ := auth.CreateAPIKey(db, user.ID, "k", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := patchKey(app, record.ID, sessionID, map[string]any{"paused_until": "tomorrow"})
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 404 for another user's key", func() {
|
||||
owner := createRouteTestUser(db, "pause-owner@test.com", auth.RoleUser)
|
||||
other := createRouteTestUser(db, "pause-other@test.com", auth.RoleAdmin)
|
||||
_, record, _ := auth.CreateAPIKey(db, owner.ID, "k", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, other.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := patchKey(app, record.ID, sessionID, map[string]any{"disabled": true})
|
||||
Expect(rec.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 401 when not authenticated", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
rec := doAuthRequest(app, "PATCH", "/api/auth/api-keys/x", []byte(`{"disabled":true}`))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Admin: GET /api/auth/admin/users", func() {
|
||||
It("returns all users for admin", func() {
|
||||
admin := createRouteTestUser(db, "admin@test.com", auth.RoleAdmin)
|
||||
|
||||
@@ -291,6 +291,30 @@ curl -X POST http://localhost:8080/api/auth/api-keys \
|
||||
|
||||
User API keys inherit the creating user's role. Admin keys grant admin access; user keys grant user-level access.
|
||||
|
||||
You can pause a key without deleting it, and resume it later. Only the key's owner can pause it. A paused key is rejected like an invalid key until it is resumed. Pause it until you resume it, or until a time in the future, after which it works again by itself:
|
||||
|
||||
```bash
|
||||
# Pause until resumed
|
||||
curl -X PATCH http://localhost:8080/api/auth/api-keys/<key-id> \
|
||||
-H "Cookie: session=<session-id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"disabled": true, "paused_until": null}'
|
||||
|
||||
# Pause until a given time (RFC 3339, must be in the future)
|
||||
curl -X PATCH http://localhost:8080/api/auth/api-keys/<key-id> \
|
||||
-H "Cookie: session=<session-id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"disabled": false, "paused_until": "2030-01-01T00:00:00Z"}'
|
||||
|
||||
# Resume
|
||||
curl -X PATCH http://localhost:8080/api/auth/api-keys/<key-id> \
|
||||
-H "Cookie: session=<session-id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"disabled": false, "paused_until": null}'
|
||||
```
|
||||
|
||||
The key list returns `disabled` and, when set, `pausedUntil` for each key. The Account page in the web UI has a Pause and Resume button for each key.
|
||||
|
||||
### Auth API Endpoints
|
||||
|
||||
| Method | Endpoint | Description | Auth Required |
|
||||
@@ -307,6 +331,7 @@ User API keys inherit the creating user's role. Admin keys grant admin access; u
|
||||
| `GET` | `/api/auth/me` | Current user info | Yes |
|
||||
| `POST` | `/api/auth/api-keys` | Create API key | Yes |
|
||||
| `GET` | `/api/auth/api-keys` | List user's API keys | Yes |
|
||||
| `PATCH` | `/api/auth/api-keys/:id` | Pause or resume API key | Yes |
|
||||
| `DELETE` | `/api/auth/api-keys/:id` | Revoke API key | Yes |
|
||||
| `GET` | `/api/auth/usage` | User's own usage stats | Yes |
|
||||
| `GET` | `/api/auth/usage/sources` | User's own per-API-key / per-source breakdown | Yes |
|
||||
|
||||
Reference in new issue
Block a user