Compare commits
4 Commits
f1335fb4d3
...
f46a8d66d3
| Author | SHA1 | Date |
|---|---|---|
|
|
f46a8d66d3 | |
|
|
d7c230fae8 | |
|
|
b9a8ca8368 | |
|
|
d1806bfd7e |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ApiKey[]> {
|
||||
return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt));
|
||||
async listKeys(): Promise<ApiKeyWithDetails[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="relative z-10 max-w-6xl mx-auto px-6 py-16">
|
||||
{/* 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>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6 py-16">
|
||||
{/* 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>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
|
||||
|
|
|
|||
|
|
@ -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<ApiKeyListItem[]> {
|
||||
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 [];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -63,8 +63,8 @@ export interface ApiKeyContextValue {
|
|||
|
||||
// Focus method for external components
|
||||
focus: () => void;
|
||||
inputRef: React.Ref<HTMLInputElement>;
|
||||
containerRef: React.Ref<HTMLInputElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | 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 });
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue