1
0
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:
Kyle Mendell
2025-03-11 14:16:42 -05:00
committed by GitHub
parent 74ba8390f4
commit 62915d863a
58 changed files with 2172 additions and 190 deletions

View File

@@ -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')!,

View File

@@ -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' }
];
}

View 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;
};

View 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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>