Compare commits

..

2 Commits

Author SHA1 Message Date
Oleg Proskurin c6f359c126 fix: pass organization id 2025-10-04 00:13:02 +07:00
Oleg Proskurin bd0cf2d70a feat: add keys pages 2025-10-03 00:14:02 +07:00
25 changed files with 900 additions and 205 deletions

View File

@ -25,7 +25,7 @@
"command": "docker", "command": "docker",
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"], "args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
"env": { "env": {
"DATABASE_URI": "postgresql://postgres:postgres@localhost:5434/banatie_db" "DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db"
} }
}, },
"mastra": { "mastra": {

View File

@ -16,7 +16,7 @@ router.use(requireMasterKey);
*/ */
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { try {
const { type, projectId, name, expiresInDays } = req.body; const { type, projectId, organizationId, name, expiresInDays } = req.body;
// Validation // Validation
if (!type || !['master', 'project'].includes(type)) { if (!type || !['master', 'project'].includes(type)) {
@ -38,6 +38,7 @@ router.post('/', async (req, res) => {
? await apiKeyService.createMasterKey(name, req.apiKey!.id) ? await apiKeyService.createMasterKey(name, req.apiKey!.id)
: await apiKeyService.createProjectKey( : await apiKeyService.createProjectKey(
projectId, projectId,
organizationId,
name, name,
req.apiKey!.id, req.apiKey!.id,
expiresInDays || 90 expiresInDays || 90

View File

@ -49,6 +49,7 @@ export class ApiKeyService {
*/ */
async createProjectKey( async createProjectKey(
projectId: string, projectId: string,
organizationId?: string,
name?: string, name?: string,
createdBy?: string, createdBy?: string,
expiresInDays: number = 90 expiresInDays: number = 90
@ -63,6 +64,7 @@ export class ApiKeyService {
keyPrefix, keyPrefix,
keyType: 'project', keyType: 'project',
projectId, projectId,
organizationId: organizationId || null,
scopes: ['generate', 'read'], scopes: ['generate', 'read'],
name: name || `Project Key - ${projectId}`, name: name || `Project Key - ${projectId}`,
expiresAt, expiresAt,

View File

@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"next": "15.5.4" "next": "15.5.4",
"@banatie/database": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "typescript": "^5",

View File

@ -0,0 +1,208 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
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';
export default function ApiKeysPage() {
const router = useRouter();
const [masterKey, setMasterKey] = useState('');
const [email, setEmail] = useState('');
const [orgName, setOrgName] = useState('');
const [projectName, setProjectName] = useState('');
const [generatedKey, setGeneratedKey] = useState('');
const [apiKeys, setApiKeys] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Check for master key on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) {
router.push('/admin/master');
} else {
setMasterKey(saved);
loadApiKeys();
}
}, [router]);
const loadApiKeys = async () => {
const keys = await listApiKeys();
setApiKeys(keys);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
setSuccess('');
setGeneratedKey('');
const result = await createProjectApiKey(masterKey, email, orgName, projectName);
if (result.success && result.apiKey) {
setGeneratedKey(result.apiKey);
setSuccess('API key created successfully!');
// Clear form
setEmail('');
setOrgName('');
setProjectName('');
// Reload keys list
await loadApiKeys();
} else {
setError(result.error || 'Failed to create API key');
}
setLoading(false);
};
if (!masterKey) {
return null; // Will redirect
}
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>
{/* Page Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
<p className="text-slate-400">
Generate API keys for your projects. Organizations and projects will be created automatically if they don't exist.
</p>
</div>
{/* Messages */}
{error && (
<div className="mb-6 p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300">
{error}
</div>
)}
{success && (
<div className="mb-6 p-4 bg-green-900/30 border border-green-700 rounded-lg text-green-300">
{success}
</div>
)}
{/* Generated Key Display */}
{generatedKey && (
<div className="mb-8 p-6 bg-slate-800/50 backdrop-blur-sm border border-amber-700 rounded-2xl">
<h2 className="text-2xl font-semibold text-white mb-4">New API Key Generated</h2>
<p className="text-amber-400 mb-4 text-sm">
Save this key now! You won't be able to see it again.
</p>
<KeyDisplay apiKey={generatedKey} />
</div>
)}
{/* Create Key Form */}
<div className="mb-8 p-6 bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl">
<h2 className="text-2xl font-semibold text-white mb-6">Create New API Key</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<AdminFormInput
label="Email"
type="email"
value={email}
onChange={setEmail}
placeholder="admin@example.com"
required
/>
<AdminFormInput
label="Organization Name"
value={orgName}
onChange={setOrgName}
placeholder="My Organization"
required
/>
<AdminFormInput
label="Project Name"
value={projectName}
onChange={setProjectName}
placeholder="My Project"
required
/>
<AdminButton type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create API Key'}
</AdminButton>
</form>
</div>
{/* API Keys List */}
<div className="p-6 bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl">
<h2 className="text-2xl font-semibold text-white mb-6">Recent API Keys</h2>
{apiKeys.length === 0 ? (
<p className="text-slate-400">No API keys created yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-700">
<th className="pb-3 text-sm font-medium text-slate-400">Type</th>
<th className="pb-3 text-sm font-medium text-slate-400">Organization</th>
<th className="pb-3 text-sm font-medium text-slate-400">Project</th>
<th className="pb-3 text-sm font-medium text-slate-400">Created</th>
<th className="pb-3 text-sm font-medium text-slate-400">Expires</th>
<th className="pb-3 text-sm font-medium text-slate-400">Status</th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="border-b border-slate-800">
<td className="py-3 text-sm text-slate-300">
<span className={`px-2 py-1 rounded text-xs font-medium ${
key.keyType === 'master' ? 'bg-amber-900/30 text-amber-400' : 'bg-blue-900/30 text-blue-400'
}`}>
{key.keyType}
</span>
</td>
<td className="py-3 text-sm text-slate-300">
{key.organizationName || '-'}
{key.organizationEmail && (
<div className="text-xs text-slate-500">{key.organizationEmail}</div>
)}
</td>
<td className="py-3 text-sm text-slate-300">{key.projectName || '-'}</td>
<td className="py-3 text-sm text-slate-400">
{new Date(key.createdAt).toLocaleDateString()}
</td>
<td className="py-3 text-sm text-slate-400">
{key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : 'Never'}
</td>
<td className="py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs font-medium ${
key.isActive ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'
}`}>
{key.isActive ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,153 @@
'use client';
import { useState, useEffect } from 'react';
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';
export default function MasterKeyPage() {
const [masterKey, setMasterKey] = useState('');
const [manualKey, setManualKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Load saved key from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
setMasterKey(saved);
}
}, []);
const handleBootstrap = async () => {
setLoading(true);
setError('');
setSuccess('');
const result = await bootstrapMasterKey();
if (result.success && result.apiKey) {
setMasterKey(result.apiKey);
setSuccess('Master key generated successfully!');
} else {
setError(result.error || 'Failed to bootstrap master key');
}
setLoading(false);
};
const handleSave = () => {
if (masterKey) {
localStorage.setItem(STORAGE_KEY, masterKey);
setSuccess('Master key saved to localStorage!');
setTimeout(() => setSuccess(''), 3000);
}
};
const handleManualSave = () => {
if (manualKey) {
localStorage.setItem(STORAGE_KEY, manualKey);
setMasterKey(manualKey);
setManualKey('');
setSuccess('Master key saved to localStorage!');
setTimeout(() => setSuccess(''), 3000);
}
};
const handleClear = () => {
localStorage.removeItem(STORAGE_KEY);
setMasterKey('');
setSuccess('Master key cleared from localStorage');
setTimeout(() => setSuccess(''), 3000);
};
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>
{/* Page Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
<p className="text-slate-400">
Bootstrap your master key or manually configure it. This key is required to generate project API keys.
</p>
</div>
{/* Messages */}
{error && (
<div className="mb-6 p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300">
{error}
</div>
)}
{success && (
<div className="mb-6 p-4 bg-green-900/30 border border-green-700 rounded-lg text-green-300">
{success}
</div>
)}
{/* Bootstrap Section */}
<div className="mb-8 p-6 bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl">
<h2 className="text-2xl font-semibold text-white mb-4">Bootstrap Master Key</h2>
<p className="text-slate-400 mb-6">
Generate the first master key for your system. This only works if no keys exist yet.
</p>
<AdminButton onClick={handleBootstrap} disabled={loading}>
{loading ? 'Generating...' : 'Generate Master Key'}
</AdminButton>
</div>
{/* Current Key Display */}
{masterKey && (
<div className="mb-8 p-6 bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl">
<h2 className="text-2xl font-semibold text-white mb-4">Current Master Key</h2>
<KeyDisplay apiKey={masterKey} />
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleSave} variant="primary">
Save to LocalStorage
</AdminButton>
<AdminButton onClick={handleClear} variant="danger">
Clear from LocalStorage
</AdminButton>
</div>
</div>
)}
{/* Manual Entry Section */}
<div className="p-6 bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl">
<h2 className="text-2xl font-semibold text-white mb-4">Manual Key Entry</h2>
<p className="text-slate-400 mb-6">
Already have a master key? Enter it here to save it to localStorage.
</p>
<AdminFormInput
label="Master Key"
value={manualKey}
onChange={setManualKey}
placeholder="bnt_..."
className="mb-4"
/>
<AdminButton onClick={handleManualSave} disabled={!manualKey}>
Save Manual Key
</AdminButton>
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import Image from "next/image";
import "./globals.css"; import "./globals.css";
const inter = Inter({ const inter = Inter({
@ -58,7 +59,64 @@ export default function RootLayout({
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head> </head>
<body className={`${inter.variable} antialiased`}> <body className={`${inter.variable} antialiased`}>
{children} <div className="min-h-screen bg-gradient-to-br from-slate-950 via-purple-950 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/30 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/30 rounded-full blur-3xl animate-pulse delay-700"></div>
</div>
{/* Header */}
<header className="relative z-10 border-b border-white/10 backdrop-blur-sm">
<nav className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center h-16">
<div className="h-full flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={150}
height={40}
priority
className="h-full w-auto object-contain"
/>
</div>
<a
href="#waitlist"
className="text-sm text-gray-300 hover:text-white transition-colors"
>
Join Beta
</a>
</nav>
</header>
{/* Page content */}
{children}
{/* Footer */}
<footer className="relative z-10 border-t border-white/10 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 pt-12 pb-4">
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
<div className="h-16 flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={200}
height={60}
className="h-full w-auto object-contain"
/>
</div>
<div className="flex gap-8 text-sm text-gray-400">
<a href="#" className="hover:text-white transition-colors">Documentation</a>
<a href="#" className="hover:text-white transition-colors">API Reference</a>
<a href="#" className="hover:text-white transition-colors">Pricing</a>
<a href="#" className="hover:text-white transition-colors">Contact</a>
</div>
</div>
<div className="mt-8 text-center text-sm text-gray-500">
&copy; 2025 Banatie. Built for builders who create.
</div>
</div>
</footer>
</div>
</body> </body>
</html> </html>
); );

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import Image from 'next/image';
export default function Home() { export default function Home() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -21,35 +20,7 @@ export default function Home() {
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-purple-950 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/30 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/30 rounded-full blur-3xl animate-pulse delay-700"></div>
</div>
{/* Header */}
<header className="relative z-10 border-b border-white/10 backdrop-blur-sm">
<nav className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center h-16">
<div className="h-full flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={150}
height={40}
priority
className="h-full w-auto object-contain"
/>
</div>
<a
href="#waitlist"
className="text-sm text-gray-300 hover:text-white transition-colors"
>
Join Beta
</a>
</nav>
</header>
{/* Hero Section */} {/* Hero Section */}
<section className="relative z-10 max-w-7xl mx-auto px-6 pt-20 pb-16 md:pt-32 md:pb-24"> <section className="relative z-10 max-w-7xl mx-auto px-6 pt-20 pb-16 md:pt-32 md:pb-24">
<div className="text-center max-w-4xl mx-auto"> <div className="text-center max-w-4xl mx-auto">
@ -247,32 +218,6 @@ export default function Home() {
</a> </a>
</div> </div>
</section> </section>
</>
{/* Footer */}
<footer className="relative z-10 border-t border-white/10 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 pt-12 pb-4">
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
<div className="h-16 flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={200}
height={60}
className="h-full w-auto object-contain"
/>
</div>
<div className="flex gap-8 text-sm text-gray-400">
<a href="#" className="hover:text-white transition-colors">Documentation</a>
<a href="#" className="hover:text-white transition-colors">API Reference</a>
<a href="#" className="hover:text-white transition-colors">Pricing</a>
<a href="#" className="hover:text-white transition-colors">Contact</a>
</div>
</div>
<div className="mt-8 text-center text-sm text-gray-500">
&copy; 2025 Banatie. Built for builders who create.
</div>
</div>
</footer>
</div>
); );
} }

View File

@ -0,0 +1,36 @@
interface AdminButtonProps {
children: React.ReactNode;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
className?: string;
}
export default function AdminButton({
children,
onClick,
type = 'button',
variant = 'primary',
disabled = false,
className = '',
}: AdminButtonProps) {
const baseClasses = 'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-amber-600 text-white hover:bg-amber-700 shadow-lg shadow-amber-900/30',
secondary: 'bg-slate-700 text-slate-200 hover:bg-slate-600',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
>
{children}
</button>
);
}

View File

@ -0,0 +1,36 @@
interface AdminFormInputProps {
label: string;
type?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
className?: string;
}
export default function AdminFormInput({
label,
type = 'text',
value,
onChange,
placeholder,
required = false,
className = '',
}: AdminFormInputProps) {
return (
<div className={`space-y-2 ${className}`}>
<label className="block text-sm font-medium text-slate-300">
{label}
{required && <span className="text-amber-500 ml-1">*</span>}
</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent"
/>
</div>
);
}

View File

@ -0,0 +1,36 @@
'use client';
import { useState } from 'react';
interface CopyButtonProps {
text: string;
label?: string;
className?: string;
}
export default function CopyButton({ text, label = 'Copy', className = '' }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<button
onClick={handleCopy}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
copied
? 'bg-green-600 text-white'
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
} ${className}`}
>
{copied ? '✓ Copied!' : label}
</button>
);
}

View File

@ -0,0 +1,39 @@
'use client';
import { useState } from 'react';
import CopyButton from './CopyButton';
interface KeyDisplayProps {
apiKey: string;
label?: string;
className?: string;
}
export default function KeyDisplay({ apiKey, label = 'API Key', className = '' }: KeyDisplayProps) {
const [revealed, setRevealed] = useState(false);
const maskedKey = apiKey ? `${apiKey.substring(0, 8)}${'•'.repeat(48)}` : '';
const displayKey = revealed ? apiKey : maskedKey;
return (
<div className={`space-y-2 ${className}`}>
<label className="block text-sm font-medium text-slate-300">{label}</label>
<div className="flex gap-2">
<div className="flex-1 px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg font-mono text-sm text-slate-200 overflow-x-auto">
{displayKey || 'No key generated yet'}
</div>
{apiKey && (
<>
<button
onClick={() => setRevealed(!revealed)}
className="px-4 py-2 text-sm font-medium bg-slate-700 text-slate-200 rounded-lg hover:bg-slate-600 transition-colors"
>
{revealed ? 'Hide' : 'Show'}
</button>
<CopyButton text={apiKey} />
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,97 @@
'use server';
import { getOrCreateOrgAndProject } from './orgProjectActions';
import { listApiKeys as listApiKeysQuery } from '../db/queries/apiKeys';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
interface BootstrapResponse {
apiKey: string;
type: string;
name: string;
expiresAt: string | null;
message: string;
}
interface CreateKeyResponse {
apiKey: string;
metadata: {
id: string;
type: string;
projectId: string;
name: string;
expiresAt: string | null;
scopes: string[];
createdAt: string;
};
message: string;
}
export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?: string; error?: string }> {
try {
const response = await fetch(`${API_BASE_URL}/api/bootstrap/initial-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
return { success: false, error: error.message || 'Failed to bootstrap master key' };
}
const data: BootstrapResponse = await response.json();
return { success: true, apiKey: data.apiKey };
} catch (error) {
console.error('Bootstrap error:', error);
return { success: false, error: 'Network error while bootstrapping master key' };
}
}
export async function createProjectApiKey(
masterKey: string,
email: string,
orgName: string,
projectName: string
): Promise<{ success: boolean; apiKey?: string; error?: string }> {
try {
// First, ensure organization and project exist in DB
const { organization, project } = await getOrCreateOrgAndProject(email, orgName, projectName);
// Then call API service to create the project key
const response = await fetch(`${API_BASE_URL}/api/admin/keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': masterKey,
},
body: JSON.stringify({
type: 'project',
projectId: project.id,
organizationId: organization.id,
name: `${orgName} - ${projectName}`,
}),
});
if (!response.ok) {
const error = await response.json();
return { success: false, error: error.message || 'Failed to create API key' };
}
const data: CreateKeyResponse = await response.json();
return { success: true, apiKey: data.apiKey };
} catch (error) {
console.error('Create key error:', error);
return { success: false, error: 'Network error while creating API key' };
}
}
export async function listApiKeys() {
try {
return await listApiKeysQuery();
} catch (error) {
console.error('List keys error:', error);
return [];
}
}

View File

@ -0,0 +1,41 @@
'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

@ -0,0 +1,11 @@
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:5434/banatie_db';
// Create postgres client
const client = postgres(connectionString);
// Create drizzle instance with schema
export const db = drizzle(client, { schema });

View File

@ -0,0 +1,35 @@
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

@ -0,0 +1,29 @@
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

@ -0,0 +1,35 @@
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);
}

View File

@ -1,15 +0,0 @@
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"key_hash" text NOT NULL,
"key_prefix" text DEFAULT 'bnt_' NOT NULL,
"key_type" text NOT NULL,
"project_id" text,
"scopes" jsonb DEFAULT '["generate"]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp,
"last_used_at" timestamp,
"is_active" boolean DEFAULT true NOT NULL,
"name" text,
"created_by" uuid,
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
);

View File

@ -1,117 +0,0 @@
{
"id": "a0f532c8-8e34-4297-a580-060eb8b49306",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'bnt_'"
},
"key_type": {
"name": "key_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scopes": {
"name": "scopes",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[\"generate\"]'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_by": {
"name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"api_keys_key_hash_unique": {
"name": "api_keys_key_hash_unique",
"nullsNotDistinct": false,
"columns": [
"key_hash"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -1,13 +1,5 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": []
{
"idx": 0,
"version": "7",
"when": 1759250997369,
"tag": "0000_gifted_sunfire",
"breakpoints": true
}
]
} }

View File

@ -1,4 +1,6 @@
import { pgTable, uuid, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core'; import { pgTable, uuid, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
import { projects } from './projects';
export const apiKeys = pgTable('api_keys', { export const apiKeys = pgTable('api_keys', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@ -10,8 +12,9 @@ export const apiKeys = pgTable('api_keys', {
// Key type: 'master' or 'project' // Key type: 'master' or 'project'
keyType: text('key_type').notNull().$type<'master' | 'project'>(), keyType: text('key_type').notNull().$type<'master' | 'project'>(),
// For project keys // Foreign key relationships
projectId: text('project_id'), organizationId: uuid('organization_id').references(() => organizations.id, { onDelete: 'cascade' }),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
// Permissions (for future use) // Permissions (for future use)
scopes: jsonb('scopes').$type<string[]>().notNull().default(['generate']), scopes: jsonb('scopes').$type<string[]>().notNull().default(['generate']),

View File

@ -1 +1,34 @@
export * from './apiKeys'; import { relations } from 'drizzle-orm';
import { organizations } from './organizations';
import { projects } from './projects';
import { apiKeys } from './apiKeys';
// Export all tables
export * from './organizations';
export * from './projects';
export * from './apiKeys';
// Define relations
export const organizationsRelations = relations(organizations, ({ many }) => ({
projects: many(projects),
apiKeys: many(apiKeys),
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
organization: one(organizations, {
fields: [projects.organizationId],
references: [organizations.id],
}),
apiKeys: many(apiKeys),
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
organization: one(organizations, {
fields: [apiKeys.organizationId],
references: [organizations.id],
}),
project: one(projects, {
fields: [apiKeys.projectId],
references: [projects.id],
}),
}));

View File

@ -0,0 +1,16 @@
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
export const organizations = pgTable('organizations', {
id: uuid('id').primaryKey().defaultRandom(),
// Organization details
name: text('name').notNull(),
email: text('email').notNull().unique(),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
});
export type Organization = typeof organizations.$inferSelect;
export type NewOrganization = typeof organizations.$inferInsert;

View File

@ -0,0 +1,20 @@
import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
// Project details
name: text('name').notNull(),
organizationId: uuid('organization_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
}, (table) => ({
// Unique constraint: one project name per organization
uniqueOrgProject: unique().on(table.organizationId, table.name),
}));
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;