diff --git a/apps/api-service/src/routes/admin/keys.ts b/apps/api-service/src/routes/admin/keys.ts index a512a96..1a43bd5 100644 --- a/apps/api-service/src/routes/admin/keys.ts +++ b/apps/api-service/src/routes/admin/keys.ts @@ -112,20 +112,25 @@ router.get('/', async (req, res) => { try { const keys = await apiKeyService.listKeys(); - // Don't expose key hashes + // Format response with nested objects, ISO dates, and no sensitive data const safeKeys = keys.map((key) => ({ id: key.id, type: key.keyType, - projectId: key.projectId, name: key.name, scopes: key.scopes, isActive: key.isActive, - createdAt: key.createdAt, - expiresAt: key.expiresAt, - lastUsedAt: key.lastUsedAt, + createdAt: key.createdAt.toISOString(), + expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null, + lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null, createdBy: key.createdBy, + organization: key.organization, + project: key.project, })); + console.log( + `[${new Date().toISOString()}] API keys listed by admin: ${req.apiKey!.id} - returned ${safeKeys.length} keys`, + ); + res.json({ keys: safeKeys, total: safeKeys.length, diff --git a/apps/api-service/src/services/ApiKeyService.ts b/apps/api-service/src/services/ApiKeyService.ts index 395632c..c97eb8e 100644 --- a/apps/api-service/src/services/ApiKeyService.ts +++ b/apps/api-service/src/services/ApiKeyService.ts @@ -9,6 +9,21 @@ export interface ApiKeyWithSlugs extends ApiKey { projectSlug?: string; } +// Extended API key type with full organization and project details for admin listing +export interface ApiKeyWithDetails extends ApiKey { + organization: { + id: string; + slug: string; + name: string; + email: string; + } | null; + project: { + id: string; + slug: string; + name: string; + } | null; +} + export class ApiKeyService { /** * Generate a new API key @@ -182,10 +197,73 @@ export class ApiKeyService { } /** - * List all keys (for admin) + * List all keys (for admin) with organization and project details */ - async listKeys(): Promise { - return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)); + async listKeys(): Promise { + const results = await db + .select({ + // API key fields + id: apiKeys.id, + keyHash: apiKeys.keyHash, + keyPrefix: apiKeys.keyPrefix, + keyType: apiKeys.keyType, + organizationId: apiKeys.organizationId, + projectId: apiKeys.projectId, + scopes: apiKeys.scopes, + createdAt: apiKeys.createdAt, + expiresAt: apiKeys.expiresAt, + lastUsedAt: apiKeys.lastUsedAt, + isActive: apiKeys.isActive, + name: apiKeys.name, + createdBy: apiKeys.createdBy, + // Organization fields + orgId: organizations.id, + orgSlug: organizations.slug, + orgName: organizations.name, + orgEmail: organizations.email, + // Project fields + projId: projects.id, + projSlug: projects.slug, + projName: projects.name, + }) + .from(apiKeys) + .leftJoin(organizations, eq(apiKeys.organizationId, organizations.id)) + .leftJoin(projects, eq(apiKeys.projectId, projects.id)) + .orderBy(desc(apiKeys.createdAt)); + + // Transform flat results to nested structure + return results.map((row) => ({ + id: row.id, + keyHash: row.keyHash, + keyPrefix: row.keyPrefix, + keyType: row.keyType, + organizationId: row.organizationId, + projectId: row.projectId, + scopes: row.scopes, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + lastUsedAt: row.lastUsedAt, + isActive: row.isActive, + name: row.name, + createdBy: row.createdBy, + organization: + row.orgId && row.orgSlug && row.orgName && row.orgEmail + ? { + id: row.orgId, + slug: row.orgSlug, + name: row.orgName, + email: row.orgEmail, + } + : null, + project: + row.projId && row.projSlug && row.projName + ? { + id: row.projId, + slug: row.projSlug, + name: row.projName, + } + : null, + })); } /** diff --git a/apps/landing/src/app/admin/apikeys/page.tsx b/apps/landing/src/app/admin/apikeys/page.tsx index ad9c01f..a73046f 100644 --- a/apps/landing/src/app/admin/apikeys/page.tsx +++ b/apps/landing/src/app/admin/apikeys/page.tsx @@ -6,7 +6,6 @@ import { createProjectApiKey, listApiKeys } from '@/lib/actions/apiKeyActions'; import KeyDisplay from '@/components/admin/KeyDisplay'; import AdminFormInput from '@/components/admin/AdminFormInput'; import AdminButton from '@/components/admin/AdminButton'; -import Link from 'next/link'; const STORAGE_KEY = 'banatie_master_key'; @@ -28,13 +27,16 @@ export default function ApiKeysPage() { router.push('/admin/master'); } else { setMasterKey(saved); - loadApiKeys(); + // Load API keys with the saved master key + listApiKeys(saved).then(setApiKeys); } }, [router]); const loadApiKeys = async () => { - const keys = await listApiKeys(); - setApiKeys(keys); + if (masterKey) { + const keys = await listApiKeys(masterKey); + setApiKeys(keys); + } }; const handleSubmit = async (e: React.FormEvent) => { @@ -66,23 +68,7 @@ export default function ApiKeysPage() { } return ( -
- {/* Navigation */} -
- - Master Key - - - API Keys - -
- +
{/* Page Header */}

Project API Keys

diff --git a/apps/landing/src/app/admin/layout.tsx b/apps/landing/src/app/admin/layout.tsx new file mode 100644 index 0000000..dab1815 --- /dev/null +++ b/apps/landing/src/app/admin/layout.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { ReactNode } from 'react'; +import { usePathname } from 'next/navigation'; +import { SubsectionNav } from '@/components/shared/SubsectionNav'; + +interface AdminLayoutProps { + children: ReactNode; +} + +const navItems = [ + { label: 'Master Key', href: '/admin/master' }, + { label: 'API Keys', href: '/admin/apikeys' }, +]; + +export default function AdminLayout({ children }: AdminLayoutProps) { + const pathname = usePathname(); + + return ( +
+ {/* Animated gradient background */} +
+
+
+
+ + {/* Subsection Navigation */} + + + {/* Page Content */} +
{children}
+
+ ); +} diff --git a/apps/landing/src/app/admin/master/page.tsx b/apps/landing/src/app/admin/master/page.tsx index f86a515..23ad318 100644 --- a/apps/landing/src/app/admin/master/page.tsx +++ b/apps/landing/src/app/admin/master/page.tsx @@ -5,7 +5,6 @@ import { bootstrapMasterKey } from '@/lib/actions/apiKeyActions'; import KeyDisplay from '@/components/admin/KeyDisplay'; import AdminFormInput from '@/components/admin/AdminFormInput'; import AdminButton from '@/components/admin/AdminButton'; -import Link from 'next/link'; const STORAGE_KEY = 'banatie_master_key'; @@ -67,23 +66,7 @@ export default function MasterKeyPage() { }; return ( -
- {/* Navigation */} -
- - Master Key - - - API Keys - -
- +
{/* Page Header */}

Master Key Management

diff --git a/apps/landing/src/lib/actions/apiKeyActions.ts b/apps/landing/src/lib/actions/apiKeyActions.ts index c3ed282..2f8fdff 100644 --- a/apps/landing/src/lib/actions/apiKeyActions.ts +++ b/apps/landing/src/lib/actions/apiKeyActions.ts @@ -1,7 +1,5 @@ 'use server'; -import { listApiKeys as listApiKeysQuery } from '../db/queries/apiKeys'; - const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; interface BootstrapResponse { @@ -26,6 +24,48 @@ interface CreateKeyResponse { message: string; } +interface ListKeysApiResponse { + keys: Array<{ + id: string; + type: string; + name: string | null; + scopes: string[]; + isActive: boolean; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + createdBy: string | null; + organization: { + id: string; + slug: string; + name: string; + email: string; + } | null; + project: { + id: string; + slug: string; + name: string; + } | null; + }>; + total: number; +} + +interface ApiKeyListItem { + id: string; + keyType: string; + name: string | null; + scopes: string[]; + isActive: boolean; + createdAt: Date; + expiresAt: Date | null; + lastUsedAt: Date | null; + organizationId: string | null; + organizationName: string | null; + organizationEmail: string | null; + projectId: string | null; + projectName: string | null; +} + export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?: string; @@ -86,9 +126,45 @@ export async function createProjectApiKey( } } -export async function listApiKeys() { +export async function listApiKeys(masterKey: string): Promise { try { - return await listApiKeysQuery(); + if (!masterKey) { + console.error('Master key not provided'); + return []; + } + + const response = await fetch(`${API_BASE_URL}/api/admin/keys`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': masterKey, + }, + cache: 'no-store', + }); + + if (!response.ok) { + console.error('Failed to fetch API keys:', response.statusText); + return []; + } + + const data: ListKeysApiResponse = await response.json(); + + // Transform nested API response to flat structure for backwards compatibility + return data.keys.map((key) => ({ + id: key.id, + keyType: key.type, + name: key.name, + scopes: key.scopes, + isActive: key.isActive, + createdAt: new Date(key.createdAt), + expiresAt: key.expiresAt ? new Date(key.expiresAt) : null, + lastUsedAt: key.lastUsedAt ? new Date(key.lastUsedAt) : null, + organizationId: key.organization?.id || null, + organizationName: key.organization?.name || null, + organizationEmail: key.organization?.email || null, + projectId: key.project?.id || null, + projectName: key.project?.name || null, + })); } catch (error) { console.error('List keys error:', error); return []; diff --git a/apps/landing/src/lib/actions/orgProjectActions.ts b/apps/landing/src/lib/actions/orgProjectActions.ts deleted file mode 100644 index cb75b40..0000000 --- a/apps/landing/src/lib/actions/orgProjectActions.ts +++ /dev/null @@ -1,41 +0,0 @@ -'use server'; - -import { getOrganizationByEmail, createOrganization } from '../db/queries/organizations'; -import { getProjectByName, createProject } from '../db/queries/projects'; -import type { Organization, Project } from '@banatie/database'; - -export async function getOrCreateOrganization(email: string, name: string): Promise { - // Try to find existing organization - const existing = await getOrganizationByEmail(email); - if (existing) { - return existing; - } - - // Create new organization - return createOrganization({ email, name }); -} - -export async function getOrCreateProject(organizationId: string, name: string): Promise { - // Try to find existing project - const existing = await getProjectByName(organizationId, name); - if (existing) { - return existing; - } - - // Create new project - return createProject({ organizationId, name }); -} - -export async function getOrCreateOrgAndProject( - email: string, - orgName: string, - projectName: string, -): Promise<{ organization: Organization; project: Project }> { - // Get or create organization - const organization = await getOrCreateOrganization(email, orgName); - - // Get or create project - const project = await getOrCreateProject(organization.id, projectName); - - return { organization, project }; -} diff --git a/apps/landing/src/lib/apikey/types.ts b/apps/landing/src/lib/apikey/types.ts index e45b1fd..84513bb 100644 --- a/apps/landing/src/lib/apikey/types.ts +++ b/apps/landing/src/lib/apikey/types.ts @@ -63,8 +63,8 @@ export interface ApiKeyContextValue { // Focus method for external components focus: () => void; - inputRef: React.Ref; - containerRef: React.Ref; + inputRef: React.RefObject; + containerRef: React.RefObject; } /** diff --git a/apps/landing/src/lib/db/client.ts b/apps/landing/src/lib/db/client.ts deleted file mode 100644 index 26f43ef..0000000 --- a/apps/landing/src/lib/db/client.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as schema from '@banatie/database'; - -const connectionString = - process.env.DATABASE_URL || - 'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db'; - -// Create postgres client -const client = postgres(connectionString); - -// Create drizzle instance with schema -export const db = drizzle(client, { schema }); diff --git a/apps/landing/src/lib/db/queries/apiKeys.ts b/apps/landing/src/lib/db/queries/apiKeys.ts deleted file mode 100644 index 4962ae1..0000000 --- a/apps/landing/src/lib/db/queries/apiKeys.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { db } from '../client'; -import { apiKeys, organizations, projects, type ApiKey } from '@banatie/database'; -import { eq, desc } from 'drizzle-orm'; - -export async function listApiKeys() { - return db - .select({ - id: apiKeys.id, - keyType: apiKeys.keyType, - name: apiKeys.name, - scopes: apiKeys.scopes, - isActive: apiKeys.isActive, - createdAt: apiKeys.createdAt, - expiresAt: apiKeys.expiresAt, - lastUsedAt: apiKeys.lastUsedAt, - organizationId: apiKeys.organizationId, - organizationName: organizations.name, - organizationEmail: organizations.email, - projectId: apiKeys.projectId, - projectName: projects.name, - }) - .from(apiKeys) - .leftJoin(organizations, eq(apiKeys.organizationId, organizations.id)) - .leftJoin(projects, eq(apiKeys.projectId, projects.id)) - .orderBy(desc(apiKeys.createdAt)) - .limit(50); -} - -export async function getApiKeysByProject(projectId: string) { - return db - .select() - .from(apiKeys) - .where(eq(apiKeys.projectId, projectId)) - .orderBy(desc(apiKeys.createdAt)); -} diff --git a/apps/landing/src/lib/db/queries/organizations.ts b/apps/landing/src/lib/db/queries/organizations.ts deleted file mode 100644 index 1323b09..0000000 --- a/apps/landing/src/lib/db/queries/organizations.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { db } from '../client'; -import { organizations, type Organization, type NewOrganization } from '@banatie/database'; -import { eq } from 'drizzle-orm'; - -export async function getOrganizationByEmail(email: string): Promise { - const [org] = await db - .select() - .from(organizations) - .where(eq(organizations.email, email)) - .limit(1); - - return org || null; -} - -export async function createOrganization(data: NewOrganization): Promise { - const [org] = await db.insert(organizations).values(data).returning(); - - return org!; -} - -export async function listOrganizations(): Promise { - return db.select().from(organizations).orderBy(organizations.createdAt); -} diff --git a/apps/landing/src/lib/db/queries/projects.ts b/apps/landing/src/lib/db/queries/projects.ts deleted file mode 100644 index c96ccde..0000000 --- a/apps/landing/src/lib/db/queries/projects.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { db } from '../client'; -import { projects, type Project, type NewProject } from '@banatie/database'; -import { eq, and } from 'drizzle-orm'; - -export async function getProjectByName( - organizationId: string, - name: string, -): Promise { - const [project] = await db - .select() - .from(projects) - .where(and(eq(projects.organizationId, organizationId), eq(projects.name, name))) - .limit(1); - - return project || null; -} - -export async function createProject(data: NewProject): Promise { - const [project] = await db.insert(projects).values(data).returning(); - - return project!; -} - -export async function listProjectsByOrganization(organizationId: string): Promise { - return db - .select() - .from(projects) - .where(eq(projects.organizationId, organizationId)) - .orderBy(projects.createdAt); -}