diff --git a/core/http/auth/apikeys.go b/core/http/auth/apikeys.go index 295030f2c..975d1ef5d 100644 --- a/core/http/auth/apikeys.go +++ b/core/http/auth/apikeys.go @@ -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 diff --git a/core/http/auth/apikeys_test.go b/core/http/auth/apikeys_test.go index 4441af07a..ab2253692 100644 --- a/core/http/auth/apikeys_test.go +++ b/core/http/auth/apikeys_test.go @@ -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()) + }) + }) }) diff --git a/core/http/auth/models.go b/core/http/auth/models.go index 854d02e6c..6a8d5260d 100644 --- a/core/http/auth/models.go +++ b/core/http/auth/models.go @@ -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. diff --git a/core/http/react-ui/e2e/account-api-key-pause.spec.js b/core/http/react-ui/e2e/account-api-key-pause.spec.js new file mode 100644 index 000000000..149a9cb85 --- /dev/null +++ b/core/http/react-ui/e2e/account-api-key-pause.spec.js @@ -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 } }]) + }) +}) diff --git a/core/http/react-ui/public/locales/de/auth.json b/core/http/react-ui/public/locales/de/auth.json index a60238a20..286420c52 100644 --- a/core/http/react-ui/public/locales/de/auth.json +++ b/core/http/react-ui/public/locales/de/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/en/auth.json b/core/http/react-ui/public/locales/en/auth.json index 242bb5889..24932fdc4 100644 --- a/core/http/react-ui/public/locales/en/auth.json +++ b/core/http/react-ui/public/locales/en/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/es/auth.json b/core/http/react-ui/public/locales/es/auth.json index 6c867d775..8e54ff809 100644 --- a/core/http/react-ui/public/locales/es/auth.json +++ b/core/http/react-ui/public/locales/es/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/id/auth.json b/core/http/react-ui/public/locales/id/auth.json index ca5387e18..ec764254b 100644 --- a/core/http/react-ui/public/locales/id/auth.json +++ b/core/http/react-ui/public/locales/id/auth.json @@ -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" } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/it/auth.json b/core/http/react-ui/public/locales/it/auth.json index a42258ee6..548553da3 100644 --- a/core/http/react-ui/public/locales/it/auth.json +++ b/core/http/react-ui/public/locales/it/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/ko/auth.json b/core/http/react-ui/public/locales/ko/auth.json index 0bc40391b..09e2e4694 100644 --- a/core/http/react-ui/public/locales/ko/auth.json +++ b/core/http/react-ui/public/locales/ko/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/pt-BR/auth.json b/core/http/react-ui/public/locales/pt-BR/auth.json index 2abf32124..dfc9b3f7b 100644 --- a/core/http/react-ui/public/locales/pt-BR/auth.json +++ b/core/http/react-ui/public/locales/pt-BR/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/zh-CN/auth.json b/core/http/react-ui/public/locales/zh-CN/auth.json index 16233fdf1..5ec188691 100644 --- a/core/http/react-ui/public/locales/zh-CN/auth.json +++ b/core/http/react-ui/public/locales/zh-CN/auth.json @@ -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": { diff --git a/core/http/react-ui/src/pages/Account.jsx b/core/http/react-ui/src/pages/Account.jsx index a269fdd7b..38323c222 100644 --- a/core/http/react-ui/src/pages/Account.jsx +++ b/core/http/react-ui/src/pages/Account.jsx @@ -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 }) { ) : (