mirror of
https://github.com/openshiporg/openship.git
synced 2026-06-19 07:35:55 +00:00
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:
+51
-72
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user