mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 11:36:46 +00:00
feat: add option to renew API key (#1214)
This commit is contained in:
@@ -266,6 +266,13 @@ func (e *APIKeyNotFoundError) Error() string {
|
|||||||
}
|
}
|
||||||
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
|
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{}
|
type APIKeyExpirationDateError struct{}
|
||||||
|
|
||||||
func (e *APIKeyExpirationDateError) Error() string {
|
func (e *APIKeyExpirationDateError) Error() string {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
|||||||
{
|
{
|
||||||
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
||||||
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
||||||
|
apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
|
||||||
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
|
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
|
// revokeApiKeyHandler godoc
|
||||||
// @Summary Revoke API key
|
// @Summary Revoke API key
|
||||||
// @Description Revoke (delete) an existing API key by ID
|
// @Description Revoke (delete) an existing API key by ID
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ type ApiKeyCreateDto struct {
|
|||||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiKeyRenewDto struct {
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type ApiKeyDto struct {
|
type ApiKeyDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -72,6 +72,56 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
|
|||||||
return apiKey, token, nil
|
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 {
|
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
|
||||||
var apiKey model.ApiKey
|
var apiKey model.ApiKey
|
||||||
err := s.db.
|
err := s.db.
|
||||||
|
|||||||
@@ -354,17 +354,30 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey := model.ApiKey{
|
apiKeys := []model.ApiKey{
|
||||||
Base: model.Base{
|
{
|
||||||
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
|
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 {
|
for _, apiKey := range apiKeys {
|
||||||
return err
|
if err := tx.Create(&apiKey).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signupTokens := []model.SignupToken{
|
signupTokens := []model.SignupToken{
|
||||||
|
|||||||
@@ -506,6 +506,10 @@
|
|||||||
"issuer_url": "Issuer URL",
|
"issuer_url": "Issuer URL",
|
||||||
"smtp_field_required_when_other_provided": "Required when any SMTP setting is provided",
|
"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",
|
"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": "Home Page",
|
||||||
"app_config_home_page_description": "The page users are redirected to after signing in."
|
"app_config_home_page_description": "The page users are redirected to after signing in."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,6 @@
|
|||||||
return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
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(() => {
|
$effect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
const newInternalCalendarDate = dateToCalendarDate(value);
|
const newInternalCalendarDate = dateToCalendarDate(value);
|
||||||
@@ -59,6 +46,17 @@
|
|||||||
|
|
||||||
function handleCalendarInteraction(newDateValue?: DateValue) {
|
function handleCalendarInteraction(newDateValue?: DateValue) {
|
||||||
open = false;
|
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(), {
|
const df = new DateFormatter(getLocale(), {
|
||||||
@@ -89,8 +87,7 @@
|
|||||||
<Popover.Content class="w-auto p-0" align="start">
|
<Popover.Content class="w-auto p-0" align="start">
|
||||||
<Calendar
|
<Calendar
|
||||||
type="single"
|
type="single"
|
||||||
bind:value={calendarDisplayDate}
|
bind:value={() => calendarDisplayDate, (newValue) => handleCalendarInteraction(newValue)}
|
||||||
onValueChange={handleCalendarInteraction}
|
|
||||||
initialFocus
|
initialFocus
|
||||||
/>
|
/>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export default class ApiKeyService extends APIService {
|
|||||||
return res.data as ApiKeyResponse;
|
return res.data as ApiKeyResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renew = async (id: string, expiresAt: Date): Promise<ApiKeyResponse> => {
|
||||||
|
const res = await this.api.post(`/api-keys/${id}/renew`, {
|
||||||
|
expiresAt
|
||||||
|
});
|
||||||
|
return res.data as ApiKeyResponse;
|
||||||
|
};
|
||||||
|
|
||||||
revoke = async (id: string): Promise<void> => {
|
revoke = async (id: string): Promise<void> => {
|
||||||
await this.api.delete(`/api-keys/${id}`);
|
await this.api.delete(`/api-keys/${id}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,4 +76,4 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<ApiKeyDialog bind:apiKeyResponse />
|
<ApiKeyDialog title={m.api_key_created()} bind:apiKeyResponse />
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
import type { ApiKeyResponse } from '$lib/types/api-key.type';
|
import type { ApiKeyResponse } from '$lib/types/api-key.type';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
title,
|
||||||
apiKeyResponse = $bindable()
|
apiKeyResponse = $bindable()
|
||||||
}: {
|
}: {
|
||||||
|
title: string;
|
||||||
apiKeyResponse: ApiKeyResponse | null;
|
apiKeyResponse: ApiKeyResponse | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
|
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
|
||||||
<Dialog.Content class="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
<Dialog.Content class="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{m.api_key_created()}</Dialog.Title>
|
<Dialog.Title>{title}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
{m.for_security_reasons_this_key_will_only_be_shown_once()}
|
{m.for_security_reasons_this_key_will_only_be_shown_once()}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
// Set default expiration to 30 days from now
|
// Set default expiration to 30 days from now
|
||||||
const defaultExpiry = new Date();
|
const defaultExpiry = new Date();
|
||||||
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
|
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
|
||||||
|
defaultExpiry.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const apiKey = {
|
const apiKey = {
|
||||||
name: '',
|
name: '',
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
AdvancedTableColumn,
|
AdvancedTableColumn,
|
||||||
CreateAdvancedTableActions
|
CreateAdvancedTableActions
|
||||||
} from '$lib/types/advanced-table.type';
|
} 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 { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideBan } from '@lucide/svelte';
|
import { LucideBan, LucideRefreshCcw, LucideTriangleAlert } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import ApiKeyDialog from './api-key-dialog.svelte';
|
||||||
|
import RenewApiKeyModal from './renew-api-key-modal.svelte';
|
||||||
|
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
||||||
|
let apiKeyToRenew: ApiKey | null = $state(null);
|
||||||
|
let renewedApiKey: ApiKeyResponse | null = $state(null);
|
||||||
let tableRef: AdvancedTable<ApiKey>;
|
let tableRef: AdvancedTable<ApiKey>;
|
||||||
|
|
||||||
export function refresh() {
|
export function refresh() {
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
label: m.expires_at(),
|
label: m.expires_at(),
|
||||||
column: 'expiresAt',
|
column: 'expiresAt',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
value: (item) => formatDate(item.expiresAt)
|
cell: ExpirationCell
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: m.last_used(),
|
label: m.last_used(),
|
||||||
@@ -53,6 +57,13 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const actions: CreateAdvancedTableActions<ApiKey> = (apiKey) => [
|
const actions: CreateAdvancedTableActions<ApiKey> = (apiKey) => [
|
||||||
|
{
|
||||||
|
label: m.renew(),
|
||||||
|
icon: LucideRefreshCcw,
|
||||||
|
variant: 'primary',
|
||||||
|
hidden: new Date(apiKey.expiresAt) > new Date(),
|
||||||
|
onClick: (apiKey) => (apiKeyToRenew = apiKey)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: m.revoke(),
|
label: m.revoke(),
|
||||||
icon: LucideBan,
|
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) {
|
function revokeApiKey(apiKey: ApiKey) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
title: m.revoke_api_key(),
|
title: m.revoke_api_key(),
|
||||||
@@ -84,6 +110,20 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet ExpirationCell({ item }: { item: ApiKey })}
|
||||||
|
{@const expired = new Date(item.expiresAt) <= new Date()}
|
||||||
|
<span
|
||||||
|
class={{
|
||||||
|
'flex gap-2 items-center': true,
|
||||||
|
'text-orange-300': expired
|
||||||
|
}}
|
||||||
|
>{formatDate(item.expiresAt)}
|
||||||
|
{#if expired}
|
||||||
|
<LucideTriangleAlert class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
id="api-key-list"
|
id="api-key-list"
|
||||||
bind:this={tableRef}
|
bind:this={tableRef}
|
||||||
@@ -93,3 +133,6 @@
|
|||||||
{columns}
|
{columns}
|
||||||
{actions}
|
{actions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ApiKeyDialog title={m.api_key_renewed()} bind:apiKeyResponse={renewedApiKey} />
|
||||||
|
<RenewApiKeyModal bind:apiKey={apiKeyToRenew} onRenew={renewApiKey} />
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DatePicker from '$lib/components/form/date-picker.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { ApiKey } from '$lib/types/api-key.type';
|
||||||
|
|
||||||
|
let {
|
||||||
|
apiKey = $bindable(null),
|
||||||
|
onRenew
|
||||||
|
}: {
|
||||||
|
apiKey: ApiKey | null;
|
||||||
|
onRenew: (date: Date) => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let date = $state(new Date());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (apiKey) {
|
||||||
|
const lastExpirationDuration =
|
||||||
|
new Date(apiKey.expiresAt).getTime() - new Date(apiKey.createdAt).getTime();
|
||||||
|
date = new Date(Date.now() + lastExpirationDuration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
apiKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root open={!!apiKey} {onOpenChange}>
|
||||||
|
<Dialog.Content class="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{m.renew_api_key()}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{m.renew_api_key_description()}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label>{m.expiration()}</Field.Label>
|
||||||
|
<DatePicker bind:value={date} />
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
<Dialog.Footer class="mt-3">
|
||||||
|
<Button variant="outline" onclick={() => onOpenChange(false)}>{m.cancel()}</Button>
|
||||||
|
<Button variant="default" usePromiseLoading onclick={() => onRenew(date)}>{m.renew()}</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -96,7 +96,14 @@ export const apiKeys = [
|
|||||||
{
|
{
|
||||||
id: '5f1fa856-c164-4295-961e-175a0d22d725',
|
id: '5f1fa856-c164-4295-961e-175a0d22d725',
|
||||||
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
|
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
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
{
|
{
|
||||||
"provider": "sqlite",
|
"provider": "sqlite",
|
||||||
"version": 20251229173100,
|
"version": 20260106140900,
|
||||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
||||||
"tables": {
|
"tables": {
|
||||||
"api_keys": [
|
"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",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
"description": null,
|
"description": null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// frontend/tests/api-key.spec.ts
|
// frontend/tests/api-key.spec.ts
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, Page, test } from '@playwright/test';
|
||||||
import { apiKeys } from '../data';
|
import { apiKeys } from '../data';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
@@ -19,15 +19,7 @@ test.describe('API Key Management', () => {
|
|||||||
|
|
||||||
// Choose the date
|
// Choose the date
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
await page.getByRole('button', { name: 'Select a date' }).click();
|
await selectDate(page, currentDate.getFullYear() + 1, currentDate.getMonth(), 1);
|
||||||
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();
|
|
||||||
|
|
||||||
// Submit the form
|
// Submit the form
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
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);
|
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 }) => {
|
test('Revoke API key', async ({ page }) => {
|
||||||
const apiKey = apiKeys[0];
|
const apiKey = apiKeys[0];
|
||||||
|
|
||||||
@@ -70,3 +86,33 @@ test.describe('API Key Management', () => {
|
|||||||
await expect(page.getByRole('cell', { name: apiKey.name })).not.toBeVisible();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user