api: API key authentication with rotation and grace period
- Add ApiKey Prisma model (keyHash, expiresAt, revokedAt) - Protect /api/v1/* routes with Authorization: Bearer header check in hooks - create rotateApiKey() utility: normal (48h grace) and emergency (immediate) - Add Super-only /admin/api-keys page for key rotation with one-time reveal - Fix roleId check by using parent layout's DB-fetched adminUser - Add API Keys link to admin sidebar
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"keyPrefix" TEXT NOT NULL,
|
||||
"keyHash" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
|
||||
@@ -178,6 +178,15 @@ model LicenseKey {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
keyPrefix String
|
||||
keyHash String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
revokedAt DateTime?
|
||||
}
|
||||
|
||||
model SubscriptionLevel {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
||||
+22
-2
@@ -1,14 +1,34 @@
|
||||
import { auth } from '$lib/auth';
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
import { building } from '$app/environment';
|
||||
import { validateApiKey } from '$lib/server/api-key';
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
// Fetch current session from Better Auth
|
||||
if (event.url.pathname.startsWith('/api/v1/')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return new Response(JSON.stringify({ error: 'API key required' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const key = authHeader.slice(7);
|
||||
const record = await validateApiKey(key);
|
||||
if (!record) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
// Make session and user available on server
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
|
||||
@@ -82,6 +82,11 @@ export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategoryS
|
||||
*
|
||||
*/
|
||||
export type LicenseKey = Prisma.LicenseKeyModel
|
||||
/**
|
||||
* Model ApiKey
|
||||
*
|
||||
*/
|
||||
export type ApiKey = Prisma.ApiKeyModel
|
||||
/**
|
||||
* Model SubscriptionLevel
|
||||
*
|
||||
|
||||
@@ -106,6 +106,11 @@ export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategoryS
|
||||
*
|
||||
*/
|
||||
export type LicenseKey = Prisma.LicenseKeyModel
|
||||
/**
|
||||
* Model ApiKey
|
||||
*
|
||||
*/
|
||||
export type ApiKey = Prisma.ApiKeyModel
|
||||
/**
|
||||
* Model SubscriptionLevel
|
||||
*
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -397,6 +397,7 @@ export const ModelName = {
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
|
||||
LicenseKey: 'LicenseKey',
|
||||
ApiKey: 'ApiKey',
|
||||
SubscriptionLevel: 'SubscriptionLevel'
|
||||
} as const
|
||||
|
||||
@@ -413,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "proclamationCategorySubscriptionLevel" | "licenseKey" | "subscriptionLevel"
|
||||
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "proclamationCategorySubscriptionLevel" | "licenseKey" | "apiKey" | "subscriptionLevel"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -1379,6 +1380,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
ApiKey: {
|
||||
payload: Prisma.$ApiKeyPayload<ExtArgs>
|
||||
fields: Prisma.ApiKeyFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.ApiKeyFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.ApiKeyFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.ApiKeyFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.ApiKeyFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.ApiKeyFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.ApiKeyCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.ApiKeyCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.ApiKeyCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.ApiKeyDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.ApiKeyUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.ApiKeyDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.ApiKeyUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.ApiKeyUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.ApiKeyUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.ApiKeyAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateApiKey>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.ApiKeyGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ApiKeyGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.ApiKeyCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ApiKeyCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
SubscriptionLevel: {
|
||||
payload: Prisma.$SubscriptionLevelPayload<ExtArgs>
|
||||
fields: Prisma.SubscriptionLevelFieldRefs
|
||||
@@ -1648,6 +1723,18 @@ export const LicenseKeyScalarFieldEnum = {
|
||||
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
|
||||
|
||||
|
||||
export const ApiKeyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
keyPrefix: 'keyPrefix',
|
||||
keyHash: 'keyHash',
|
||||
createdAt: 'createdAt',
|
||||
expiresAt: 'expiresAt',
|
||||
revokedAt: 'revokedAt'
|
||||
} as const
|
||||
|
||||
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
|
||||
|
||||
|
||||
export const SubscriptionLevelScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
@@ -1872,6 +1959,7 @@ export type GlobalOmitConfig = {
|
||||
proclamationCategory?: Prisma.ProclamationCategoryOmit
|
||||
proclamationCategorySubscriptionLevel?: Prisma.ProclamationCategorySubscriptionLevelOmit
|
||||
licenseKey?: Prisma.LicenseKeyOmit
|
||||
apiKey?: Prisma.ApiKeyOmit
|
||||
subscriptionLevel?: Prisma.SubscriptionLevelOmit
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ModelName = {
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
|
||||
LicenseKey: 'LicenseKey',
|
||||
ApiKey: 'ApiKey',
|
||||
SubscriptionLevel: 'SubscriptionLevel'
|
||||
} as const
|
||||
|
||||
@@ -239,6 +240,18 @@ export const LicenseKeyScalarFieldEnum = {
|
||||
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
|
||||
|
||||
|
||||
export const ApiKeyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
keyPrefix: 'keyPrefix',
|
||||
keyHash: 'keyHash',
|
||||
createdAt: 'createdAt',
|
||||
expiresAt: 'expiresAt',
|
||||
revokedAt: 'revokedAt'
|
||||
} as const
|
||||
|
||||
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
|
||||
|
||||
|
||||
export const SubscriptionLevelScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
|
||||
@@ -21,5 +21,6 @@ export type * from './models/SolfeggioFrequency.ts'
|
||||
export type * from './models/ProclamationCategory.ts'
|
||||
export type * from './models/ProclamationCategorySubscriptionLevel.ts'
|
||||
export type * from './models/LicenseKey.ts'
|
||||
export type * from './models/ApiKey.ts'
|
||||
export type * from './models/SubscriptionLevel.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
export type ApiKeyRecord = {
|
||||
id: string;
|
||||
keyPrefix: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
};
|
||||
|
||||
export type GeneratedKey = {
|
||||
plaintext: string;
|
||||
prefix: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
export function generateApiKey(): GeneratedKey {
|
||||
const raw = randomBytes(32);
|
||||
const hex = raw.toString('hex');
|
||||
const plaintext = `sk_${hex}`;
|
||||
const prefix = plaintext.slice(0, 11);
|
||||
const hash = createHash('sha256').update(plaintext).digest('hex');
|
||||
return { plaintext, prefix, hash };
|
||||
}
|
||||
|
||||
export async function validateApiKey(key: string): Promise<ApiKeyRecord | null> {
|
||||
const hash = createHash('sha256').update(key).digest('hex');
|
||||
const record = await prisma.apiKey.findUnique({ where: { keyHash: hash } });
|
||||
if (!record) return null;
|
||||
if (record.revokedAt && record.revokedAt <= new Date()) return null;
|
||||
if (record.expiresAt && record.expiresAt <= new Date()) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function rotateApiKey(emergency = false): Promise<{
|
||||
plaintext: string;
|
||||
prefix: string;
|
||||
previousExpiresAt: Date | null;
|
||||
previousRevokedAt: Date | null;
|
||||
}> {
|
||||
const now = new Date();
|
||||
|
||||
const activeKeys = await prisma.apiKey.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
|
||||
}
|
||||
});
|
||||
|
||||
let previousExpiresAt: Date | null = null;
|
||||
let previousRevokedAt: Date | null = null;
|
||||
|
||||
if (emergency) {
|
||||
previousRevokedAt = now;
|
||||
for (const key of activeKeys) {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: key.id },
|
||||
data: { revokedAt: now }
|
||||
});
|
||||
}
|
||||
previousExpiresAt = null;
|
||||
} else {
|
||||
const graceEnd = new Date(now.getTime() + 48 * 60 * 60 * 1000);
|
||||
previousExpiresAt = graceEnd;
|
||||
for (const key of activeKeys) {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: key.id },
|
||||
data: { expiresAt: graceEnd }
|
||||
});
|
||||
}
|
||||
previousRevokedAt = null;
|
||||
}
|
||||
|
||||
const { plaintext, prefix, hash } = generateApiKey();
|
||||
await prisma.apiKey.create({
|
||||
data: { keyPrefix: prefix, keyHash: hash }
|
||||
});
|
||||
|
||||
return { plaintext, prefix, previousExpiresAt, previousRevokedAt };
|
||||
}
|
||||
@@ -55,6 +55,14 @@
|
||||
{model.displayName}
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
href="/admin/api-keys"
|
||||
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/api-keys')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
|
||||
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
|
||||
>
|
||||
API Keys
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import { rotateApiKey } from '$lib/server/api-key';
|
||||
import { ROLES } from '$lib/svelte-admin/roles';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||
const { adminUser } = await parent();
|
||||
if (adminUser.roleId > ROLES.SUPER) {
|
||||
throw error(403, 'Only Super admins can manage API keys');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const activeKey = await prisma.apiKey.findFirst({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
|
||||
},
|
||||
select: { keyPrefix: true, createdAt: true, expiresAt: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
const expiredCount = await prisma.apiKey.count({
|
||||
where: {
|
||||
OR: [{ revokedAt: { not: null, lte: now } }, { expiresAt: { not: null, lte: now } }]
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeKey: activeKey
|
||||
? {
|
||||
prefix: activeKey.keyPrefix,
|
||||
createdAt: activeKey.createdAt.toISOString(),
|
||||
expiresAt: activeKey.expiresAt?.toISOString() ?? null
|
||||
}
|
||||
: null,
|
||||
totalRotated: expiredCount
|
||||
};
|
||||
};
|
||||
|
||||
async function viewerRoleId(locals: App.Locals): Promise<number> {
|
||||
if (!locals.user?.id) return 4;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: locals.user.id },
|
||||
select: { roleId: true }
|
||||
});
|
||||
return user?.roleId ?? 4;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
rotate: async ({ locals }) => {
|
||||
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
|
||||
return { success: false, error: 'Not authorized' };
|
||||
}
|
||||
|
||||
const result = await rotateApiKey(false);
|
||||
return {
|
||||
success: true,
|
||||
newKey: result.plaintext,
|
||||
mode: 'normal' as const,
|
||||
expiresAt: result.previousExpiresAt?.toISOString() ?? null
|
||||
};
|
||||
},
|
||||
rotateEmergency: async ({ locals }) => {
|
||||
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
|
||||
return { success: false, error: 'Not authorized' };
|
||||
}
|
||||
|
||||
const result = await rotateApiKey(true);
|
||||
return { success: true, newKey: result.plaintext, mode: 'emergency' as const };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let newKey = $state('');
|
||||
let mode = $state<'normal' | 'emergency' | null>(null);
|
||||
let expiryDate = $state<string | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
|
||||
function handleRotate(emergency: boolean) {
|
||||
return ({ cancel }: any) => {
|
||||
const msg = emergency
|
||||
? 'EMERGENCY ROTATION: The current key will be revoked IMMEDIATELY. Continue?'
|
||||
: 'Rotate the API key? The current key will remain valid for 48 hours.';
|
||||
if (!confirm(msg)) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
return async ({ result }: any) => {
|
||||
if (result.type === 'success' && result.status === 200) {
|
||||
if (result.data?.newKey) {
|
||||
newKey = result.data.newKey;
|
||||
mode = result.data.mode ?? null;
|
||||
expiryDate = result.data.expiresAt ?? null;
|
||||
}
|
||||
} else {
|
||||
toastMessage = 'Rotation failed';
|
||||
toastType = 'error';
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard.writeText(newKey);
|
||||
toastMessage = 'Copied to clipboard';
|
||||
toastType = 'success';
|
||||
}
|
||||
|
||||
function dismissKey() {
|
||||
newKey = '';
|
||||
mode = null;
|
||||
expiryDate = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">API Key Management</h1>
|
||||
|
||||
{#if newKey}
|
||||
<div
|
||||
class="mb-6 border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<p class="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
|
||||
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
|
||||
</p>
|
||||
<p class="mb-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
This key will not be shown again. Copy it now.
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<code
|
||||
class="rounded border border-amber-300 bg-white px-3 py-2 font-mono text-sm break-all dark:border-amber-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{newKey}
|
||||
</code>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyKey}
|
||||
class="border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-600 dark:bg-gray-800 dark:text-amber-300 dark:hover:bg-amber-900/50"
|
||||
>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismissKey}
|
||||
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
{#if mode === 'normal' && expiryDate}
|
||||
<p class="mt-3 text-xs text-amber-700 dark:text-amber-400">
|
||||
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
|
||||
to give in-field devices time to update.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if toastMessage}
|
||||
<div
|
||||
class="mb-4 border px-4 py-2 text-sm {toastType === 'success'
|
||||
? 'border-green-300 bg-green-50 text-green-700'
|
||||
: 'border-red-300 bg-red-50 text-red-700'}"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-6 rounded border border-gray-200 p-4 dark:border-gray-700">
|
||||
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Current Key Status</h2>
|
||||
{#if data.activeKey}
|
||||
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
Prefix: <code class="font-mono font-medium text-gray-900 dark:text-gray-100"
|
||||
>{data.activeKey.prefix}</code
|
||||
>
|
||||
</div>
|
||||
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
|
||||
{#if data.activeKey.expiresAt}
|
||||
<div>Expires: {new Date(data.activeKey.expiresAt).toLocaleString()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No active API key. Generate one below.</p>
|
||||
{/if}
|
||||
{#if data.totalRotated > 0}
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{data.totalRotated} previous key(s) have been rotated.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-4 py-2 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Generate New API Key
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||
>
|
||||
Emergency Rotation
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user