refactor(api): overhaul API key storage and validation

Remove legacy `token` and `tokenHash` columns and replace them with a secure `tokenSecret` password field. Update the authentication logic to use `bcryptjs` for token verification, adjust validation helpers, and add usage statistics tracking. Introduce new UI components for the API key list page and a display button in the platform filter bar. Add a migration to drop the old columns and add the new column.

BREAKING CHANGE: the `token` and `tokenHash` columns are removed; `tokenSecret` is added and used for all API key operations. All existing API keys must be regenerated.
This commit is contained in:
Junaid
2025-08-18 19:27:49 -07:00
parent 3ecc8df593
commit 4ee1a74f18
13 changed files with 561 additions and 421 deletions
+51 -72
View File
@@ -3290,31 +3290,6 @@ var User = (0, import_core.list)({
// features/keystone/models/ApiKey.ts
var import_fields3 = require("@keystone-6/core/fields");
var import_core2 = require("@keystone-6/core");
// features/keystone/lib/crypto-utils.ts
function generateApiKeyTokenSync() {
const prefix = "osp_";
const randomString = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2) + Date.now().toString(36);
return `${prefix}${randomString}`;
}
function hashApiKeySync(key) {
let hash = 0;
if (key.length === 0) return hash.toString();
for (let i = 0; i < key.length; i++) {
const char = key.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
// features/keystone/models/ApiKey.ts
function generateApiKeyToken() {
return generateApiKeyTokenSync();
}
function hashApiKey(key) {
return hashApiKeySync(key);
}
var ApiKey = (0, import_core2.list)({
access: {
operation: {
@@ -3338,33 +3313,12 @@ var ApiKey = (0, import_core2.list)({
}
},
resolveInput: {
create: async ({ listKey: listKey2, operation, inputData, item, resolvedData, context }) => {
if (operation !== "create") {
throw new Error("This hook should only run for create operations");
}
const token = generateApiKeyToken();
const tokenHash = hashApiKey(token);
context._createdApiKeyToken = token;
create: async ({ resolvedData, context }) => {
return {
...resolvedData,
tokenHash,
tokenPreview: `${token.substring(0, 12)}...${token.substring(token.length - 4)}`,
token,
// Store the full token in the database field
user: resolvedData.user || (context.session?.itemId ? { connect: { id: context.session.itemId } } : void 0)
};
}
},
afterOperation: {
create: async ({ listKey: listKey2, operation, item, resolvedData, context }) => {
if (operation === "create" && context._createdApiKeyToken) {
return {
...item,
token: context._createdApiKeyToken
};
}
return item;
}
}
},
fields: {
@@ -3374,13 +3328,13 @@ var ApiKey = (0, import_core2.list)({
description: "A descriptive name for this API key (e.g. 'Production Bot', 'Analytics Dashboard')"
}
}),
tokenHash: (0, import_fields3.text)({
tokenSecret: (0, import_fields3.password)({
validation: { isRequired: true },
isIndexed: "unique",
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "hidden" },
listView: { fieldMode: "hidden" }
listView: { fieldMode: "hidden" },
description: "Secure API key token (hashed and never displayed)"
}
}),
tokenPreview: (0, import_fields3.text)({
@@ -3391,14 +3345,6 @@ var ApiKey = (0, import_core2.list)({
description: "Preview of the API key (actual key is hidden for security)"
}
}),
token: (0, import_fields3.text)({
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "hidden" },
listView: { fieldMode: "hidden" },
description: "Full API key token (only available during creation)"
}
}),
scopes: (0, import_fields3.json)({
defaultValue: [],
ui: {
@@ -7455,6 +7401,7 @@ async function sendPasswordResetEmail(resetToken, to, baseUrl) {
// features/keystone/index.ts
var import_iron = __toESM(require("@hapi/iron"));
var cookie = __toESM(require("cookie"));
var import_bcryptjs = __toESM(require("bcryptjs"));
var databaseURL = process.env.DATABASE_URL || "file:./keystone.db";
var sessionConfig = {
maxAge: 60 * 60 * 24 * 360,
@@ -7489,41 +7436,73 @@ function statelessSessions({
if (accessToken.startsWith("osp_")) {
console.log("\u{1F511} API KEY DETECTED, VALIDATING...");
try {
const tokenHash = hashApiKeySync(accessToken);
console.log("\u{1F511} TOKEN HASH:", tokenHash);
const apiKey = await context.sudo().query.ApiKey.findOne({
where: { tokenHash },
const apiKeys = await context.sudo().query.ApiKey.findMany({
where: { status: { equals: "active" } },
query: `
id
name
scopes
status
expiresAt
usageCount
tokenSecret { isSet }
user { id }
`
});
console.log("\u{1F511} API KEY FOUND:", JSON.stringify(apiKey, null, 2));
if (!apiKey) {
console.log("\u{1F511} API KEY NOT FOUND");
console.log("\u{1F511} CHECKING AGAINST", apiKeys.length, "ACTIVE API KEYS");
let matchingApiKey = null;
for (const apiKey of apiKeys) {
try {
if (!apiKey.tokenSecret?.isSet) continue;
const fullApiKey = await context.sudo().db.ApiKey.findOne({
where: { id: apiKey.id }
});
if (!fullApiKey || typeof fullApiKey.tokenSecret !== "string") {
continue;
}
const isValid = await import_bcryptjs.default.compare(accessToken, fullApiKey.tokenSecret);
if (isValid) {
matchingApiKey = apiKey;
console.log("\u{1F511} FOUND MATCHING API KEY:", apiKey.id);
break;
}
} catch (error) {
console.log("\u{1F511} ERROR VERIFYING API KEY:", error);
continue;
}
}
if (!matchingApiKey) {
console.log("\u{1F511} NO MATCHING API KEY FOUND");
return;
}
if (apiKey.status !== "active") {
console.log("\u{1F511} API KEY NOT ACTIVE:", apiKey.status);
if (matchingApiKey.status !== "active") {
console.log("\u{1F511} API KEY NOT ACTIVE:", matchingApiKey.status);
return;
}
if (apiKey.expiresAt && /* @__PURE__ */ new Date() > new Date(apiKey.expiresAt)) {
if (matchingApiKey.expiresAt && /* @__PURE__ */ new Date() > new Date(matchingApiKey.expiresAt)) {
console.log("\u{1F511} API KEY EXPIRED");
await context.sudo().query.ApiKey.updateOne({
where: { id: apiKey.id },
where: { id: matchingApiKey.id },
data: { status: "revoked" }
});
return;
}
if (apiKey.user?.id) {
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
const usage = matchingApiKey.usageCount || { total: 0, daily: {} };
usage.total = (usage.total || 0) + 1;
usage.daily[today] = (usage.daily[today] || 0) + 1;
context.sudo().query.ApiKey.updateOne({
where: { id: matchingApiKey.id },
data: {
lastUsedAt: /* @__PURE__ */ new Date(),
usageCount: usage
}
}).catch(console.error);
if (matchingApiKey.user?.id) {
const session = {
itemId: apiKey.user.id,
itemId: matchingApiKey.user.id,
listKey,
apiKeyScopes: apiKey.scopes || []
apiKeyScopes: matchingApiKey.scopes || []
// Attach scopes for permission checking
};
console.log("\u{1F511} RETURNING SESSION:", JSON.stringify(session, null, 2));
File diff suppressed because one or more lines are too long
@@ -17,7 +17,7 @@ import type {
// Types matching Keystone exactly
export type AdminMultiSelectFieldMeta = {
options: readonly { label: string; value: string | number }[]
options: { label: string; value: string | number }[]
type: 'string' | 'integer' | 'enum'
defaultValue: string[] | number[]
}
@@ -128,7 +128,7 @@ export function CardValue({ item, field }: CellProps) {
export const controller = (
config: Config
): FieldController<Value, KeystoneOption[]> & {
): FieldController<Value, string[]> & {
options: KeystoneOption[]
type: 'string' | 'integer' | 'enum'
valuesToOptionsWithStringValues: Record<string, KeystoneOption>
@@ -164,7 +164,7 @@ export const controller = (
serialize: value => ({ [config.path]: value.map(x => parseValue(x.value)) }),
validate: () => true,
filter: {
Filter: ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => {
Filter: ({ value, onChange, type }: any) => {
const selectedValues = new Set(value || [])
return (
+62 -16
View File
@@ -8,6 +8,7 @@ import { sendPasswordResetEmail } from "./lib/mail";
import Iron from "@hapi/iron";
import * as cookie from "cookie";
import { hashApiKeySync } from "./lib/crypto-utils";
import bcryptjs from "bcryptjs";
const databaseURL = process.env.DATABASE_URL || "file:./keystone.db";
@@ -61,49 +62,94 @@ export function statelessSessions({
if (accessToken.startsWith("osp_")) {
console.log('🔑 API KEY DETECTED, VALIDATING...');
try {
const tokenHash = hashApiKeySync(accessToken);
console.log('🔑 TOKEN HASH:', tokenHash);
const apiKey = await context.sudo().query.ApiKey.findOne({
where: { tokenHash },
// Get all active API keys and test the token against each one
const apiKeys = await context.sudo().query.ApiKey.findMany({
where: { status: { equals: 'active' } },
query: `
id
name
scopes
status
expiresAt
usageCount
tokenSecret { isSet }
user { id }
`,
});
console.log('🔑 API KEY FOUND:', JSON.stringify(apiKey, null, 2));
console.log('🔑 CHECKING AGAINST', apiKeys.length, 'ACTIVE API KEYS');
if (!apiKey) {
console.log('🔑 API KEY NOT FOUND');
return; // API key not found
let matchingApiKey = null;
// Test token against each API key using bcryptjs (same as Keystone's default KDF)
for (const apiKey of apiKeys) {
try {
if (!apiKey.tokenSecret?.isSet) continue;
// Get the full API key item with the tokenSecret value
const fullApiKey = await context.sudo().db.ApiKey.findOne({
where: { id: apiKey.id },
});
if (!fullApiKey || typeof fullApiKey.tokenSecret !== 'string') {
continue;
}
// Use bcryptjs to compare - this is exactly what Keystone does internally
const isValid = await bcryptjs.compare(accessToken, fullApiKey.tokenSecret);
if (isValid) {
matchingApiKey = apiKey;
console.log('🔑 FOUND MATCHING API KEY:', apiKey.id);
break;
}
} catch (error) {
console.log('🔑 ERROR VERIFYING API KEY:', error);
// Continue to next API key if this one doesn't match
continue;
}
}
if (apiKey.status !== 'active') {
console.log('🔑 API KEY NOT ACTIVE:', apiKey.status);
if (!matchingApiKey) {
console.log('🔑 NO MATCHING API KEY FOUND');
return; // API key not found or invalid
}
if (matchingApiKey.status !== 'active') {
console.log('🔑 API KEY NOT ACTIVE:', matchingApiKey.status);
return; // API key is inactive
}
if (apiKey.expiresAt && new Date() > new Date(apiKey.expiresAt)) {
if (matchingApiKey.expiresAt && new Date() > new Date(matchingApiKey.expiresAt)) {
console.log('🔑 API KEY EXPIRED');
// Auto-revoke expired keys
await context.sudo().query.ApiKey.updateOne({
where: { id: apiKey.id },
where: { id: matchingApiKey.id },
data: { status: 'revoked' },
});
return; // API key has expired
}
// Update usage statistics (async, don't wait)
const today = new Date().toISOString().split('T')[0];
const usage = matchingApiKey.usageCount || { total: 0, daily: {} };
usage.total = (usage.total || 0) + 1;
usage.daily[today] = (usage.daily[today] || 0) + 1;
context.sudo().query.ApiKey.updateOne({
where: { id: matchingApiKey.id },
data: {
lastUsedAt: new Date(),
usageCount: usage,
},
}).catch(console.error);
// Return user session with API key scopes attached
if (apiKey.user?.id) {
if (matchingApiKey.user?.id) {
const session = {
itemId: apiKey.user.id,
itemId: matchingApiKey.user.id,
listKey,
apiKeyScopes: apiKey.scopes || [] // Attach scopes for permission checking
apiKeyScopes: matchingApiKey.scopes || [] // Attach scopes for permission checking
};
console.log('🔑 RETURNING SESSION:', JSON.stringify(session, null, 2));
return session;
+42 -114
View File
@@ -1,5 +1,6 @@
import {
text,
password,
relationship,
multiselect,
select,
@@ -88,40 +89,14 @@ export const ApiKey = list({
},
},
resolveInput: {
create: async ({ listKey, operation, inputData, item, resolvedData, context }) => {
// Note: item is undefined for create operations per KeystoneJS docs
if (operation !== 'create') {
throw new Error('This hook should only run for create operations');
}
// Generate secure token and hash it
const token = generateApiKeyToken();
const tokenHash = hashApiKey(token);
// Store the token in context so we can return it in the mutation
(context as any)._createdApiKeyToken = token;
create: async ({ resolvedData, context }) => {
// Auto-assign user relationship
return {
...resolvedData,
tokenHash,
tokenPreview: `${token.substring(0, 12)}...${token.substring(token.length - 4)}`,
token: token, // Store the full token in the database field
user: resolvedData.user || (context.session?.itemId ? { connect: { id: context.session.itemId } } : undefined),
};
},
},
afterOperation: {
create: async ({ listKey, operation, item, resolvedData, context }) => {
// Add the real token to the returned item so GraphQL can access it
if (operation === 'create' && (context as any)._createdApiKeyToken) {
return {
...item,
token: (context as any)._createdApiKeyToken
};
}
return item;
},
},
},
fields: {
name: text({
@@ -131,13 +106,13 @@ export const ApiKey = list({
},
}),
tokenHash: text({
tokenSecret: password({
validation: { isRequired: true },
isIndexed: "unique",
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "hidden" },
listView: { fieldMode: "hidden" },
description: "Secure API key token (hashed and never displayed)",
},
}),
@@ -150,15 +125,6 @@ export const ApiKey = list({
},
}),
token: text({
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "hidden" },
listView: { fieldMode: "hidden" },
description: "Full API key token (only available during creation)",
},
}),
scopes: json({
defaultValue: [],
ui: {
@@ -229,7 +195,29 @@ export const ApiKey = list({
},
});
// Helper functions for API key validation
// Helper function to validate API key tokens using password field
export async function validateApiKeyToken(
apiKeyId: string,
token: string,
context: any
): Promise<boolean> {
try {
// Use Keystone's built-in password verification through createAuth
// This is a simplified approach - we'll handle this in the authentication layer
const result = await context.query.ApiKey.authenticateItemWithPassword({
identifier: apiKeyId,
secret: token,
identityField: 'id',
secretField: 'tokenSecret'
});
return result.success;
} catch (error) {
return false;
}
}
// Simplified validation function for API keys
export async function validateApiKey(
token: string,
requiredScopes: ApiKeyScope[] = [],
@@ -244,70 +232,10 @@ export async function validateApiKey(
return { valid: false, error: 'Invalid API key format' };
}
const tokenHash = hashApiKeySync(token);
const apiKey = await context.query.ApiKey.findOne({
where: { tokenHash },
query: `
id
name
scopes
status
expiresAt
usageCount
restrictedToIPs
user { id name email }
`,
});
if (!apiKey) {
return { valid: false, error: 'API key not found' };
}
if (apiKey.status !== 'active') {
return { valid: false, error: `API key is ${apiKey.status}` };
}
if (apiKey.expiresAt && new Date() > new Date(apiKey.expiresAt)) {
// Auto-revoke expired keys
await context.query.ApiKey.updateOne({
where: { id: apiKey.id },
data: { status: 'revoked' },
});
return { valid: false, error: 'API key has expired' };
}
// Check if key has required scopes
const keyScopes = apiKey.scopes || [];
const missingScopes = requiredScopes.filter(scope => !keyScopes.includes(scope));
if (missingScopes.length > 0) {
return {
valid: false,
error: `Missing required scopes: ${missingScopes.join(', ')}`
};
}
// Update usage statistics
const today = new Date().toISOString().split('T')[0];
const usage = apiKey.usageCount || { total: 0, daily: {} };
usage.total = (usage.total || 0) + 1;
usage.daily[today] = (usage.daily[today] || 0) + 1;
// Update last used and usage count (async, don't wait)
context.query.ApiKey.updateOne({
where: { id: apiKey.id },
data: {
lastUsedAt: new Date(),
usageCount: usage,
},
}).catch(console.error);
return {
valid: true,
user: apiKey.user,
scopes: keyScopes,
};
// This will be handled differently - we'll need to update the keystone/index.ts
// authentication logic to use the password field directly
// For now, return a placeholder that will be updated in the auth layer
return { valid: false, error: 'API key validation moved to auth layer' };
}
// Scope validation helper
@@ -327,40 +255,40 @@ export function getPermissionsForScopes(scopes: ApiKeyScope[]): string[] {
scopes.forEach(scope => {
switch (scope) {
case 'orders:read':
case 'read_orders':
permissions.add('canReadOrders');
break;
case 'orders:write':
case 'write_orders':
permissions.add('canReadOrders');
permissions.add('canManageOrders');
permissions.add('canProcessOrders');
break;
case 'products:read':
case 'read_products':
// Add product read permissions
break;
case 'products:write':
case 'write_products':
// Add product write permissions
break;
case 'shops:read':
case 'read_shops':
permissions.add('canReadShops');
break;
case 'shops:write':
case 'write_shops':
permissions.add('canReadShops');
permissions.add('canManageShops');
permissions.add('canCreateShops');
break;
case 'channels:read':
case 'read_channels':
permissions.add('canReadChannels');
break;
case 'channels:write':
case 'write_channels':
permissions.add('canReadChannels');
permissions.add('canManageChannels');
permissions.add('canCreateChannels');
break;
case 'users:read':
case 'read_users':
permissions.add('canReadUsers');
break;
case 'users:write':
case 'write_users':
permissions.add('canReadUsers');
permissions.add('canManageUsers');
permissions.add('canManageRoles');
@@ -120,6 +120,7 @@ export async function createApiKey(data: {
name: string;
scopes: string[];
expiresAt?: string;
tokenSecret: string;
}) {
const query = `
mutation CreateApiKey($data: ApiKeyCreateInput!) {
@@ -135,7 +136,6 @@ export async function createApiKey(data: {
restrictedToIPs
createdAt
updatedAt
token
user {
id
name
@@ -145,10 +145,15 @@ export async function createApiKey(data: {
}
`;
// Create preview from the token (first 8 chars + "...")
const tokenPreview = data.tokenSecret.substring(0, 12) + "...";
const apiKeyData = {
name: data.name,
scopes: data.scopes,
expiresAt: data.expiresAt,
tokenSecret: data.tokenSecret,
tokenPreview,
};
try {
@@ -0,0 +1,191 @@
/**
* ApiKeyListPageClient - Client Component for API Keys Platform Page
* Based on dashboard ListPageClient but with platform-specific layout and PlatformFilterBar
*/
'use client'
import React, { useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
Triangle,
Square,
Circle,
Search
} from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { EmptyState } from '@/components/ui/empty-state'
import { PageBreadcrumbs } from "@/features/dashboard/components/PageBreadcrumbs"
import { PlatformFilterBar } from '@/features/platform/components/PlatformFilterBar'
import { ListTable } from '@/features/dashboard/components/ListTable'
import { useSelectedFields } from '@/features/dashboard/hooks/useSelectedFields'
import { CreateApiKey } from './CreateApiKey'
interface ApiKeyListPageClientProps {
list: any
initialData: { items: any[], count: number }
initialError: string | null
initialSearchParams: {
page: number
pageSize: number
search: string
}
}
function EmptyStateDefault({ list }: { list: any }) {
return (
<EmptyState
title={`No ${list.label} Created`}
description={`You can create a new ${list.singular.toLowerCase()} to get started.`}
icons={[Triangle, Square, Circle]}
/>
)
}
function EmptyStateSearch({ onResetFilters }: { onResetFilters: () => void }) {
return (
<EmptyState
title="No Results Found"
description="Try adjusting your search filters."
icons={[Search]}
action={{
label: "Reset Filters",
onClick: onResetFilters
}}
/>
)
}
export function ApiKeyListPageClient({
list,
initialData,
initialError,
initialSearchParams
}: ApiKeyListPageClientProps) {
const router = useRouter()
// Hooks for field selection
const selectedFields = useSelectedFields(list)
// Extract data from props
const data = initialData
const error = initialError
const currentPage = initialSearchParams.page
const pageSize = initialSearchParams.pageSize
const searchString = initialSearchParams.search
// Handle reset filters
const handleResetFilters = useCallback(() => {
router.push(window.location.pathname)
}, [router])
if (!list) {
return (
<section
aria-label="API Keys overview"
className="overflow-hidden flex flex-col"
>
<Alert variant="destructive">
<AlertDescription>
The requested list was not found.
</AlertDescription>
</Alert>
</section>
)
}
// Check if we have any active filters (search or actual filters)
const hasFilters = !!searchString
const isFiltered = hasFilters
const isEmpty = data?.count === 0 && !isFiltered
return (
<section
aria-label="API Keys overview"
className="overflow-hidden flex flex-col"
>
<PageBreadcrumbs
items={[
{
type: "link",
label: "Dashboard",
href: "/",
},
{
type: "page",
label: "Platform",
},
{
type: "page",
label: "API Keys",
},
]}
/>
<div className="flex flex-col flex-1 min-h-0">
<div className="border-gray-200 dark:border-gray-800">
<div className="px-4 md:px-6 pt-4 md:pt-6 pb-4">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
API Keys
</h1>
<p className="text-muted-foreground">
<span>Create and manage secure API keys for programmatic access</span>
</p>
</div>
</div>
{/* Platform Filter Bar with custom create button */}
<div className="px-4 md:px-6">
<PlatformFilterBar
list={{
key: list.key,
path: list.path,
label: list.label,
singular: list.singular,
plural: list.plural,
description: list.description || undefined,
labelField: list.labelField as string,
initialColumns: list.initialColumns,
groups: list.groups as unknown as string[],
graphql: {
plural: list.plural,
singular: list.singular
},
fields: list.fields
}}
customCreateButton={<CreateApiKey />}
showDisplayButton={true}
selectedFields={selectedFields}
/>
</div>
{/* Data table using dashboard ListTable component */}
{error ? (
<div className="px-4 md:px-6">
<Alert variant="destructive">
<AlertDescription>
Failed to load items: {error}
</AlertDescription>
</Alert>
</div>
) : isEmpty ? (
<div className="px-4 md:px-6">
<EmptyStateDefault list={list} />
</div>
) : data?.count === 0 ? (
<div className="px-4 md:px-6">
<EmptyStateSearch onResetFilters={handleResetFilters} />
</div>
) : (
<ListTable
data={data}
list={list}
selectedFields={selectedFields}
currentPage={currentPage}
pageSize={pageSize}
/>
)}
</div>
</section>
)
}
@@ -20,6 +20,23 @@ import { createApiKey } from "../actions/getApiKeys";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
// Client-side token generation
function generateApiKeyToken(): string {
// Generate a secure API key token in the browser
const prefix = 'osp_';
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
// Convert to base62 (alphanumeric) for readability
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < randomBytes.length; i++) {
result += chars[randomBytes[i] % chars.length];
}
return prefix + result;
}
// Define API key scopes as options for MultipleSelector
const scopeOptions: Option[] = [
{ value: "read_orders", label: "read_orders" },
@@ -79,14 +96,19 @@ export function CreateApiKey() {
setLoading(true);
try {
// Generate token client-side
const generatedToken = generateApiKeyToken();
const result = await createApiKey({
name: formData.name,
scopes: formData.scopes.map(scope => scope.value),
expiresAt: formData.expiresAt || undefined,
tokenSecret: generatedToken, // Pass the generated token to be hashed
});
if (result.success && result.data) {
setCreatedToken(result.data.token || "osp_sample_token_for_demo");
if (result.success) {
// Show the generated token (this is the only time it will be visible)
setCreatedToken(generatedToken);
setShowToken(true);
toast.success("API key created successfully!");
router.refresh();
@@ -124,11 +146,12 @@ export function CreateApiKey() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="h-8 gap-1">
<Plus className="h-3.5 w-3.5" />
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
Create API Key
</span>
<Button
size="icon"
className="lg:px-4 lg:py-2 lg:w-auto rounded-lg"
>
<Plus className="h-4 w-4" />
<span className="hidden lg:inline">Create API Key</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[525px]">
@@ -155,16 +178,6 @@ export function CreateApiKey() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="expiresAt">Expiration (optional)</Label>
<Input
id="expiresAt"
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData(prev => ({ ...prev, expiresAt: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label>
Scopes <span className="text-red-500">*</span>
@@ -180,11 +193,22 @@ export function CreateApiKey() {
hidePlaceholderWhenSelected={true}
emptyIndicator={<p className="text-center text-sm">No scopes found</p>}
onChange={handleScopeChange}
className="text-base"
/>
<p className="text-muted-foreground mt-2 text-xs">
Select the minimum permissions needed for this API key
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="expiresAt">Expiration (optional)</Label>
<Input
id="expiresAt"
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData(prev => ({ ...prev, expiresAt: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
@@ -1,192 +1,136 @@
import { getListByPath } from "@/features/dashboard/actions";
import { getApiKeys } from "../actions/getApiKeys";
import { PageBreadcrumbs } from "@/features/dashboard/components/PageBreadcrumbs";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Circle, Square, Triangle } from "lucide-react";
import { CreateApiKey } from "../components/CreateApiKey";
import { Pagination } from "@/features/dashboard/components/Pagination";
import ApiKeyTable from "../components/ApiKeyTable";
// Define ApiKey type
interface ApiKey {
id: string;
name: string;
tokenPreview: string;
scopes: string[];
status: string;
expiresAt?: string;
lastUsedAt?: string;
usageCount?: { total: number; daily: Record<string, number> };
restrictedToIPs?: string[];
createdAt: string;
updatedAt?: string;
user?: {
id: string;
name?: string;
email: string;
};
}
import { getListByPath, getAdminMetaAction } from "@/features/dashboard/actions";
import { getListItemsAction } from "@/features/dashboard/actions/getListItemsAction";
import { buildOrderByClause } from "@/features/dashboard/lib/buildOrderByClause";
import { buildWhereClause } from "@/features/dashboard/lib/buildWhereClause";
import { ApiKeyListPageClient } from "../components/ApiKeyListPageClient";
interface PageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
function ErrorDisplay({ title, message }: { title: string; message: string }) {
return (
<div className="px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold tracking-tight text-red-600">
{title}
</h1>
<p className="mt-2 text-gray-600">{message}</p>
</div>
);
}
export async function ApiKeyListPage({ searchParams }: PageProps) {
const resolvedSearchParams = await searchParams;
const searchParamsObj = Object.fromEntries(
Object.entries(resolvedSearchParams).map(([key, value]) => [
key,
Array.isArray(value) ? value : value?.toString(),
])
);
// Parse search parameters
const page = Number(resolvedSearchParams.page) || 1;
const pageSize = Number(resolvedSearchParams.pageSize) || 10;
const search = typeof resolvedSearchParams.search === "string" && resolvedSearchParams.search !== "" ? resolvedSearchParams.search : null;
// Get sort from URL
const sortBy = resolvedSearchParams.sortBy as string | undefined;
const sort = sortBy ? {
field: sortBy.startsWith("-") ? sortBy.slice(1) : sortBy,
direction: (sortBy.startsWith("-") ? "DESC" : "ASC") as "ASC" | "DESC"
} : null;
try {
// Get list metadata
const list = await getListByPath("api-keys");
if (!list) {
return (
<ErrorDisplay
title="Invalid List"
message="The requested list could not be found."
/>
);
}
// Fetch API keys
const [response] = await Promise.all([
getApiKeys(
{},
pageSize,
(page - 1) * pageSize,
sort ? [{ [sort.field]: sort.direction.toLowerCase() }] : [{ createdAt: 'desc' }]
)
]);
let apiKeys: ApiKey[] = [];
let count = 0;
if (response.success) {
apiKeys = response.data?.items || [];
count = response.data?.count || 0;
} else {
console.error("Error fetching API keys:", response.error);
}
// Get list metadata
const list = await getListByPath("api-keys");
if (!list) {
return (
<section
aria-label="API Keys overview"
className="overflow-hidden flex flex-col"
>
<PageBreadcrumbs
items={[
{
type: "link",
label: "Dashboard",
href: "/",
},
{
type: "page",
label: "Platform",
},
{
type: "page",
label: "API Keys",
},
]}
/>
<div className="flex flex-col flex-1 min-h-0">
<div className="border-gray-200 dark:border-gray-800">
<div className="px-4 md:px-6 pt-4 md:pt-6 pb-4">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
API Keys
</h1>
<p className="text-muted-foreground">
<span>Create and manage secure API keys for programmatic access</span>
</p>
</div>
</div>
<div className="px-4 md:px-6">
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
{/* Search and filters for API keys */}
</div>
<CreateApiKey />
</div>
</div>
<div className="flex-1 overflow-auto p-4">
{apiKeys && apiKeys.length > 0 ? (
<ApiKeyTable data={apiKeys} />
) : (
<div className="flex items-center justify-center h-full py-10">
<div className="text-center">
<div className="relative h-11 w-11 mx-auto mb-2">
<Triangle className="absolute left-1 top-1 w-4 h-4 fill-indigo-200 stroke-indigo-400 dark:stroke-indigo-600 dark:fill-indigo-950 rotate-[90deg]" />
<Square className="absolute right-[.2rem] top-1 w-4 h-4 fill-orange-300 stroke-orange-500 dark:stroke-amber-600 dark:fill-amber-950 rotate-[30deg]" />
<Circle className="absolute bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 fill-emerald-200 stroke-emerald-400 dark:stroke-emerald-600 dark:fill-emerald-900" />
</div>
<p className="font-medium">No API keys found</p>
<p className="text-muted-foreground text-sm">
{search !== null
? "Try adjusting your search criteria"
: "Create your first API key to get started"}
</p>
{search !== null && (
<Link href="/dashboard/platform/api-keys">
<Button variant="outline" className="mt-4" size="sm">
Clear filters
</Button>
</Link>
)}
</div>
</div>
)}
</div>
</div>
{/* Pagination */}
{apiKeys && apiKeys.length > 0 && (
<Pagination
currentPage={page}
total={count}
pageSize={pageSize}
list={{
singular: "API Key",
plural: "API Keys"
}}
/>
)}
</section>
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return (
<ErrorDisplay
title="Error Loading API Keys"
message={`There was an error loading API keys: ${errorMessage}`}
/>
<div className="px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold tracking-tight text-red-600">
Invalid List
</h1>
<p className="mt-2 text-gray-600">The requested list could not be found.</p>
</div>
);
}
// Parse search params (same as dashboard ListPage)
const currentPage = parseInt(searchParamsObj.page?.toString() || '1', 10) || 1
const pageSize = parseInt(searchParamsObj.pageSize?.toString() || list.pageSize?.toString() || '50', 10)
const searchString = searchParamsObj.search?.toString() || ''
// Build dynamic orderBy clause using Keystone's defaults
const orderBy = buildOrderByClause(list, searchParamsObj)
// Build filters from URL params using Keystone's approach
const filterWhere = buildWhereClause(list, searchParamsObj)
// Build search where clause
const searchParameters = searchString ? { search: searchString } : {}
const searchWhere = buildWhereClause(list, searchParameters)
// Combine search and filters - following Keystone's pattern
const whereConditions = []
if (Object.keys(searchWhere).length > 0) {
whereConditions.push(searchWhere)
}
if (Object.keys(filterWhere).length > 0) {
whereConditions.push(filterWhere)
}
const where = whereConditions.length > 0 ? { AND: whereConditions } : {}
// Build GraphQL variables
const variables = {
where,
take: pageSize,
skip: (currentPage - 1) * pageSize,
orderBy
}
// Build selected fields set from URL params or default to initial columns
let selectedFields = ['id'] // Always include ID
if (searchParamsObj.fields) {
// Use fields from URL params
const fieldsFromUrl = searchParamsObj.fields.toString().split(',').filter(field => {
return field in (list.fields || {})
})
selectedFields = [...selectedFields, ...fieldsFromUrl]
} else {
// Use initial columns or fallback to basic fields
if (list.initialColumns && list.initialColumns.length > 0) {
selectedFields = [...selectedFields, ...list.initialColumns]
} else if (list.fields) {
// Fallback for lists without initialColumns
Object.keys(list.fields).forEach(fieldKey => {
if (['name', 'title', 'label', 'createdAt', 'updatedAt'].includes(fieldKey)) {
selectedFields.push(fieldKey)
}
})
}
}
// Remove duplicates
selectedFields = [...new Set(selectedFields)]
// Fetch list items data with cache options
const cacheOptions = {
next: {
tags: [`list-${list.key}`],
revalidate: 300, // 5 minutes
},
}
// Use the dashboard action for list items data
const response = await getListItemsAction("api-keys", variables, selectedFields, cacheOptions)
let fetchedData: { items: any[], count: number } = { items: [], count: 0 }
let error: string | null = null
if (response.success) {
fetchedData = response.data
} else {
console.error('Error fetching list items:', response.error)
error = response.error
}
// Get adminMeta for the list structure
const adminMetaResponse = await getAdminMetaAction(list.key)
// Extract the list with proper field metadata if successful
const adminMetaList = adminMetaResponse.success ? adminMetaResponse.data.list : null
// Create enhanced list with validation data
const enhancedList = adminMetaList || list
return (
<ApiKeyListPageClient
list={enhancedList}
initialData={fetchedData}
initialError={error}
initialSearchParams={{
page: currentPage,
pageSize,
search: searchString
}}
/>
);
}
@@ -8,11 +8,13 @@ import {
Search,
SlidersHorizontal,
ArrowUpDown,
Columns3,
CirclePlus,
} from "lucide-react"
import { FilterAdd } from "../../dashboard/components/FilterAdd"
import { FilterList } from "../../dashboard/components/FilterList"
import { SortSelection } from "../../dashboard/components/SortSelection"
import { FieldSelection } from "../../dashboard/components/FieldSelection"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import Link from "next/link"
@@ -22,9 +24,11 @@ import { enhanceFields } from '../../dashboard/utils/enhanceFields'
interface PlatformFilterBarProps {
list: any
customCreateButton?: React.ReactNode
showDisplayButton?: boolean
selectedFields?: Set<string>
}
export function PlatformFilterBar({ list, customCreateButton }: PlatformFilterBarProps) {
export function PlatformFilterBar({ list, customCreateButton, showDisplayButton = false, selectedFields = new Set() }: PlatformFilterBarProps) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
@@ -128,6 +132,19 @@ export function PlatformFilterBar({ list, customCreateButton }: PlatformFilterBa
</Button>
</SortSelection>
{showDisplayButton && (
<FieldSelection listMeta={list} selectedFields={selectedFields}>
<Button
variant="outline"
size="icon"
className="lg:px-4 lg:py-2 lg:w-auto rounded-lg"
>
<Columns3 className="stroke-muted-foreground" />
<span className="hidden lg:inline">Display</span>
</Button>
</FieldSelection>
)}
{!list.hideCreate && (
customCreateButton || (
<Link
@@ -0,0 +1,15 @@
/*
Warnings:
- You are about to drop the column `token` on the `ApiKey` table. All the data in the column will be lost.
- You are about to drop the column `tokenHash` on the `ApiKey` table. All the data in the column will be lost.
- Added the required column `tokenSecret` to the `ApiKey` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "ApiKey_tokenHash_key";
-- AlterTable
ALTER TABLE "ApiKey" DROP COLUMN "token",
DROP COLUMN "tokenHash",
ADD COLUMN "tokenSecret" TEXT NOT NULL;
+3 -11
View File
@@ -663,9 +663,8 @@ input UserRelateToManyForCreateInput {
type ApiKey {
id: ID!
name: String
tokenHash: String
tokenSecret: PasswordState
tokenPreview: String
token: String
scopes: JSON
status: ApiKeyStatusType
expiresAt: DateTime
@@ -685,7 +684,6 @@ enum ApiKeyStatusType {
input ApiKeyWhereUniqueInput {
id: ID
tokenHash: String
}
input ApiKeyWhereInput {
@@ -694,9 +692,7 @@ input ApiKeyWhereInput {
NOT: [ApiKeyWhereInput!]
id: IDFilter
name: StringFilter
tokenHash: StringFilter
tokenPreview: StringFilter
token: StringFilter
status: ApiKeyStatusTypeNullableFilter
expiresAt: DateTimeNullableFilter
lastUsedAt: DateTimeNullableFilter
@@ -715,9 +711,7 @@ input ApiKeyStatusTypeNullableFilter {
input ApiKeyOrderByInput {
id: OrderDirection
name: OrderDirection
tokenHash: OrderDirection
tokenPreview: OrderDirection
token: OrderDirection
status: OrderDirection
expiresAt: OrderDirection
lastUsedAt: OrderDirection
@@ -727,9 +721,8 @@ input ApiKeyOrderByInput {
input ApiKeyUpdateInput {
name: String
tokenHash: String
tokenSecret: String
tokenPreview: String
token: String
scopes: JSON
status: ApiKeyStatusType
expiresAt: DateTime
@@ -754,9 +747,8 @@ input ApiKeyUpdateArgs {
input ApiKeyCreateInput {
name: String
tokenHash: String
tokenSecret: String
tokenPreview: String
token: String
scopes: JSON
status: ApiKeyStatusType
expiresAt: DateTime
+1 -2
View File
@@ -76,9 +76,8 @@ model Role {
model ApiKey {
id String @id @default(cuid())
name String @default("")
tokenHash String @unique @default("")
tokenSecret String
tokenPreview String @default("")
token String @default("")
scopes Json? @default("[]")
status ApiKeyStatusType? @default(active)
expiresAt DateTime?