diff --git a/drizzle.config.ts b/drizzle.config.ts index 0f2ac48521..0bf74f0fc5 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - out: './migrations', + out: './src/db/migrations', schema: './src/db/schema.ts', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL! }, diff --git a/src/actions/auditLogs.ts b/src/actions/auditLogs.ts new file mode 100644 index 0000000000..83db4a6465 --- /dev/null +++ b/src/actions/auditLogs.ts @@ -0,0 +1,64 @@ +'use server'; + +import { eq, gte, and, desc } from 'drizzle-orm'; + +import { db } from '@/db'; +import { auditLogs } from '@/db/schema'; +import { logger } from '@/lib/server/logger'; +import { AuditLogType } from '@/types/auditLogs'; + +/** + * getAuditLogs + * + * Server action to retrieve audit logs for an organization + */ +export const getAuditLogs = async ({ + orgId, + limit = 100, + nRecentDays, +}: { + orgId?: string | null; + limit?: number; + nRecentDays?: number; +}): Promise => { + if (!orgId) { + return []; + } + + try { + if (isNaN(limit) || limit <= 0) { + throw new Error('Limit must be a positive number'); + } + if (nRecentDays === undefined) { + return await db + .select() + .from(auditLogs) + .where(eq(auditLogs.organizationId, orgId)) + .orderBy(desc(auditLogs.createdAt)) + .limit(limit); + } else { + if (isNaN(nRecentDays) || nRecentDays <= 0) { + throw new Error('nRecentDays must be a positive number'); + } + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - nRecentDays); + return await db + .select() + .from(auditLogs) + .where( + and( + eq(auditLogs.organizationId, orgId), + gte(auditLogs.createdAt, cutoff), + ), + ) + .orderBy(desc(auditLogs.createdAt)) + .limit(limit); + } + } catch (error) { + logger.error({ + event: 'audit_log_fetch_error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } +}; diff --git a/src/app/(authenticated)/dashboard/audit-logs/page.tsx b/src/app/(authenticated)/dashboard/audit-logs/page.tsx index 77e5383995..23a324772e 100644 --- a/src/app/(authenticated)/dashboard/audit-logs/page.tsx +++ b/src/app/(authenticated)/dashboard/audit-logs/page.tsx @@ -1,6 +1,8 @@ 'use client'; import { useState } from 'react'; +import { useOrganization } from '@clerk/nextjs'; +import { useQuery } from '@tanstack/react-query'; import { Button, @@ -12,18 +14,28 @@ import { import { TitleBar } from '@/components/dashboard/TitleBar'; import { AuditLogDetails } from '@/components/dashboard/AuditLogDetails'; import { AuditLogEntry } from '@/components/dashboard/AuditLogEntry'; -import type { AuditLog } from '@/components/dashboard/mockAuditLogs'; -import { getFilteredLogs } from '@/components/dashboard/mockAuditLogs'; +import type { AuditLogType } from '@/types/auditLogs'; +import { getAuditLogs } from '@/actions/auditLogs'; type TimePeriod = '7' | '30' | '90'; const AuditLogsPage = () => { const [timePeriod, setTimePeriod] = useState('7'); - const [selectedLog, setSelectedLog] = useState(null); + const [selectedLog, setSelectedLog] = useState(null); + const { organization } = useOrganization(); - const logs = getFilteredLogs(Number(timePeriod)); + const { data: logs = [], isLoading } = useQuery({ + queryKey: ['auditLogs', organization?.id, Number(timePeriod)], + queryFn: async () => + await getAuditLogs({ + orgId: organization?.id, + limit: 20, + nRecentDays: Number(timePeriod), + }), + enabled: !!organization?.id, + }); - const handleLogClick = (log: AuditLog) => setSelectedLog(log); + const handleLogClick = (log: AuditLogType) => setSelectedLog(log); const handleCloseDrawer = () => setSelectedLog(null); return ( @@ -63,8 +75,12 @@ const AuditLogsPage = () => {
- {logs.length > 0 ? ( - logs.map((log: AuditLog) => ( + {isLoading ? ( +
+

Loading...

+
+ ) : logs.length > 0 ? ( + logs.map((log: AuditLogType) => ( )) ) : ( diff --git a/src/app/api/provider-whitelist/allow-all-providers/route.ts b/src/app/api/provider-whitelist/allow-all-providers/route.ts new file mode 100644 index 0000000000..889d55fddf --- /dev/null +++ b/src/app/api/provider-whitelist/allow-all-providers/route.ts @@ -0,0 +1,92 @@ +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { AuditLogTargetType } from '@/types/auditLogs'; +import { logger } from '@/lib/server/logger'; +import { createAuditLog } from '@/lib/server/auditLogs'; + +// Allow all providers update schema +const allowAllProvidersSchema = z.object({ + allowAllProviders: z.boolean(), + policyVersion: z.number().int().positive(), +}); + +export type AllowAllProvidersRequest = z.infer; +export type AllowAllProvidersResponse = { + success: boolean; + message?: string; + error?: string; +}; + +// Note: I didn't really put any thought into the API as this is just stubs for the audit logs to work. +// Consider merging this with the other provider-whitelist endpoints. +// Please refactor! +export async function POST( + request: NextRequest, +): Promise> { + try { + const { userId, orgId } = await auth(); + if (!userId) { + return NextResponse.json( + { success: false, error: 'Unauthorized: User required' }, + { status: 401 }, + ); + } + if (!orgId) { + return NextResponse.json( + { success: false, error: 'Unauthorized: Organization required' }, + { status: 401 }, + ); + } + + const body = await request.json(); + const result = allowAllProvidersSchema.safeParse(body); + + if (!result.success) { + logger.warn({ + event: 'provider_whitelist_allow_all_providers_validation_failed', + errors: result.error.format(), + }); + + return NextResponse.json( + { success: false, error: 'Invalid request data' }, + { status: 400 }, + ); + } + + const data = result.data; + // TODO: persist data to the database + + const auditLogData = { + userId, + organizationId: orgId, + targetType: AuditLogTargetType.PROVIDER_WHITELIST, + targetId: 'allow-all-providers', + newValue: { allowAllProviders: data.allowAllProviders }, + description: `${data.allowAllProviders ? 'Enabled' : 'Disabled'} all providers`, + }; + await createAuditLog(auditLogData); + + return NextResponse.json({ + success: true, + message: 'Allow all providers setting updated successfully', + }); + } catch (error) { + logger.error({ + event: 'provider_whitelist_allow_all_providers_update_error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return NextResponse.json( + { + success: false, + error: + error instanceof Error + ? error.message + : 'An unexpected error occurred', + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/provider-whitelist/models/route.ts b/src/app/api/provider-whitelist/models/route.ts new file mode 100644 index 0000000000..a943d7f803 --- /dev/null +++ b/src/app/api/provider-whitelist/models/route.ts @@ -0,0 +1,95 @@ +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { AuditLogTargetType } from '@/types/auditLogs'; +import { logger } from '@/lib/server/logger'; +import { createAuditLog } from '@/lib/server/auditLogs'; + +// Model toggle update schema +const modelToggleSchema = z.object({ + providerId: z.string().min(1, 'Provider ID is required'), + modelId: z.string().min(1, 'Model ID is required'), + enabled: z.boolean(), + policyVersion: z.number().int().positive(), +}); + +export type ModelToggleRequest = z.infer; +export type ModelToggleResponse = { + success: boolean; + message?: string; + error?: string; +}; + +// Note: I didn't really put any thought into the API as this is just stubs for the audit logs to work. +// Consider merging this with the other provider-whitelist endpoints. +// Please refactor! +export async function POST( + request: NextRequest, +): Promise> { + try { + const { userId, orgId } = await auth(); + if (!userId) { + return NextResponse.json( + { success: false, error: 'Unauthorized: User required' }, + { status: 401 }, + ); + } + if (!orgId) { + return NextResponse.json( + { success: false, error: 'Unauthorized: Organization required' }, + { status: 401 }, + ); + } + + const body = await request.json(); + const result = modelToggleSchema.safeParse(body); + + if (!result.success) { + logger.warn({ + event: 'model_toggle_validation_failed', + errors: result.error.format(), + }); + + return NextResponse.json( + { success: false, error: 'Invalid request data' }, + { status: 400 }, + ); + } + + const data = result.data; + // TODO: persist data to the database + + const auditLogData = { + userId, + organizationId: orgId, + targetType: AuditLogTargetType.PROVIDER_WHITELIST, + targetId: `${data.providerId}:${data.modelId}`, + newValue: { enabled: data.enabled }, + description: `${data.enabled ? 'Enabled' : 'Disabled'} model ${data.modelId} for provider ${data.providerId}`, + }; + await createAuditLog(auditLogData); + + // Return success response + return NextResponse.json({ + success: true, + message: 'Model status updated successfully', + }); + } catch (error) { + logger.error({ + event: 'model_toggle_update_error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return NextResponse.json( + { + success: false, + error: + error instanceof Error + ? error.message + : 'An unexpected error occurred', + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/provider-whitelist/providers/route.ts b/src/app/api/provider-whitelist/providers/route.ts new file mode 100644 index 0000000000..356637a1b1 --- /dev/null +++ b/src/app/api/provider-whitelist/providers/route.ts @@ -0,0 +1,93 @@ +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { AuditLogTargetType } from '@/types/auditLogs'; +import { logger } from '@/lib/server/logger'; +import { createAuditLog } from '@/lib/server/auditLogs'; + +// Provider toggle update schema +const providerToggleSchema = z.object({ + providerId: z.string().min(1, 'Provider ID is required'), + enabled: z.boolean(), + policyVersion: z.number().int().positive(), +}); + +export type ProviderToggleRequest = z.infer; +export type ProviderToggleResponse = { + success: boolean; + message?: string; + error?: string; +}; + +// Note: I didn't really put any thought into the API as this is just stubs for the audit logs to work. +// Consider merging this with the other provider-whitelist endpoints. +// Please refactor! +export async function POST( + request: NextRequest, +): Promise> { + try { + const { userId, orgId } = await auth(); + if (!userId) { + return NextResponse.json( + { success: false, error: 'Unauthorized: User required' }, + { status: 401 }, + ); + } + if (!orgId) { + return NextResponse.json( + { success: false, error: 'Unauthorized: Organization required' }, + { status: 401 }, + ); + } + + const body = await request.json(); + const result = providerToggleSchema.safeParse(body); + + if (!result.success) { + logger.warn({ + event: 'provider_toggle_validation_failed', + errors: result.error.format(), + }); + + return NextResponse.json( + { success: false, error: 'Invalid request data' }, + { status: 400 }, + ); + } + + const data = result.data; + // TODO: persist data to the database + + const auditLogData = { + userId, + organizationId: orgId, + targetType: AuditLogTargetType.PROVIDER_WHITELIST, + targetId: data.providerId, + newValue: { enabled: data.enabled }, + description: `${data.enabled ? 'Enabled' : 'Disabled'} provider ${data.providerId}`, + }; + await createAuditLog(auditLogData); + + return NextResponse.json({ + success: true, + message: 'Provider status updated successfully', + }); + } catch (error) { + logger.error({ + event: 'provider_toggle_update_error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return NextResponse.json( + { + success: false, + error: + error instanceof Error + ? error.message + : 'An unexpected error occurred', + }, + { status: 500 }, + ); + } +} diff --git a/src/components/dashboard/AuditLogCard.tsx b/src/components/dashboard/AuditLogCard.tsx index 35b1daf782..199f247d2d 100644 --- a/src/components/dashboard/AuditLogCard.tsx +++ b/src/components/dashboard/AuditLogCard.tsx @@ -2,6 +2,8 @@ import Link from 'next/link'; import React, { useState } from 'react'; +import { useOrganization } from '@clerk/nextjs'; +import { useQuery } from '@tanstack/react-query'; import { Drawer, @@ -12,14 +14,21 @@ import { import { AuditLogDetails } from './AuditLogDetails'; import { AuditLogEntry } from './AuditLogEntry'; -import type { AuditLog } from './mockAuditLogs'; -import { mockAuditLogs } from './mockAuditLogs'; +import type { AuditLogType } from '@/types/auditLogs'; +import { getAuditLogs } from '@/actions/auditLogs'; export function AuditLogCard() { - const logs = mockAuditLogs.slice(0, 5); - const [selectedLog, setSelectedLog] = useState(null); + const [selectedLog, setSelectedLog] = useState(null); + const { organization } = useOrganization(); - const handleLogClick = (log: AuditLog) => setSelectedLog(log); + const { data: logs = [], isLoading } = useQuery({ + queryKey: ['auditLogs', organization?.id, 5], + queryFn: async () => + await getAuditLogs({ orgId: organization?.id, limit: 5 }), + enabled: !!organization?.id, + }); + + const handleLogClick = (log: AuditLogType) => setSelectedLog(log); const handleCloseDrawer = () => setSelectedLog(null); return ( @@ -33,8 +42,12 @@ export function AuditLogCard() { {/* Log entries */}
- {logs.length > 0 ? ( - logs.map((log: AuditLog) => ( + {isLoading ? ( +
+

Loading...

+
+ ) : logs.length > 0 ? ( + logs.map((log: AuditLogType) => ( )) ) : ( diff --git a/src/components/dashboard/AuditLogDetails.tsx b/src/components/dashboard/AuditLogDetails.tsx index 38ad9d1ee4..af60ade4d3 100644 --- a/src/components/dashboard/AuditLogDetails.tsx +++ b/src/components/dashboard/AuditLogDetails.tsx @@ -4,10 +4,10 @@ import { ArrowRight, Calendar, Clock, User } from 'lucide-react'; import Link from 'next/link'; import React from 'react'; -import type { AuditLog } from './mockAuditLogs'; +import type { AuditLogType } from '@/types/auditLogs'; type AuditLogDetailsProps = { - log: AuditLog; + log: AuditLogType; }; const formatValue = (value: unknown): React.ReactNode => { @@ -48,19 +48,19 @@ export function AuditLogDetails({ log }: AuditLogDetailsProps) {
- {log.timestamp.toLocaleDateString()} + {log.createdAt.toLocaleDateString()} - {log.timestamp.toLocaleTimeString()} + {log.createdAt.toLocaleTimeString()}
- {log.user} + {log.userId}
- {log.path && ( + {log.targetId && ( View in settings @@ -76,19 +76,10 @@ export function AuditLogDetails({ log }: AuditLogDetailsProps) {

- Before + New Value

- {formatValue(log.details.before)} -
-
- -
-

- After -

-
- {formatValue(log.details.after)} + {formatValue(log.newValue)}
diff --git a/src/components/dashboard/AuditLogEntry.tsx b/src/components/dashboard/AuditLogEntry.tsx index 86e26c1aa9..017ca5c877 100644 --- a/src/components/dashboard/AuditLogEntry.tsx +++ b/src/components/dashboard/AuditLogEntry.tsx @@ -5,21 +5,21 @@ import React from 'react'; import { cn } from '@/lib/utils'; -import type { AuditLog, AuditLogType } from './mockAuditLogs'; -import { getFormattedTime } from './mockAuditLogs'; +import { getFormattedTime } from '../../lib/dateUtils'; +import { type AuditLogType, AuditLogTargetType } from '@/types/auditLogs'; type AuditLogEntryProps = { - log: AuditLog; - onClick: (log: AuditLog) => void; + log: AuditLogType; + onClick: (log: AuditLogType) => void; }; -const getIconByType = (type: AuditLogType) => { +const getIconByType = (type: AuditLogTargetType) => { switch (type) { - case 'provider_whitelist': + case AuditLogTargetType.PROVIDER_WHITELIST: return ; - case 'default_parameters': + case AuditLogTargetType.DEFAULT_PARAMETERS: return ; - case 'member_change': + case AuditLogTargetType.MEMBER_CHANGE: return ; default: return ; @@ -36,71 +36,13 @@ export function AuditLogEntry({ log, onClick }: AuditLogEntryProps) { )} >
-
{getIconByType(log.type)}
+
{getIconByType(log.targetType)}

{log.description}

- - {/* Summary of changes */} -
- {log.type === 'provider_whitelist' && ( - - {(() => { - const before = log.details.before as string[]; - const after = log.details.after as string[]; - if (Array.isArray(before) && Array.isArray(after)) { - if (after.length > before.length) { - const added = after.filter((p) => !before.includes(p)); - return `Added: ${added.join(', ')}`; - } else { - const removed = before.filter((p) => !after.includes(p)); - return `Removed: ${removed.join(', ')}`; - } - } - return ''; - })()} - - )} - - {log.type === 'default_parameters' && ( - - {(() => { - const before = log.details.before as Record; - const after = log.details.after as Record; - if ( - before && - after && - typeof before === 'object' && - typeof after === 'object' - ) { - return Object.entries(after) - .filter(([key, val]) => before[key] !== val) - .map(([key, val]) => `${key}: ${before[key]} → ${val}`) - .join(', '); - } - return ''; - })()} - - )} - - {log.type === 'member_change' && ( - - {(() => { - if (!log.details.before) { - return 'New member added'; - } - if (!log.details.after) { - return 'Member removed'; - } - return 'Role updated'; - })()} - - )} -
-
-

{log.user}

+

{log.userId}

- {getFormattedTime(log.timestamp)} + {getFormattedTime(log.createdAt)}

diff --git a/src/components/dashboard/ProviderWhitelistPage.tsx b/src/components/dashboard/ProviderWhitelistPage.tsx index cb401222f7..e6f1b3c427 100644 --- a/src/components/dashboard/ProviderWhitelistPage.tsx +++ b/src/components/dashboard/ProviderWhitelistPage.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'; import { useState } from 'react'; import { Badge, Checkbox, Label } from '@/components/ui'; +import { toast } from 'sonner'; const initialProviders = [ { @@ -53,23 +54,134 @@ const ProviderWhitelistPage = () => { const [providers, setProviders] = useState(initialProviders); const [policyVersion] = useState(1); const [allowAllProviders, setAllowAllProviders] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + + // API functions to send provider whitelist data to backend + // Note: I'm not sure that it actually makes sense to have three separate endpoints here, + // and I didn't really put any thought into their API as they're just stubs for the audit logs to work. + // Please refactor! + const updateAllowAllProvidersAPI = async (allowAllProviders: boolean) => { + setIsUpdating(true); + try { + const response = await fetch( + '/api/provider-whitelist/allow-all-providers', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + allowAllProviders, + policyVersion, + }), + }, + ); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + console.log('Allow all providers setting updated successfully', { + allowAllProviders, + }); + } catch (error) { + console.error('Failed to update global settings:', error); + toast.error( + 'Failed to update allow all providers setting. Please try again.', + ); + } finally { + setIsUpdating(false); + } + }; + + const updateProviderAPI = async (providerId: string, enabled: boolean) => { + setIsUpdating(true); + try { + const response = await fetch('/api/provider-whitelist/providers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + providerId, + enabled, + policyVersion, + }), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + console.log('Provider status updated successfully', { + providerId, + enabled, + }); + } catch (error) { + console.error('Failed to update provider status:', error); + toast.error('Failed to update provider status. Please try again.'); + } finally { + setIsUpdating(false); + } + }; + + const updateModelAPI = async ( + providerId: string, + modelId: string, + enabled: boolean, + ) => { + setIsUpdating(true); + try { + const response = await fetch('/api/provider-whitelist/models', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + providerId, + modelId, + enabled, + policyVersion, + }), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + console.log('Model status updated successfully', { + providerId, + modelId, + enabled, + }); + } catch (error) { + console.error('Failed to update model status:', error); + toast.error('Failed to update model status. Please try again.'); + } finally { + setIsUpdating(false); + } + }; const toggleAllowAllProviders = () => { const newAllowAllProviders = !allowAllProviders; setAllowAllProviders(newAllowAllProviders); + let updatedProviders = providers; if (newAllowAllProviders) { - setProviders( - providers.map((provider) => ({ - ...provider, + // Enable all providers and their models + updatedProviders = providers.map((provider) => ({ + ...provider, + enabled: true, + models: provider.models.map((model) => ({ + ...model, enabled: true, - models: provider.models.map((model) => ({ - ...model, - enabled: true, - })), })), - ); + })); + setProviders(updatedProviders); } + + // Call API to update backend with the changed allow all providers setting + updateAllowAllProvidersAPI(newAllowAllProviders); }; const toggleProvider = (providerId: string) => { @@ -77,22 +189,31 @@ const ProviderWhitelistPage = () => { return; } - setProviders( - providers.map((provider) => { - if (provider.id === providerId) { - const newEnabled = !provider.enabled; - return { - ...provider, - enabled: newEnabled, - models: provider.models.map((model) => ({ - ...model, - enabled: newEnabled ? model.enabled : false, - })), - }; - } - return provider; - }), - ); + // Find the provider to get its current state + const provider = providers.find((p) => p.id === providerId); + if (!provider) return; + + const newEnabled = !provider.enabled; + + const updatedProviders = providers.map((provider) => { + if (provider.id === providerId) { + return { + ...provider, + enabled: newEnabled, + // If provider is disabled, disable all its models + models: provider.models.map((model) => ({ + ...model, + enabled: newEnabled ? model.enabled : false, + })), + }; + } + return provider; + }); + + setProviders(updatedProviders); + + // Call API to update backend with the changed provider status + updateProviderAPI(providerId, newEnabled); }; const toggleModel = (providerId: string, modelId: string) => { @@ -100,22 +221,34 @@ const ProviderWhitelistPage = () => { return; } - setProviders( - providers.map((provider) => { - if (provider.id === providerId) { - return { - ...provider, - models: provider.models.map((model) => { - if (model.id === modelId) { - return { ...model, enabled: !model.enabled }; - } - return model; - }), - }; - } - return provider; - }), - ); + // Find the model to get its current state + const provider = providers.find((p) => p.id === providerId); + if (!provider) return; + + const model = provider.models.find((m) => m.id === modelId); + if (!model) return; + + const newEnabled = !model.enabled; + + const updatedProviders = providers.map((provider) => { + if (provider.id === providerId) { + return { + ...provider, + models: provider.models.map((model) => { + if (model.id === modelId) { + return { ...model, enabled: newEnabled }; + } + return model; + }), + }; + } + return provider; + }); + + setProviders(updatedProviders); + + // Call API to update backend with the changed model status + updateModelAPI(providerId, modelId, newEnabled); }; return ( @@ -195,7 +328,9 @@ const ProviderWhitelistPage = () => { {`Policy v${policyVersion}`} - Changes will be pushed to SSE stream within 30 seconds + {isUpdating + ? 'Updating provider whitelist...' + : 'Changes will be pushed to SSE stream within 30 seconds'}
diff --git a/src/components/dashboard/mockAuditLogs.ts b/src/components/dashboard/mockAuditLogs.ts deleted file mode 100644 index df57bcd39c..0000000000 --- a/src/components/dashboard/mockAuditLogs.ts +++ /dev/null @@ -1,144 +0,0 @@ -export type AuditLogType = - | 'provider_whitelist' - | 'default_parameters' - | 'member_change'; - -export type AuditLogDetails = { - before: unknown; - after: unknown; -}; - -export type AuditLog = { - id: string; - type: AuditLogType; - description: string; - timestamp: Date; - user: string; - details: AuditLogDetails; - path?: string; -}; - -export const mockAuditLogs: AuditLog[] = [ - { - id: '1', - type: 'provider_whitelist', - description: 'Added OpenAI to provider whitelist', - timestamp: new Date(2025, 4, 10, 14, 30), - user: 'John Doe', - details: { - before: ['Anthropic', 'Cohere'], - after: ['Anthropic', 'Cohere', 'OpenAI'], - }, - path: '/dashboard/organization-profile/provider-whitelist', - }, - { - id: '2', - type: 'default_parameters', - description: 'Updated default temperature parameter', - timestamp: new Date(2025, 4, 9, 11, 15), - user: 'Jane Smith', - details: { - before: { temperature: 0.7 }, - after: { temperature: 0.9 }, - }, - path: '/dashboard/organization-profile/default-parameters', - }, - { - id: '3', - type: 'member_change', - description: 'Changed role for Alex Johnson from Member to Admin', - timestamp: new Date(2025, 4, 8, 9, 45), - user: 'Sarah Williams', - details: { - before: { role: 'Member' }, - after: { role: 'Admin' }, - }, - path: '/dashboard/organization-profile/organization-members', - }, - { - id: '4', - type: 'provider_whitelist', - description: 'Removed Claude from provider whitelist', - timestamp: new Date(2025, 4, 7, 16, 20), - user: 'Michael Brown', - details: { - before: ['OpenAI', 'Claude', 'Cohere'], - after: ['OpenAI', 'Cohere'], - }, - path: '/dashboard/organization-profile/provider-whitelist', - }, - { - id: '5', - type: 'default_parameters', - description: 'Updated max tokens parameter', - timestamp: new Date(2025, 4, 6, 13, 10), - user: 'Emily Davis', - details: { - before: { max_tokens: 1000 }, - after: { max_tokens: 2000 }, - }, - path: '/dashboard/organization-profile/default-parameters', - }, - { - id: '6', - type: 'member_change', - description: 'Added new member David Wilson', - timestamp: new Date(2025, 4, 5, 10, 30), - user: 'John Doe', - details: { - before: null, - after: { - name: 'David Wilson', - email: 'david@example.com', - role: 'Member', - }, - }, - path: '/dashboard/organization-profile/organization-members', - }, - { - id: '7', - type: 'member_change', - description: 'Removed member Lisa Taylor', - timestamp: new Date(2025, 4, 4, 15, 45), - user: 'Sarah Williams', - details: { - before: { - name: 'Lisa Taylor', - email: 'lisa@example.com', - role: 'Member', - }, - after: null, - }, - path: '/dashboard/organization-profile/organization-members', - }, -]; - -export const getFilteredLogs = (days: number): AuditLog[] => { - const now = new Date(); - const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); - - return mockAuditLogs.filter((log) => log.timestamp >= cutoff); -}; - -export const getFormattedTime = (date: Date): string => { - const now = new Date(); - const diffInHours = Math.floor( - (now.getTime() - date.getTime()) / (1000 * 60 * 60), - ); - - if (diffInHours < 24) { - return diffInHours === 0 - ? 'Just now' - : diffInHours === 1 - ? '1 hour ago' - : `${diffInHours} hours ago`; - } - - const diffInDays = Math.floor(diffInHours / 24); - - if (diffInDays < 7) { - return diffInDays === 1 ? 'Yesterday' : `${diffInDays} days ago`; - } - - return date.toLocaleDateString(); -}; diff --git a/migrations/.gitkeep b/src/db/migrations/.gitkeep similarity index 100% rename from migrations/.gitkeep rename to src/db/migrations/.gitkeep diff --git a/src/db/migrations/0000_daffy_psylocke.sql b/src/db/migrations/0000_daffy_psylocke.sql new file mode 100644 index 0000000000..c9aeb22eb6 --- /dev/null +++ b/src/db/migrations/0000_daffy_psylocke.sql @@ -0,0 +1,10 @@ +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + "user_id" text NOT NULL, + "organization_id" text NOT NULL, + "target_type" integer NOT NULL, + "target_id" text NOT NULL, + "new_value" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "description" text NOT NULL +); diff --git a/src/db/migrations/meta/0000_snapshot.json b/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000000..a6ce0433b6 --- /dev/null +++ b/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,81 @@ +{ + "id": "6600a193-62ff-4475-9533-eb1dafc7339e", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_value": { + "name": "new_value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000000..3107751770 --- /dev/null +++ b/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1747347845063, + "tag": "0000_daffy_psylocke", + "breakpoints": true + } + ] +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 70b786d12e..c35fd13a92 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1 +1,22 @@ -// TODO +import { + pgTable, + text, + timestamp, + integer, + jsonb, + uuid, +} from 'drizzle-orm/pg-core'; + +// AuditLogType +export const auditLogs = pgTable('audit_logs', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + organizationId: text('organization_id').notNull(), + targetType: integer('target_type').notNull(), // AuditLogTargetType + targetId: text('target_id').notNull(), + newValue: jsonb('new_value').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + description: text('description').notNull(), +}); diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts new file mode 100644 index 0000000000..a9ad825c70 --- /dev/null +++ b/src/lib/dateUtils.ts @@ -0,0 +1,23 @@ +// Format time for display - this is a client-side utility function +export function getFormattedTime(date: Date): string { + const now = new Date(); + const diffInHours = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60), + ); + + if (diffInHours < 24) { + return diffInHours === 0 + ? 'Just now' + : diffInHours === 1 + ? '1 hour ago' + : `${diffInHours} hours ago`; + } + + const diffInDays = Math.floor(diffInHours / 24); + + if (diffInDays < 7) { + return diffInDays === 1 ? 'Yesterday' : `${diffInDays} days ago`; + } + + return date.toLocaleDateString(); +} diff --git a/src/lib/server/auditLogs.ts b/src/lib/server/auditLogs.ts new file mode 100644 index 0000000000..8e2f99fb84 --- /dev/null +++ b/src/lib/server/auditLogs.ts @@ -0,0 +1,50 @@ +import { db } from '@/db'; +import { auditLogs } from '@/db/schema'; +import { auditLogSchema } from '@/types/auditLogs'; +import { logger } from '@/lib/server/logger'; +import type { z } from 'zod'; + +export type AuditLogCreateRequest = z.infer; + +/** + * Server-side function to create audit logs in the database + * @param request The audit log data to create + * @returns Object containing success status and created log ID + */ +export async function createAuditLog(request: AuditLogCreateRequest): Promise<{ + success: boolean; + error?: string | Record; +}> { + try { + await db.insert(auditLogs).values({ + userId: request.userId, + organizationId: request.organizationId, + targetType: request.targetType, + targetId: request.targetId, + newValue: request.newValue, + description: request.description, + // id and createdAt will be set automatically by the database + }); + + logger.info({ + event: 'audit_log_created', + userId: request.userId, + organizationId: request.organizationId, + targetType: request.targetType, + }); + + return { + success: true, + }; + } catch (error) { + logger.error({ + event: 'audit_log_creation_error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + return { + success: false, + error: + error instanceof Error ? error.message : 'An unexpected error occurred', + }; + } +} diff --git a/src/types/auditLogs.ts b/src/types/auditLogs.ts new file mode 100644 index 0000000000..09356f7f6e --- /dev/null +++ b/src/types/auditLogs.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * Client-safe type definitions for audit logs + * These types mirror the database schema but are safe to import in client components + */ + +export enum AuditLogTargetType { + PROVIDER_WHITELIST = 1, + DEFAULT_PARAMETERS = 2, + MEMBER_CHANGE = 3, // TODO: Currently no logs of this type are collected +} + +export interface AuditLogType { + id: string; + userId: string; + organizationId: string; + targetType: AuditLogTargetType; + targetId: string; + newValue: unknown; // JSONB in the database + createdAt: Date; + description: string; +} + +// Validation schema for audit log creation +export const auditLogSchema = z.object({ + userId: z.string().min(1, 'User ID is required'), + // You should only create audit logs for the authenticated organization + organizationId: z.string().min(1, 'Organization ID is required'), + targetType: z.nativeEnum(AuditLogTargetType, { + errorMap: () => ({ message: 'Target type must be a valid enum value' }), + }), + targetId: z.string().min(1, 'Target ID is required'), + newValue: z.any().refine((val) => val !== undefined, 'New value is required'), + description: z.string().min(1, 'Description is required'), +});