diff --git a/apps/backend/src/modules/api-keys/api-keys.controller.ts b/apps/backend/src/modules/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..38a922f --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Delete, Body, Param, HttpCode } from '@nestjs/common'; +import { ApiKeysService } from './api-keys.service'; +import { CreateApiKeyDto, ApiKeyResponseDto, ApiKeyUsageDto } from './dto/api-key.dto'; + +// NOTE: In production, add @UseGuards(AuthGuard) and extract userId from request +// For now, userId is passed as a path parameter for demo purposes + +@Controller('api-keys') +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post(':userId') + async createApiKey( + @Param('userId') userId: string, + @Body() dto: CreateApiKeyDto, + ): Promise { + return this.apiKeysService.createApiKey(userId, dto); + } + + @Get(':userId') + async getApiKeys(@Param('userId') userId: string): Promise { + return this.apiKeysService.getApiKeys(userId); + } + + @Delete(':userId/:keyId') + @HttpCode(204) + async revokeApiKey( + @Param('userId') userId: string, + @Param('keyId') keyId: string, + ): Promise { + return this.apiKeysService.revokeApiKey(userId, keyId); + } + + @Get(':userId/:keyId/usage') + async getApiKeyUsage( + @Param('userId') userId: string, + @Param('keyId') keyId: string, + ): Promise { + return this.apiKeysService.getApiKeyUsage(userId, keyId); + } +} diff --git a/apps/backend/src/modules/api-keys/api-keys.module.ts b/apps/backend/src/modules/api-keys/api-keys.module.ts new file mode 100644 index 0000000..89e8402 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ApiKeysController } from './api-keys.controller'; +import { ApiKeysService } from './api-keys.service'; + +@Module({ + controllers: [ApiKeysController], + providers: [ApiKeysService], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/apps/backend/src/modules/api-keys/api-keys.service.ts b/apps/backend/src/modules/api-keys/api-keys.service.ts new file mode 100644 index 0000000..b2bb366 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.service.ts @@ -0,0 +1,133 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { randomBytes, createHash } from 'crypto'; +import { CreateApiKeyDto, ApiKeyResponseDto, ApiKeyUsageDto } from './dto/api-key.dto'; + +@Injectable() +export class ApiKeysService { + private prisma: PrismaClient; + + constructor() { + this.prisma = new PrismaClient(); + } + + private generateKey(): string { + return `sk_${randomBytes(32).toString('hex')}`; + } + + private hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); + } + + private getKeyPreview(key: string): string { + return key.slice(-4); + } + + async createApiKey( + userId: string, + dto: CreateApiKeyDto, + ): Promise { + const key = this.generateKey(); + const keyHash = this.hashKey(key); + + const apiKey = await this.prisma.apiKey.create({ + data: { + userId, + name: dto.name, + key: keyHash, + keyHash, + }, + }); + + return { + id: apiKey.id, + name: apiKey.name, + key, + keyPreview: this.getKeyPreview(key), + createdAt: apiKey.createdAt, + lastUsedAt: apiKey.lastUsedAt, + revokedAt: apiKey.revokedAt, + }; + } + + async getApiKeys(userId: string): Promise { + const apiKeys = await this.prisma.apiKey.findMany({ + where: { + userId, + revokedAt: null, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return apiKeys.map(key => ({ + id: key.id, + name: key.name, + keyPreview: key.keyHash.slice(-4), + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + revokedAt: key.revokedAt, + })); + } + + async revokeApiKey(userId: string, keyId: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id: keyId }, + }); + + if (!apiKey) { + throw new NotFoundException(`API key not found`); + } + + if (apiKey.userId !== userId) { + throw new ConflictException(`Unauthorized access to API key`); + } + + await this.prisma.apiKey.update({ + where: { id: keyId }, + data: { revokedAt: new Date() }, + }); + } + + async validateAndUpdateUsage(key: string): Promise { + const keyHash = this.hashKey(key); + + const apiKey = await this.prisma.apiKey.findUnique({ + where: { keyHash }, + }); + + if (!apiKey || apiKey.revokedAt) { + return null; + } + + await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { lastUsedAt: new Date() }, + }); + + return apiKey.userId; + } + + async getApiKeyUsage(userId: string, keyId: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id: keyId }, + }); + + if (!apiKey || apiKey.userId !== userId) { + throw new NotFoundException(`API key not found`); + } + + // Placeholder for actual usage tracking + const requestCount = 0; + + return { + id: apiKey.id, + name: apiKey.name, + keyPreview: apiKey.keyHash.slice(-4), + createdAt: apiKey.createdAt, + lastUsedAt: apiKey.lastUsedAt, + requestCount, + }; + } +} diff --git a/apps/backend/src/modules/api-keys/dto/api-key.dto.ts b/apps/backend/src/modules/api-keys/dto/api-key.dto.ts new file mode 100644 index 0000000..dd9066a --- /dev/null +++ b/apps/backend/src/modules/api-keys/dto/api-key.dto.ts @@ -0,0 +1,22 @@ +export class CreateApiKeyDto { + name: string; +} + +export class ApiKeyResponseDto { + id: string; + name: string; + key?: string; // Only returned on creation + keyPreview: string; // Last 4 chars + createdAt: Date; + lastUsedAt: Date | null; + revokedAt: Date | null; +} + +export class ApiKeyUsageDto { + id: string; + name: string; + keyPreview: string; + createdAt: Date; + lastUsedAt: Date | null; + requestCount: number; +} diff --git a/apps/backend/src/modules/api-keys/interfaces/api-key.interface.ts b/apps/backend/src/modules/api-keys/interfaces/api-key.interface.ts new file mode 100644 index 0000000..4a1c8c1 --- /dev/null +++ b/apps/backend/src/modules/api-keys/interfaces/api-key.interface.ts @@ -0,0 +1,10 @@ +export interface IApiKey { + id: string; + userId: string; + name: string; + key: string; + keyHash: string; + lastUsedAt: Date | null; + revokedAt: Date | null; + createdAt: Date; +} diff --git a/apps/dashboard/src/app/settings/api-keys/page.tsx b/apps/dashboard/src/app/settings/api-keys/page.tsx new file mode 100644 index 0000000..95ac13c --- /dev/null +++ b/apps/dashboard/src/app/settings/api-keys/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ApiKeyList } from '@/components/api-keys/ApiKeyList'; +import { CreateKeyModal } from '@/components/api-keys/CreateKeyModal'; +import { useApiKeys } from '@/hooks/useApiKeys'; + +export default function ApiKeysPage() { + const [showCreateModal, setShowCreateModal] = useState(false); + const [userId, setUserId] = useState(''); + const { keys, loading, error, createKey, revokeKey, refetch } = useApiKeys(userId); + + // Get userId from session/context in production + useEffect(() => { + // Placeholder: In production, get from auth context/session + const tempUserId = localStorage.getItem('userId') || 'user-123'; + setUserId(tempUserId); + }, []); + + const handleCreateKey = async (name: string) => { + await createKey(name); + setShowCreateModal(false); + refetch(); + }; + + const handleRevokeKey = async (keyId: string) => { + if (confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) { + await revokeKey(keyId); + refetch(); + } + }; + + return ( +
+
+

API Keys

+

Manage your API keys for programmatic access to Sentinel

+
+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + + + {showCreateModal && ( + setShowCreateModal(false)} onCreate={handleCreateKey} /> + )} +
+ ); +} diff --git a/apps/dashboard/src/components/api-keys/ApiKeyList.tsx b/apps/dashboard/src/components/api-keys/ApiKeyList.tsx new file mode 100644 index 0000000..3fb717f --- /dev/null +++ b/apps/dashboard/src/components/api-keys/ApiKeyList.tsx @@ -0,0 +1,77 @@ +'use client'; + +interface ApiKey { + id: string; + name: string; + keyPreview: string; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; +} + +interface ApiKeyListProps { + keys: ApiKey[]; + loading: boolean; + onRevoke: (keyId: string) => void; +} + +export function ApiKeyList({ keys, loading, onRevoke }: ApiKeyListProps) { + if (loading) { + return ( +
+
+

Loading API keys...

+
+ ); + } + + if (keys.length === 0) { + return ( +
+

No API keys created yet

+

Create your first API key to get started

+
+ ); + } + + return ( +
+ + + + + + + + + + + + {keys.map(key => ( + + + + + + + + ))} + +
NameKeyCreatedLast UsedActions
{key.name}****{key.keyPreview} + {new Date(key.createdAt).toLocaleDateString()} + + {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : 'Never'} + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/api-keys/CreateKeyModal.tsx b/apps/dashboard/src/components/api-keys/CreateKeyModal.tsx new file mode 100644 index 0000000..6d176fe --- /dev/null +++ b/apps/dashboard/src/components/api-keys/CreateKeyModal.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState } from 'react'; + +interface CreateKeyModalProps { + onClose: () => void; + onCreate: (name: string) => void; +} + +export function CreateKeyModal({ onClose, onCreate }: CreateKeyModalProps) { + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + alert('Please enter a name for the API key'); + return; + } + setLoading(true); + try { + await onCreate(name); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+

Create API Key

+ +
+
+ + setName(e.target.value)} + placeholder="e.g., Production Alerts" + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg focus:outline-none focus:border-blue-500" + disabled={loading} + /> +

Give your key a descriptive name

+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index ab06299..735b080 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -90,6 +90,26 @@ const navItems: NavItem[] = [ ), }, + { + href: '/settings/api-keys', + label: 'API Keys', + icon: ( + + ), + }, { href: '/settings', label: 'Settings', diff --git a/apps/dashboard/src/hooks/useApiKeys.ts b/apps/dashboard/src/hooks/useApiKeys.ts new file mode 100644 index 0000000..3e27017 --- /dev/null +++ b/apps/dashboard/src/hooks/useApiKeys.ts @@ -0,0 +1,75 @@ +import { useState, useCallback } from 'react'; +import axios from 'axios'; + +interface ApiKey { + id: string; + name: string; + keyPreview: string; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; + +export function useApiKeys(userId: string) { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + if (!userId) return; + setLoading(true); + setError(null); + try { + const response = await axios.get(`${API_BASE_URL}/api-keys/${userId}`); + setKeys(response.data); + } catch (err) { + setError('Failed to load API keys'); + console.error('Error fetching API keys:', err); + } finally { + setLoading(false); + } + }, [userId]); + + const createKey = useCallback( + async (name: string) => { + if (!userId) return; + try { + const response = await axios.post(`${API_BASE_URL}/api-keys/${userId}`, { + name, + }); + // Show the key to user only on creation + alert(`API Key created! Store it safely:\n\n${response.data.key}`); + await refetch(); + } catch (err) { + setError('Failed to create API key'); + console.error('Error creating API key:', err); + } + }, + [userId, refetch], + ); + + const revokeKey = useCallback( + async (keyId: string) => { + if (!userId) return; + try { + await axios.delete(`${API_BASE_URL}/api-keys/${userId}/${keyId}`); + await refetch(); + } catch (err) { + setError('Failed to revoke API key'); + console.error('Error revoking API key:', err); + } + }, + [userId, refetch], + ); + + return { + keys, + loading, + error, + createKey, + revokeKey, + refetch, + }; +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 79988ca..9e92f95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,7 @@ model User { watchlist Watchlist[] notificationPreferences NotificationPreference[] auditLogs AuditLog[] + apiKeys ApiKey[] @@map("users") } @@ -77,6 +78,22 @@ model AuditLog { @@map("audit_logs") } +model ApiKey { + id String @id @default(cuid()) + userId String + name String + key String @unique + keyHash String @unique + lastUsedAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("api_keys") +} + model Proposal { id String @id @default(cuid()) title String