mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-12 12:06:18 +00:00
feat: api key authentication (#291)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -6,7 +6,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const clientId = url.searchParams.get('client_id');
|
||||
const oidcService = new OidcService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||
|
||||
const client = await oidcService.getClient(clientId!);
|
||||
const client = await oidcService.getClientMetaData(clientId!);
|
||||
|
||||
return {
|
||||
scope: url.searchParams.get('scope')!,
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
{ href: '/settings/admin/users', label: 'Users' },
|
||||
{ href: '/settings/admin/user-groups', label: 'User Groups' },
|
||||
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
|
||||
{ href: '/settings/admin/api-keys', label: 'API Keys' },
|
||||
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
|
||||
];
|
||||
}
|
||||
|
||||
16
frontend/src/routes/settings/admin/api-keys/+page.server.ts
Normal file
16
frontend/src/routes/settings/admin/api-keys/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
||||
import ApiKeyService from '$lib/services/api-key-service';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const apiKeyService = new ApiKeyService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||
|
||||
const apiKeys = await apiKeyService.list({
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc' as const
|
||||
}
|
||||
});
|
||||
|
||||
return apiKeys;
|
||||
};
|
||||
89
frontend/src/routes/settings/admin/api-keys/+page.svelte
Normal file
89
frontend/src/routes/settings/admin/api-keys/+page.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import ApiKeyService from '$lib/services/api-key-service';
|
||||
import type { ApiKey, ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type';
|
||||
import type { Paginated } from '$lib/types/pagination.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideMinus } from 'lucide-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ApiKeyDialog from './api-key-dialog.svelte';
|
||||
import ApiKeyForm from './api-key-form.svelte';
|
||||
import ApiKeyList from './api-key-list.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let apiKeys = $state<Paginated<ApiKey>>(data);
|
||||
|
||||
const apiKeyService = new ApiKeyService();
|
||||
let expandAddApiKey = $state(false);
|
||||
let apiKeyResponse = $state<ApiKeyResponse | null>(null);
|
||||
|
||||
async function createApiKey(apiKeyData: ApiKeyCreate) {
|
||||
try {
|
||||
const response = await apiKeyService.create(apiKeyData);
|
||||
apiKeyResponse = response;
|
||||
|
||||
// After creation, reload the list of API keys
|
||||
const updatedKeys = await apiKeyService.list();
|
||||
apiKeys = updatedKeys;
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogClose(open: boolean) {
|
||||
if (!open) {
|
||||
apiKeyResponse = null;
|
||||
// Refresh the list when dialog closes
|
||||
apiKeyService
|
||||
.list()
|
||||
.then((keys) => {
|
||||
apiKeys = keys;
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>API Keys</title>
|
||||
</svelte:head>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Create API Key</Card.Title>
|
||||
<Card.Description>Add a new API key for programmatic access.</Card.Description>
|
||||
</div>
|
||||
{#if !expandAddApiKey}
|
||||
<Button on:click={() => (expandAddApiKey = true)}>Add API Key</Button>
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}>
|
||||
<LucideMinus class="h-5 w-5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if expandAddApiKey}
|
||||
<div transition:slide>
|
||||
<Card.Content>
|
||||
<ApiKeyForm callback={createApiKey} />
|
||||
</Card.Content>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="mt-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Manage API Keys</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ApiKeyList {apiKeys} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<ApiKeyDialog bind:apiKeyResponse onOpenChange={handleDialogClose} />
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import type { ApiKeyResponse } from '$lib/types/api-key.type';
|
||||
|
||||
let {
|
||||
apiKeyResponse = $bindable(),
|
||||
onOpenChange
|
||||
}: {
|
||||
apiKeyResponse: ApiKeyResponse | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md" closeButton={false}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>API Key Created</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
For security reasons, this key will only be shown once. Please store it securely.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if apiKeyResponse}
|
||||
<div>
|
||||
<div class="mb-2 font-medium">Name</div>
|
||||
<p class="text-muted-foreground">{apiKeyResponse.apiKey.name}</p>
|
||||
|
||||
{#if apiKeyResponse.apiKey.description}
|
||||
<div class="mb-2 mt-4 font-medium">Description</div>
|
||||
<p class="text-muted-foreground">{apiKeyResponse.apiKey.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2 mt-4 font-medium">API Key</div>
|
||||
<div class="bg-muted rounded-md p-2">
|
||||
<CopyToClipboard value={apiKeyResponse.token}>
|
||||
<span class="break-all font-mono text-sm">{apiKeyResponse.token}</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Dialog.Footer class="mt-3">
|
||||
<Button variant="default" on:click={() => onOpenChange(false)}>Close</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { ApiKeyCreate } from '$lib/types/api-key.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
|
||||
let {
|
||||
callback
|
||||
}: {
|
||||
callback: (apiKey: ApiKeyCreate) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Set default expiration to 30 days from now
|
||||
const defaultExpiry = new Date();
|
||||
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
|
||||
|
||||
const apiKey = {
|
||||
name: '',
|
||||
description: '',
|
||||
expiresAt: defaultExpiry
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, 'Name must be at least 3 characters')
|
||||
.max(50, 'Name cannot exceed 50 characters'),
|
||||
description: z.string().default(''),
|
||||
expiresAt: z.date().min(new Date(), 'Expiration date must be in the future')
|
||||
});
|
||||
|
||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, apiKey);
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
|
||||
const apiKeyData: ApiKeyCreate = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
expiresAt: data.expiresAt
|
||||
};
|
||||
|
||||
isLoading = true;
|
||||
const success = await callback(apiKeyData);
|
||||
if (success) form.reset();
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||
<FormInput
|
||||
label="Name"
|
||||
bind:input={$inputs.name}
|
||||
description="Name to identify this API key."
|
||||
/>
|
||||
<FormInput
|
||||
label="Expires At"
|
||||
type="date"
|
||||
description="When this API key will expire."
|
||||
bind:input={$inputs.expiresAt}
|
||||
/>
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<FormInput
|
||||
label="Description"
|
||||
description="Optional description to help identify this key's purpose."
|
||||
bind:input={$inputs.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import ApiKeyService from '$lib/services/api-key-service';
|
||||
import type { ApiKey } from '$lib/types/api-key.type';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideBan } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
apiKeys: initialApiKeys
|
||||
}: {
|
||||
apiKeys: Paginated<ApiKey>;
|
||||
} = $props();
|
||||
|
||||
let apiKeys = $state<Paginated<ApiKey>>(initialApiKeys);
|
||||
let requestOptions: SearchPaginationSortRequest | undefined = $state({
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc'
|
||||
}
|
||||
});
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
// Update the local state whenever the prop changes
|
||||
$effect(() => {
|
||||
apiKeys = initialApiKeys;
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string | undefined) {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function revokeApiKey(apiKey: ApiKey) {
|
||||
openConfirmDialog({
|
||||
title: 'Revoke API Key',
|
||||
message: `Are you sure you want to revoke the API key "${apiKey.name}"? This will break any integrations using this key.`,
|
||||
confirm: {
|
||||
label: 'Revoke',
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await apiKeyService.revoke(apiKey.id);
|
||||
apiKeys = await apiKeyService.list(requestOptions);
|
||||
toast.success('API key revoked successfully');
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Initial load uses the server-side data
|
||||
apiKeys = initialApiKeys;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AdvancedTable
|
||||
items={apiKeys}
|
||||
{requestOptions}
|
||||
onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))}
|
||||
withoutSearch
|
||||
defaultSort={{ column: 'lastUsedAt', direction: 'desc' }}
|
||||
columns={[
|
||||
{ label: 'Name', sortColumn: 'name' },
|
||||
{ label: 'Description' },
|
||||
{ label: 'Expires At', sortColumn: 'expiresAt' },
|
||||
{ label: 'Last Used', sortColumn: 'lastUsedAt' },
|
||||
{ label: 'Actions', hidden: true }
|
||||
]}
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell>{item.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{item.description || '-'}</Table.Cell>
|
||||
<Table.Cell>{formatDate(item.expiresAt)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(item.lastUsedAt)}</Table.Cell>
|
||||
<Table.Cell class="flex justify-end">
|
||||
<Button on:click={() => revokeApiKey(item)} size="sm" variant="outline" aria-label="Revoke"
|
||||
><LucideBan class="h-3 w-3 text-red-500" /></Button
|
||||
>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
Reference in New Issue
Block a user