diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go
index 9647dbd4..c2bd43d6 100644
--- a/backend/internal/common/errors.go
+++ b/backend/internal/common/errors.go
@@ -266,6 +266,13 @@ func (e *APIKeyNotFoundError) Error() string {
}
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
+type APIKeyNotExpiredError struct{}
+
+func (e *APIKeyNotExpiredError) Error() string {
+ return "API Key is not expired yet"
+}
+func (e *APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
+
type APIKeyExpirationDateError struct{}
func (e *APIKeyExpirationDateError) Error() string {
diff --git a/backend/internal/controller/api_key_controller.go b/backend/internal/controller/api_key_controller.go
index 0a10aec1..3b364682 100644
--- a/backend/internal/controller/api_key_controller.go
+++ b/backend/internal/controller/api_key_controller.go
@@ -30,6 +30,7 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
{
apiKeyGroup.GET("", uc.listApiKeysHandler)
apiKeyGroup.POST("", uc.createApiKeyHandler)
+ apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
}
}
@@ -101,6 +102,41 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
})
}
+// renewApiKeyHandler godoc
+// @Summary Renew API key
+// @Description Renew an existing API key by ID
+// @Tags API Keys
+// @Param id path string true "API Key ID"
+// @Success 200 {object} dto.ApiKeyResponseDto "Renewed API key with new token"
+// @Router /api/api-keys/{id}/renew [post]
+func (c *ApiKeyController) renewApiKeyHandler(ctx *gin.Context) {
+ userID := ctx.GetString("userID")
+ apiKeyID := ctx.Param("id")
+
+ var input dto.ApiKeyRenewDto
+ if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
+ _ = ctx.Error(err)
+ return
+ }
+
+ apiKey, token, err := c.apiKeyService.RenewApiKey(ctx.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
+ if err != nil {
+ _ = ctx.Error(err)
+ return
+ }
+
+ var apiKeyDto dto.ApiKeyDto
+ if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
+ _ = ctx.Error(err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, dto.ApiKeyResponseDto{
+ ApiKey: apiKeyDto,
+ Token: token,
+ })
+}
+
// revokeApiKeyHandler godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID
diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go
index 15ce27ea..e1920b67 100644
--- a/backend/internal/dto/api_key_dto.go
+++ b/backend/internal/dto/api_key_dto.go
@@ -10,6 +10,10 @@ type ApiKeyCreateDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
+type ApiKeyRenewDto struct {
+ ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
+}
+
type ApiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`
diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go
index 42c5ce39..97562697 100644
--- a/backend/internal/service/api_key_service.go
+++ b/backend/internal/service/api_key_service.go
@@ -72,6 +72,56 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
return apiKey, token, nil
}
+func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (model.ApiKey, string, error) {
+ // Check if expiration is in the future
+ if !expiration.After(time.Now()) {
+ return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
+ }
+
+ tx := s.db.Begin()
+ defer tx.Rollback()
+
+ var apiKey model.ApiKey
+ err := tx.
+ WithContext(ctx).
+ Model(&model.ApiKey{}).
+ Where("id = ? AND user_id = ?", apiKeyID, userID).
+ First(&apiKey).
+ Error
+
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return model.ApiKey{}, "", &common.APIKeyNotFoundError{}
+ }
+ return model.ApiKey{}, "", err
+ }
+
+ // Only allow renewal if the key has already expired
+ if apiKey.ExpiresAt.ToTime().After(time.Now()) {
+ return model.ApiKey{}, "", &common.APIKeyNotExpiredError{}
+ }
+
+ // Generate a secure random API key
+ token, err := utils.GenerateRandomAlphanumericString(32)
+ if err != nil {
+ return model.ApiKey{}, "", err
+ }
+
+ apiKey.Key = utils.CreateSha256Hash(token)
+ apiKey.ExpiresAt = datatype.DateTime(expiration)
+
+ err = tx.WithContext(ctx).Save(&apiKey).Error
+ if err != nil {
+ return model.ApiKey{}, "", err
+ }
+
+ if err := tx.Commit().Error; err != nil {
+ return model.ApiKey{}, "", err
+ }
+
+ return apiKey, token, nil
+}
+
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey model.ApiKey
err := s.db.
diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go
index 1be77436..7cb98530 100644
--- a/backend/internal/service/e2etest_service.go
+++ b/backend/internal/service/e2etest_service.go
@@ -354,17 +354,30 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
- apiKey := model.ApiKey{
- Base: model.Base{
- ID: "5f1fa856-c164-4295-961e-175a0d22d725",
+ apiKeys := []model.ApiKey{
+ {
+ Base: model.Base{
+ ID: "5f1fa856-c164-4295-961e-175a0d22d725",
+ },
+ Name: "Test API Key",
+ Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
+ UserID: users[0].ID,
+ ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
+ },
+ {
+ Base: model.Base{
+ ID: "98900330-7a7b-48fe-881b-2cc6ad049976",
+ },
+ Name: "Expired API Key",
+ Key: "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389",
+ UserID: users[0].ID,
+ ExpiresAt: datatype.DateTime(time.Now().Add(-20 * 24 * time.Hour)),
},
- Name: "Test API Key",
- Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
- UserID: users[0].ID,
- ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
}
- if err := tx.Create(&apiKey).Error; err != nil {
- return err
+ for _, apiKey := range apiKeys {
+ if err := tx.Create(&apiKey).Error; err != nil {
+ return err
+ }
}
signupTokens := []model.SignupToken{
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 39d0587c..18a9d9bf 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -506,6 +506,10 @@
"issuer_url": "Issuer URL",
"smtp_field_required_when_other_provided": "Required when any SMTP setting is provided",
"smtp_field_required_when_email_enabled": "Required when email notifications are enabled",
+ "renew": "Renew",
+ "renew_api_key": "Renew API Key",
+ "renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.",
+ "api_key_renewed": "API key renewed",
"app_config_home_page": "Home Page",
"app_config_home_page_description": "The page users are redirected to after signing in."
}
diff --git a/frontend/src/lib/components/form/date-picker.svelte b/frontend/src/lib/components/form/date-picker.svelte
index e2723265..fc0627ce 100644
--- a/frontend/src/lib/components/form/date-picker.svelte
+++ b/frontend/src/lib/components/form/date-picker.svelte
@@ -31,19 +31,6 @@
return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
- $effect(() => {
- if (calendarDisplayDate) {
- const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone());
- if (!value || value.getTime() !== newExternalDate.getTime()) {
- value = newExternalDate;
- }
- } else {
- if (value !== undefined) {
- value = undefined;
- }
- }
- });
-
$effect(() => {
if (value) {
const newInternalCalendarDate = dateToCalendarDate(value);
@@ -59,6 +46,17 @@
function handleCalendarInteraction(newDateValue?: DateValue) {
open = false;
+ calendarDisplayDate = newDateValue as CalendarDate | undefined;
+ if (calendarDisplayDate) {
+ const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone());
+ if (!value || value.getTime() !== newExternalDate.getTime()) {
+ value = newExternalDate;
+ }
+ } else {
+ if (value !== undefined) {
+ value = undefined;
+ }
+ }
}
const df = new DateFormatter(getLocale(), {
@@ -89,8 +87,7 @@
calendarDisplayDate, (newValue) => handleCalendarInteraction(newValue)}
initialFocus
/>
diff --git a/frontend/src/lib/services/api-key-service.ts b/frontend/src/lib/services/api-key-service.ts
index 681a23b3..c30bf984 100644
--- a/frontend/src/lib/services/api-key-service.ts
+++ b/frontend/src/lib/services/api-key-service.ts
@@ -13,6 +13,13 @@ export default class ApiKeyService extends APIService {
return res.data as ApiKeyResponse;
};
+ renew = async (id: string, expiresAt: Date): Promise => {
+ const res = await this.api.post(`/api-keys/${id}/renew`, {
+ expiresAt
+ });
+ return res.data as ApiKeyResponse;
+ };
+
revoke = async (id: string): Promise => {
await this.api.delete(`/api-keys/${id}`);
};
diff --git a/frontend/src/routes/settings/admin/api-keys/+page.svelte b/frontend/src/routes/settings/admin/api-keys/+page.svelte
index 91ab3efd..06317a69 100644
--- a/frontend/src/routes/settings/admin/api-keys/+page.svelte
+++ b/frontend/src/routes/settings/admin/api-keys/+page.svelte
@@ -76,4 +76,4 @@
-
+
diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte
index fd0715f7..b52ba02b 100644
--- a/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte
+++ b/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte
@@ -6,8 +6,10 @@
import type { ApiKeyResponse } from '$lib/types/api-key.type';
let {
+ title,
apiKeyResponse = $bindable()
}: {
+ title: string;
apiKeyResponse: ApiKeyResponse | null;
} = $props();
@@ -21,7 +23,7 @@
e.preventDefault()}>
- {m.api_key_created()}
+ {title}
{m.for_security_reasons_this_key_will_only_be_shown_once()}
diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte
index 09518f2b..0c09ff87 100644
--- a/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte
+++ b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte
@@ -19,6 +19,7 @@
// Set default expiration to 30 days from now
const defaultExpiry = new Date();
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
+ defaultExpiry.setHours(0, 0, 0, 0);
const apiKey = {
name: '',
diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte
index 656306a5..7bfc08e8 100644
--- a/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte
+++ b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte
@@ -7,13 +7,17 @@
AdvancedTableColumn,
CreateAdvancedTableActions
} from '$lib/types/advanced-table.type';
- import type { ApiKey } from '$lib/types/api-key.type';
+ import type { ApiKey, ApiKeyResponse } from '$lib/types/api-key.type';
import { axiosErrorToast } from '$lib/utils/error-util';
- import { LucideBan } from '@lucide/svelte';
+ import { LucideBan, LucideRefreshCcw, LucideTriangleAlert } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
+ import ApiKeyDialog from './api-key-dialog.svelte';
+ import RenewApiKeyModal from './renew-api-key-modal.svelte';
const apiKeyService = new ApiKeyService();
+ let apiKeyToRenew: ApiKey | null = $state(null);
+ let renewedApiKey: ApiKeyResponse | null = $state(null);
let tableRef: AdvancedTable;
export function refresh() {
@@ -35,7 +39,7 @@
label: m.expires_at(),
column: 'expiresAt',
sortable: true,
- value: (item) => formatDate(item.expiresAt)
+ cell: ExpirationCell
},
{
label: m.last_used(),
@@ -53,6 +57,13 @@
];
const actions: CreateAdvancedTableActions = (apiKey) => [
+ {
+ label: m.renew(),
+ icon: LucideRefreshCcw,
+ variant: 'primary',
+ hidden: new Date(apiKey.expiresAt) > new Date(),
+ onClick: (apiKey) => (apiKeyToRenew = apiKey)
+ },
{
label: m.revoke(),
icon: LucideBan,
@@ -61,6 +72,21 @@
}
];
+ async function renewApiKey(expirationDate: Date) {
+ if (!apiKeyToRenew) return;
+
+ await apiKeyService
+ .renew(apiKeyToRenew.id, expirationDate)
+ .then(async (response) => {
+ renewedApiKey = response;
+ await refresh();
+ apiKeyToRenew = null;
+ })
+ .catch((e) => {
+ axiosErrorToast(e);
+ });
+ }
+
function revokeApiKey(apiKey: ApiKey) {
openConfirmDialog({
title: m.revoke_api_key(),
@@ -84,6 +110,20 @@
}
+{#snippet ExpirationCell({ item }: { item: ApiKey })}
+ {@const expired = new Date(item.expiresAt) <= new Date()}
+ {formatDate(item.expiresAt)}
+ {#if expired}
+
+ {/if}
+
+{/snippet}
+
+
+
+
diff --git a/frontend/src/routes/settings/admin/api-keys/renew-api-key-modal.svelte b/frontend/src/routes/settings/admin/api-keys/renew-api-key-modal.svelte
new file mode 100644
index 00000000..30ad47fa
--- /dev/null
+++ b/frontend/src/routes/settings/admin/api-keys/renew-api-key-modal.svelte
@@ -0,0 +1,52 @@
+
+
+
+ e.preventDefault()}>
+
+ {m.renew_api_key()}
+
+ {m.renew_api_key_description()}
+
+
+
+ {m.expiration()}
+
+
+
+
+
+
+
+
+
diff --git a/tests/data.ts b/tests/data.ts
index 1c234ef2..c47af384 100644
--- a/tests/data.ts
+++ b/tests/data.ts
@@ -96,7 +96,14 @@ export const apiKeys = [
{
id: '5f1fa856-c164-4295-961e-175a0d22d725',
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
- name: 'Test API Key'
+ name: 'Test API Key',
+ expired: false
+ },
+ {
+ id: '98900330-7a7b-48fe-881b-2cc6ad049976',
+ key: '141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389',
+ name: 'Expired API Key',
+ expired: true
}
];
diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json
index ed60f745..7232df29 100644
--- a/tests/resources/export/database.json
+++ b/tests/resources/export/database.json
@@ -1,9 +1,20 @@
{
"provider": "sqlite",
- "version": 20251229173100,
+ "version": 20260106140900,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
"tables": {
"api_keys": [
+ {
+ "created_at": "2025-12-21T19:12:03Z",
+ "description": null,
+ "expiration_email_sent": false,
+ "expires_at": "2025-12-25T12:00:00Z",
+ "key": "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389",
+ "id": "98900330-7a7b-48fe-881b-2cc6ad049976",
+ "last_used_at": null,
+ "name": "Expired API Key",
+ "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
+ },
{
"created_at": "2025-11-25T12:39:02Z",
"description": null,
diff --git a/tests/specs/api-key.spec.ts b/tests/specs/api-key.spec.ts
index 2543b428..57c70ff4 100644
--- a/tests/specs/api-key.spec.ts
+++ b/tests/specs/api-key.spec.ts
@@ -1,5 +1,5 @@
// frontend/tests/api-key.spec.ts
-import { expect, test } from '@playwright/test';
+import { expect, Page, test } from '@playwright/test';
import { apiKeys } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
@@ -19,15 +19,7 @@ test.describe('API Key Management', () => {
// Choose the date
const currentDate = new Date();
- await page.getByRole('button', { name: 'Select a date' }).click();
- await page.getByLabel('Select year').click();
- // Select the next year
- await page.getByRole('option', { name: (currentDate.getFullYear() + 1).toString() }).click();
- // Select the first day of the month
- await page
- .getByRole('button', { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ })
- .first()
- .click();
+ await selectDate(page, currentDate.getFullYear() + 1, currentDate.getMonth(), 1);
// Submit the form
await page.getByRole('button', { name: 'Save' }).click();
@@ -51,6 +43,30 @@ test.describe('API Key Management', () => {
await expect(page.getByRole('cell', { name }).first()).toContainText(name);
});
+ test('Renew API key', async ({ page }) => {
+ const apiKey = apiKeys[1];
+
+ await page
+ .getByRole('row', { name: apiKey.name })
+ .getByRole('button', { name: 'Toggle menu' })
+ .click();
+
+ await page.getByRole('menuitem', { name: 'Renew' }).click();
+
+ // Choose the date
+ const currentDate = new Date();
+ await selectDate(page, currentDate.getFullYear() + 1, currentDate.getMonth(), 1);
+
+ await page.getByRole('button', { name: 'Renew' }).click();
+
+ await expect(page.getByRole('heading', { name: 'API key renewed' })).toBeVisible();
+
+ // Verify the new expiration date is shown
+ const row = page.getByRole('row', { name: apiKey.name });
+ const expectedDate = new Date(currentDate.getFullYear() + 1, currentDate.getMonth(), 1);
+ await expect(row.getByRole('cell', { name: expectedDate.toLocaleString() })).toBeVisible();
+ });
+
test('Revoke API key', async ({ page }) => {
const apiKey = apiKeys[0];
@@ -70,3 +86,33 @@ test.describe('API Key Management', () => {
await expect(page.getByRole('cell', { name: apiKey.name })).not.toBeVisible();
});
});
+
+async function selectDate(page: Page, year: number, month: number, day: number) {
+ // Open the date picker
+ await page.getByRole('button', { name: 'Select a date' }).click();
+ // Select the year
+ await page.getByLabel('Select year').click();
+ await page.getByRole('option', { name: year.toString() }).click();
+ // Select the month and day
+ const monthNames = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December'
+ ];
+ const monthName = monthNames[month];
+ await page.getByRole('button', { name: 'Select month' }).click();
+ await page.getByRole('option', { name: monthName }).click();
+
+ await page
+ .getByRole('button', { name: new RegExp(`([A-Z][a-z]+), ([A-Z][a-z]+) ${day}, (\\d{4})`) }).first()
+ .click();
+}