Compare commits

..

4 Commits

12 changed files with 215 additions and 195 deletions

View File

@ -112,20 +112,25 @@ router.get('/', async (req, res) => {
try { try {
const keys = await apiKeyService.listKeys(); 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) => ({ const safeKeys = keys.map((key) => ({
id: key.id, id: key.id,
type: key.keyType, type: key.keyType,
projectId: key.projectId,
name: key.name, name: key.name,
scopes: key.scopes, scopes: key.scopes,
isActive: key.isActive, isActive: key.isActive,
createdAt: key.createdAt, createdAt: key.createdAt.toISOString(),
expiresAt: key.expiresAt, expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null,
lastUsedAt: key.lastUsedAt, lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null,
createdBy: key.createdBy, 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({ res.json({
keys: safeKeys, keys: safeKeys,
total: safeKeys.length, total: safeKeys.length,

View File

@ -9,6 +9,21 @@ export interface ApiKeyWithSlugs extends ApiKey {
projectSlug?: string; 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 { export class ApiKeyService {
/** /**
* Generate a new API key * 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<ApiKey[]> { async listKeys(): Promise<ApiKeyWithDetails[]> {
return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)); 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,
}));
} }
/** /**

View File

@ -6,7 +6,6 @@ import { createProjectApiKey, listApiKeys } from '@/lib/actions/apiKeyActions';
import KeyDisplay from '@/components/admin/KeyDisplay'; import KeyDisplay from '@/components/admin/KeyDisplay';
import AdminFormInput from '@/components/admin/AdminFormInput'; import AdminFormInput from '@/components/admin/AdminFormInput';
import AdminButton from '@/components/admin/AdminButton'; import AdminButton from '@/components/admin/AdminButton';
import Link from 'next/link';
const STORAGE_KEY = 'banatie_master_key'; const STORAGE_KEY = 'banatie_master_key';
@ -28,13 +27,16 @@ export default function ApiKeysPage() {
router.push('/admin/master'); router.push('/admin/master');
} else { } else {
setMasterKey(saved); setMasterKey(saved);
loadApiKeys(); // Load API keys with the saved master key
listApiKeys(saved).then(setApiKeys);
} }
}, [router]); }, [router]);
const loadApiKeys = async () => { const loadApiKeys = async () => {
const keys = await listApiKeys(); if (masterKey) {
setApiKeys(keys); const keys = await listApiKeys(masterKey);
setApiKeys(keys);
}
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -66,23 +68,7 @@ export default function ApiKeysPage() {
} }
return ( return (
<div className="relative z-10 max-w-6xl mx-auto px-6 py-16"> <div className="max-w-6xl mx-auto px-6 py-8">
{/* Navigation */}
<div className="mb-8 flex gap-4">
<Link
href="/admin/master"
className="px-4 py-2 bg-slate-700 text-slate-300 rounded-lg font-medium hover:bg-slate-600"
>
Master Key
</Link>
<Link
href="/admin/apikeys"
className="px-4 py-2 bg-amber-600 text-white rounded-lg font-medium"
>
API Keys
</Link>
</div>
{/* Page Header */} {/* Page Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1> <h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>

View File

@ -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 (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
{/* Animated gradient background */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/10 rounded-full blur-3xl animate-pulse delay-700"></div>
</div>
{/* Subsection Navigation */}
<SubsectionNav items={navItems} currentPath={pathname} />
{/* Page Content */}
<div className="relative z-10">{children}</div>
</div>
);
}

View File

@ -5,7 +5,6 @@ import { bootstrapMasterKey } from '@/lib/actions/apiKeyActions';
import KeyDisplay from '@/components/admin/KeyDisplay'; import KeyDisplay from '@/components/admin/KeyDisplay';
import AdminFormInput from '@/components/admin/AdminFormInput'; import AdminFormInput from '@/components/admin/AdminFormInput';
import AdminButton from '@/components/admin/AdminButton'; import AdminButton from '@/components/admin/AdminButton';
import Link from 'next/link';
const STORAGE_KEY = 'banatie_master_key'; const STORAGE_KEY = 'banatie_master_key';
@ -67,23 +66,7 @@ export default function MasterKeyPage() {
}; };
return ( return (
<div className="relative z-10 max-w-4xl mx-auto px-6 py-16"> <div className="max-w-4xl mx-auto px-6 py-8">
{/* Navigation */}
<div className="mb-8 flex gap-4">
<Link
href="/admin/master"
className="px-4 py-2 bg-amber-600 text-white rounded-lg font-medium"
>
Master Key
</Link>
<Link
href="/admin/apikeys"
className="px-4 py-2 bg-slate-700 text-slate-300 rounded-lg font-medium hover:bg-slate-600"
>
API Keys
</Link>
</div>
{/* Page Header */} {/* Page Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1> <h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>

View File

@ -1,7 +1,5 @@
'use server'; 'use server';
import { listApiKeys as listApiKeysQuery } from '../db/queries/apiKeys';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
interface BootstrapResponse { interface BootstrapResponse {
@ -26,6 +24,48 @@ interface CreateKeyResponse {
message: string; 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<{ export async function bootstrapMasterKey(): Promise<{
success: boolean; success: boolean;
apiKey?: string; apiKey?: string;
@ -86,9 +126,45 @@ export async function createProjectApiKey(
} }
} }
export async function listApiKeys() { export async function listApiKeys(masterKey: string): Promise<ApiKeyListItem[]> {
try { 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) { } catch (error) {
console.error('List keys error:', error); console.error('List keys error:', error);
return []; return [];

View File

@ -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<Organization> {
// 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<Project> {
// 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 };
}

View File

@ -63,8 +63,8 @@ export interface ApiKeyContextValue {
// Focus method for external components // Focus method for external components
focus: () => void; focus: () => void;
inputRef: React.Ref<HTMLInputElement>; inputRef: React.RefObject<HTMLInputElement | null>;
containerRef: React.Ref<HTMLInputElement>; containerRef: React.RefObject<HTMLDivElement | null>;
} }
/** /**

View File

@ -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 });

View File

@ -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));
}

View File

@ -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<Organization | null> {
const [org] = await db
.select()
.from(organizations)
.where(eq(organizations.email, email))
.limit(1);
return org || null;
}
export async function createOrganization(data: NewOrganization): Promise<Organization> {
const [org] = await db.insert(organizations).values(data).returning();
return org!;
}
export async function listOrganizations(): Promise<Organization[]> {
return db.select().from(organizations).orderBy(organizations.createdAt);
}

View File

@ -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<Project | null> {
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<Project> {
const [project] = await db.insert(projects).values(data).returning();
return project!;
}
export async function listProjectsByOrganization(organizationId: string): Promise<Project[]> {
return db
.select()
.from(projects)
.where(eq(projects.organizationId, organizationId))
.orderBy(projects.createdAt);
}