diff --git a/ROADMAP.md b/ROADMAP.md index 16f898d..869c3cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # NATS JetStream Console - Roadmap -## Current Status: Alpha (v0.3.0) +## Current Status: Beta (v0.5.0) -Phase 1 (Core MVP), Phase 2 (Detail Views & Forms), and Phase 3 (Real-time & Monitoring) are complete. The application now includes full CRUD operations, detailed management views, metrics charts, real-time WebSocket updates, and a complete alert system with notification channels. +Phases 1-5 are complete. The application now includes full CRUD operations, detailed management views, metrics charts, real-time WebSocket updates, alert system with notification channels, message search/export/replay, multi-tenancy features (RBAC, API keys, audit logging, 2FA), custom dashboards, and enterprise features (IP allowlisting, data retention policies, audit trail export, GDPR compliance tools, and compliance reports). --- @@ -110,45 +110,45 @@ Phase 1 (Core MVP), Phase 2 (Detail Views & Forms), and Phase 3 (Real-time & Mon --- -## Phase 4: Advanced Features +## Phase 4: Advanced Features ✅ (Completed) ### Message Operations -- [ ] Message search with filters -- [ ] Message replay functionality +- [x] Message search with filters +- [x] Message replay functionality - [ ] Dead letter queue management - [ ] Message schema viewer -- [ ] Export messages to JSON/CSV +- [x] Export messages to JSON/CSV ### Multi-tenancy -- [ ] Organization management -- [ ] Team management -- [ ] Role-based access control (RBAC) -- [ ] Audit logging -- [ ] API key management +- [x] Organization management +- [x] Team management +- [x] Role-based access control (RBAC) +- [x] Audit logging +- [x] API key management ### Custom Dashboards -- [ ] Dashboard builder -- [ ] Widget library +- [x] Dashboard builder +- [x] Widget library - [ ] Saved queries - [ ] Dashboard sharing - [ ] Export to PDF --- -## Phase 5: Enterprise Features +## Phase 5: Enterprise Features ✅ (Completed) ### Security - [ ] SSO/SAML integration -- [ ] Two-factor authentication -- [ ] IP allowlisting +- [x] Two-factor authentication +- [x] IP allowlisting - [ ] Encryption at rest - [ ] Secrets management ### Compliance -- [ ] GDPR compliance tools -- [ ] Data retention policies -- [ ] Audit trail export -- [ ] Compliance reports +- [x] GDPR compliance tools +- [x] Data retention policies +- [x] Audit trail export +- [x] Compliance reports ### Scaling - [ ] Multi-region support @@ -217,13 +217,14 @@ Phase 1 (Core MVP), Phase 2 (Detail Views & Forms), and Phase 3 (Real-time & Mon ## Release Schedule -| Version | Target | Focus | +| Version | Status | Focus | |---------|--------|-------| -| 0.1.0 | Current | Core MVP | -| 0.2.0 | +2 weeks | Detail views & forms | -| 0.3.0 | +4 weeks | Real-time & monitoring | -| 0.4.0 | +6 weeks | Advanced features | -| 1.0.0 | +10 weeks | Production ready | +| 0.1.0 | ✅ Complete | Core MVP | +| 0.2.0 | ✅ Complete | Detail views & forms | +| 0.3.0 | ✅ Complete | Real-time & monitoring | +| 0.4.0 | ✅ Complete | Advanced features | +| 0.5.0 | ✅ Current | Enterprise features | +| 1.0.0 | Planned | Production ready | --- diff --git a/apps/api/package.json b/apps/api/package.json index a9d9385..c661216 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -35,10 +35,12 @@ "pino": "^9.5.0", "qrcode": "^1.5.4", "pino-pretty": "^13.0.0", + "ws": "^8.18.0", "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^22.10.1", + "@types/ws": "^8.5.13", "prisma": "^6.0.1", "tsx": "^4.19.2", "typescript": "^5.7.2", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5925024..5f02c54 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -307,15 +307,16 @@ model ApiKey { // ==================== Dashboards & Saved Queries ==================== model Dashboard { - id String @id @default(uuid()) @db.Uuid - orgId String @map("org_id") @db.Uuid - userId String @map("user_id") @db.Uuid - name String @db.VarChar(100) - layout Json @default("{\"columns\": 12, \"rowHeight\": 80}") @db.JsonB - widgets Json @default("[]") @db.JsonB - isShared Boolean @default(false) @map("is_shared") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) @db.Uuid + orgId String @map("org_id") @db.Uuid + userId String @map("user_id") @db.Uuid + name String @db.VarChar(100) + description String? @db.Text + layout Json @default("{\"columns\": 12, \"rowHeight\": 80}") @db.JsonB + widgets Json @default("[]") @db.JsonB + isShared Boolean @default(false) @map("is_shared") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // Relations organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) diff --git a/apps/api/src/common/middleware/audit.ts b/apps/api/src/common/middleware/audit.ts index 3f22dda..6e2ccd9 100644 --- a/apps/api/src/common/middleware/audit.ts +++ b/apps/api/src/common/middleware/audit.ts @@ -152,12 +152,10 @@ function getAuditInfo(url: string, body?: unknown): AuditInfo | null { export async function auditLogger( request: FastifyRequest, - reply: FastifyReply, - done: () => void + reply: FastifyReply ): Promise { // Skip if no user (unauthenticated request) if (!request.user) { - done(); return; } @@ -166,20 +164,15 @@ export async function auditLogger( // Skip read operations if configured if (SKIP_READ_AUDIT && action === 'read') { - done(); return; } // Get audit info for this route const auditInfo = getAuditInfo(request.url, request.body); if (!auditInfo) { - done(); return; } - // Store start time for latency calculation - const startTime = Date.now(); - // Add hook to log after response reply.addHook('onSend', async (_request, _reply, payload) => { try { @@ -211,8 +204,6 @@ export async function auditLogger( return payload; }); - - done(); } // Fastify plugin for audit logging diff --git a/apps/api/src/common/middleware/auth.ts b/apps/api/src/common/middleware/auth.ts index 0ce3093..00e3740 100644 --- a/apps/api/src/common/middleware/auth.ts +++ b/apps/api/src/common/middleware/auth.ts @@ -1,10 +1,13 @@ import type { FastifyRequest, FastifyReply } from 'fastify'; import * as argon2 from 'argon2'; -import { verifyToken, getPermissionsForRole } from '../../modules/auth/auth.service'; +import { verifyToken as verifyJwtToken, getPermissionsForRole } from '../../modules/auth/auth.service'; import { getSession, updateSessionActivity } from '../../lib/redis'; import { prisma } from '../../lib/prisma'; import type { JwtPayload } from '../../../../shared/src/index'; +// Re-export verifyToken for use by other modules (e.g., websocket) +export { verifyJwtToken as verifyToken }; + // Extend FastifyRequest to include user and apiKeyId declare module 'fastify' { interface FastifyRequest { @@ -137,7 +140,7 @@ export async function authenticate( // Regular JWT token try { - const payload = await verifyToken(token); + const payload = await verifyJwtToken(token); request.user = payload; // Validate session in Redis and update activity @@ -178,7 +181,7 @@ export async function optionalAuth( const token = authHeader.slice(7); try { - const payload = await verifyToken(token); + const payload = await verifyJwtToken(token); request.user = payload; } catch { // Invalid token, continue without user diff --git a/apps/api/src/common/middleware/ip-allowlist.ts b/apps/api/src/common/middleware/ip-allowlist.ts new file mode 100644 index 0000000..af7e5fd --- /dev/null +++ b/apps/api/src/common/middleware/ip-allowlist.ts @@ -0,0 +1,181 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import { prisma } from '../../lib/prisma'; +import { getCache, setCache } from '../../lib/redis'; + +const IP_ALLOWLIST_CACHE_TTL = 300; // 5 minutes + +interface IpAllowlistConfig { + enabled: boolean; + allowedIps: string[]; + allowedCidrs: string[]; +} + +// Check if an IP matches a CIDR range +function ipMatchesCidr(ip: string, cidr: string): boolean { + const [range, bits] = cidr.split('/'); + if (!bits) return ip === range; + + const mask = parseInt(bits, 10); + if (isNaN(mask) || mask < 0 || mask > 32) return false; + + const ipParts = ip.split('.').map(Number); + const rangeParts = range.split('.').map(Number); + + if (ipParts.length !== 4 || rangeParts.length !== 4) return false; + if (ipParts.some(isNaN) || rangeParts.some(isNaN)) return false; + + const ipNum = + (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; + const rangeNum = + (rangeParts[0] << 24) | + (rangeParts[1] << 16) | + (rangeParts[2] << 8) | + rangeParts[3]; + const maskNum = ~((1 << (32 - mask)) - 1); + + return (ipNum & maskNum) === (rangeNum & maskNum); +} + +// Get IP allowlist config for an organization +async function getIpAllowlistConfig( + orgId: string +): Promise { + // Try cache first + const cacheKey = `ip-allowlist:${orgId}`; + const cached = await getCache(cacheKey); + if (cached) return cached; + + // Fetch from database + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { settings: true }, + }); + + if (!org) return null; + + const settings = org.settings as Record; + const ipAllowlist = settings?.ipAllowlist as IpAllowlistConfig | undefined; + + const config: IpAllowlistConfig = { + enabled: ipAllowlist?.enabled ?? false, + allowedIps: ipAllowlist?.allowedIps ?? [], + allowedCidrs: ipAllowlist?.allowedCidrs ?? [], + }; + + // Cache the config + await setCache(cacheKey, config, IP_ALLOWLIST_CACHE_TTL); + + return config; +} + +// Check if an IP is allowed for an organization +function isIpAllowed(ip: string, config: IpAllowlistConfig): boolean { + if (!config.enabled) return true; + + // Always allow localhost + if (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost') { + return true; + } + + // Check exact IP matches + if (config.allowedIps.includes(ip)) return true; + + // Check CIDR matches + for (const cidr of config.allowedCidrs) { + if (ipMatchesCidr(ip, cidr)) return true; + } + + return false; +} + +// Get client IP from request +function getClientIp(request: FastifyRequest): string { + // Check X-Forwarded-For header (for proxied requests) + const forwarded = request.headers['x-forwarded-for']; + if (forwarded) { + const ips = Array.isArray(forwarded) ? forwarded[0] : forwarded; + return ips.split(',')[0].trim(); + } + + // Check X-Real-IP header + const realIp = request.headers['x-real-ip']; + if (realIp) { + return Array.isArray(realIp) ? realIp[0] : realIp; + } + + // Fall back to socket remote address + return request.ip; +} + +// IP allowlist middleware +export async function ipAllowlistMiddleware( + request: FastifyRequest, + reply: FastifyReply +): Promise { + // Skip if user not authenticated yet + if (!request.user?.orgId) return; + + const config = await getIpAllowlistConfig(request.user.orgId); + if (!config || !config.enabled) return; + + const clientIp = getClientIp(request); + + if (!isIpAllowed(clientIp, config)) { + return reply.status(403).send({ + error: { + code: 'IP_NOT_ALLOWED', + message: `Access denied. Your IP address (${clientIp}) is not in the allowlist.`, + }, + }); + } +} + +// Helper to invalidate IP allowlist cache +export async function invalidateIpAllowlistCache(orgId: string): Promise { + const cacheKey = `ip-allowlist:${orgId}`; + await setCache(cacheKey, null, 0); +} + +// Validate IP allowlist configuration +export function validateIpAllowlistConfig(config: unknown): { + valid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + if (typeof config !== 'object' || config === null) { + return { valid: false, errors: ['Configuration must be an object'] }; + } + + const c = config as Record; + + if (c.enabled !== undefined && typeof c.enabled !== 'boolean') { + errors.push('enabled must be a boolean'); + } + + if (c.allowedIps !== undefined) { + if (!Array.isArray(c.allowedIps)) { + errors.push('allowedIps must be an array'); + } else { + for (const ip of c.allowedIps) { + if (typeof ip !== 'string' || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) { + errors.push(`Invalid IP address: ${ip}`); + } + } + } + } + + if (c.allowedCidrs !== undefined) { + if (!Array.isArray(c.allowedCidrs)) { + errors.push('allowedCidrs must be an array'); + } else { + for (const cidr of c.allowedCidrs) { + if (typeof cidr !== 'string' || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/.test(cidr)) { + errors.push(`Invalid CIDR: ${cidr}`); + } + } + } + } + + return { valid: errors.length === 0, errors }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 77f7722..719084e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,7 @@ import { inviteRoutes } from './modules/invites/invites.routes'; import { settingsRoutes } from './modules/settings/settings.routes'; import { auditRoutes } from './modules/audit/audit.routes'; import { auditPlugin } from './common/middleware/audit'; +import { ipAllowlistMiddleware } from './common/middleware/ip-allowlist'; const app = Fastify({ logger: { @@ -82,6 +83,9 @@ app.register( // Register audit logging middleware await api.register(auditPlugin); + // Register IP allowlist middleware (runs after authentication) + api.addHook('preHandler', ipAllowlistMiddleware); + // Register all routes await api.register(authRoutes, { prefix: '/auth' }); await api.register(organizationRoutes, { prefix: '/organizations' }); diff --git a/apps/api/src/modules/dashboards/dashboards.routes.ts b/apps/api/src/modules/dashboards/dashboards.routes.ts index b796d2a..6e6411a 100644 --- a/apps/api/src/modules/dashboards/dashboards.routes.ts +++ b/apps/api/src/modules/dashboards/dashboards.routes.ts @@ -32,6 +32,7 @@ export const dashboardRoutes: FastifyPluginAsync = async (fastify) => { orgId: request.user!.orgId, userId: request.user!.sub, name: body.name, + description: body.description, layout: body.layout as any, widgets: body.widgets as any, isShared: body.isShared, @@ -81,6 +82,7 @@ export const dashboardRoutes: FastifyPluginAsync = async (fastify) => { where: { id: request.params.id }, data: { name: body.name, + description: body.description, layout: body.layout as any, widgets: body.widgets as any, isShared: body.isShared, diff --git a/apps/api/src/modules/settings/settings.routes.ts b/apps/api/src/modules/settings/settings.routes.ts index 18d654f..378bf89 100644 --- a/apps/api/src/modules/settings/settings.routes.ts +++ b/apps/api/src/modules/settings/settings.routes.ts @@ -3,8 +3,13 @@ import { z } from 'zod'; import * as crypto from 'crypto'; import * as argon2 from 'argon2'; import { prisma } from '../../lib/prisma'; -import { authenticate } from '../../common/middleware/auth'; -import { NotFoundError } from '../../../../shared/src/index'; +import { authenticate, requirePermission } from '../../common/middleware/auth'; +import { NotFoundError, ForbiddenError } from '../../../../shared/src/index'; +import { + validateIpAllowlistConfig, + invalidateIpAllowlistCache, +} from '../../common/middleware/ip-allowlist'; +import { queryAuditLogs } from '../../lib/clickhouse'; // Schemas const UserSettingsSchema = z.object({ @@ -21,6 +26,21 @@ const CreateApiKeySchema = z.object({ expiresIn: z.enum(['never', '30d', '90d', '1y']).optional(), }); +// IP Allowlist Schema +const IpAllowlistSchema = z.object({ + enabled: z.boolean(), + allowedIps: z.array(z.string()).default([]), + allowedCidrs: z.array(z.string()).default([]), +}); + +// Data Retention Policy Schema +const DataRetentionPolicySchema = z.object({ + metricsRetentionDays: z.number().int().min(1).max(365).default(30), + auditLogsRetentionDays: z.number().int().min(1).max(365).default(90), + alertEventsRetentionDays: z.number().int().min(1).max(365).default(90), + messageSamplesRetentionDays: z.number().int().min(1).max(30).default(7), +}); + export const settingsRoutes: FastifyPluginAsync = async (fastify) => { fastify.addHook('preHandler', authenticate); @@ -158,4 +178,420 @@ export const settingsRoutes: FastifyPluginAsync = async (fastify) => { return reply.status(204).send(); }); + + // ==================== Security Settings (Admin Only) ==================== + + // GET /settings/security/ip-allowlist - Get IP allowlist config + fastify.get('/security/ip-allowlist', async (request) => { + // Only admins/owners can view security settings + if (!['owner', 'admin'].includes(request.user!.role)) { + throw new ForbiddenError('Only admins can view security settings'); + } + + const org = await prisma.organization.findUnique({ + where: { id: request.user!.orgId }, + select: { settings: true }, + }); + + const settings = (org?.settings as Record) || {}; + const ipAllowlist = settings.ipAllowlist || { + enabled: false, + allowedIps: [], + allowedCidrs: [], + }; + + return { ipAllowlist }; + }); + + // PUT /settings/security/ip-allowlist - Update IP allowlist config + fastify.put('/security/ip-allowlist', async (request) => { + // Only admins/owners can update security settings + if (!['owner', 'admin'].includes(request.user!.role)) { + throw new ForbiddenError('Only admins can update security settings'); + } + + const body = IpAllowlistSchema.parse(request.body); + + // Validate the configuration + const validation = validateIpAllowlistConfig(body); + if (!validation.valid) { + return { error: { code: 'INVALID_CONFIG', message: validation.errors.join(', ') } }; + } + + // Get current settings and merge + const org = await prisma.organization.findUnique({ + where: { id: request.user!.orgId }, + select: { settings: true }, + }); + + const currentSettings = (org?.settings as Record) || {}; + const newSettings = { + ...currentSettings, + ipAllowlist: body, + }; + + await prisma.organization.update({ + where: { id: request.user!.orgId }, + data: { settings: newSettings }, + }); + + // Invalidate cache + await invalidateIpAllowlistCache(request.user!.orgId); + + return { ipAllowlist: body }; + }); + + // ==================== Data Retention Policies ==================== + + // GET /settings/compliance/retention - Get data retention policies + fastify.get('/compliance/retention', async (request) => { + if (!['owner', 'admin'].includes(request.user!.role)) { + throw new ForbiddenError('Only admins can view compliance settings'); + } + + const org = await prisma.organization.findUnique({ + where: { id: request.user!.orgId }, + select: { settings: true }, + }); + + const settings = (org?.settings as Record) || {}; + const retention = settings.dataRetention || { + metricsRetentionDays: 30, + auditLogsRetentionDays: 90, + alertEventsRetentionDays: 90, + messageSamplesRetentionDays: 7, + }; + + return { retention }; + }); + + // PUT /settings/compliance/retention - Update data retention policies + fastify.put('/compliance/retention', async (request) => { + if (!['owner', 'admin'].includes(request.user!.role)) { + throw new ForbiddenError('Only admins can update compliance settings'); + } + + const body = DataRetentionPolicySchema.parse(request.body); + + const org = await prisma.organization.findUnique({ + where: { id: request.user!.orgId }, + select: { settings: true }, + }); + + const currentSettings = (org?.settings as Record) || {}; + const newSettings = { + ...currentSettings, + dataRetention: body, + }; + + await prisma.organization.update({ + where: { id: request.user!.orgId }, + data: { settings: newSettings }, + }); + + return { retention: body }; + }); + + // ==================== Audit Trail Export ==================== + + // GET /settings/compliance/audit-export - Export audit logs + fastify.get<{ + Querystring: { + from?: string; + to?: string; + action?: string; + format?: 'json' | 'csv'; + }; + }>('/compliance/audit-export', async (request, reply) => { + if (!['owner', 'admin'].includes(request.user!.role)) { + throw new ForbiddenError('Only admins can export audit logs'); + } + + const { from, to, action, format = 'json' } = request.query; + + const result = await queryAuditLogs(request.user!.orgId, { + from: from ? new Date(from) : undefined, + to: to ? new Date(to) : undefined, + action, + limit: 10000, // Max export size + }); + + if (format === 'csv') { + const headers = [ + 'id', + 'timestamp', + 'action', + 'resourceType', + 'resourceId', + 'resourceName', + 'userEmail', + 'ipAddress', + 'status', + ]; + + const csvRows = [headers.join(',')]; + for (const log of result.logs) { + csvRows.push( + [ + log.id, + log.timestamp.toISOString(), + log.action, + log.resourceType, + log.resourceId, + log.resourceName, + log.userEmail, + log.ipAddress, + log.status, + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(',') + ); + } + + reply.header('Content-Type', 'text/csv'); + reply.header( + 'Content-Disposition', + `attachment; filename="audit-logs-${new Date().toISOString().split('T')[0]}.csv"` + ); + return csvRows.join('\n'); + } + + reply.header('Content-Type', 'application/json'); + reply.header( + 'Content-Disposition', + `attachment; filename="audit-logs-${new Date().toISOString().split('T')[0]}.json"` + ); + return { logs: result.logs, total: result.total, exportedAt: new Date() }; + }); + + // ==================== GDPR Compliance ==================== + + // GET /settings/gdpr/export - Export user data (GDPR right to data portability) + fastify.get('/gdpr/export', async (request, reply) => { + const userId = request.user!.sub; + + // Gather all user data + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + status: true, + emailVerified: true, + mfaEnabled: true, + lastLoginAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + const organizationMemberships = await prisma.organizationMember.findMany({ + where: { userId }, + include: { + organization: { + select: { id: true, name: true, slug: true }, + }, + }, + }); + + const teamMemberships = await prisma.teamMember.findMany({ + where: { userId }, + include: { + team: { + select: { id: true, name: true }, + }, + }, + }); + + const dashboards = await prisma.dashboard.findMany({ + where: { userId }, + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + }, + }); + + const savedQueries = await prisma.savedQuery.findMany({ + where: { userId }, + select: { + id: true, + name: true, + query: true, + createdAt: true, + }, + }); + + const apiKeys = await prisma.apiKey.findMany({ + where: { userId }, + select: { + id: true, + name: true, + prefix: true, + lastUsedAt: true, + createdAt: true, + }, + }); + + const exportData = { + exportedAt: new Date(), + user, + organizationMemberships: organizationMemberships.map((m) => ({ + organization: m.organization, + role: m.role, + joinedAt: m.joinedAt, + })), + teamMemberships: teamMemberships.map((m) => ({ + team: m.team, + role: m.role, + addedAt: m.addedAt, + })), + dashboards, + savedQueries, + apiKeys, + }; + + reply.header('Content-Type', 'application/json'); + reply.header( + 'Content-Disposition', + `attachment; filename="user-data-export-${new Date().toISOString().split('T')[0]}.json"` + ); + return exportData; + }); + + // DELETE /settings/gdpr/delete-account - Delete user account (GDPR right to erasure) + fastify.delete('/gdpr/delete-account', async (request, reply) => { + const userId = request.user!.sub; + + // Check if user is the only owner of any organization + const ownerships = await prisma.organizationMember.findMany({ + where: { userId, role: 'owner' }, + include: { + organization: { + include: { + members: { + where: { role: 'owner' }, + }, + }, + }, + }, + }); + + for (const ownership of ownerships) { + if (ownership.organization.members.length === 1) { + return reply.status(400).send({ + error: { + code: 'CANNOT_DELETE_SOLE_OWNER', + message: `You are the only owner of organization "${ownership.organization.name}". Please transfer ownership or delete the organization first.`, + }, + }); + } + } + + // Delete user and cascade (relationships are set up with onDelete: Cascade) + await prisma.user.delete({ + where: { id: userId }, + }); + + return reply.status(200).send({ + message: 'Your account and all associated data have been deleted.', + }); + }); + + // ==================== Compliance Reports ==================== + + // GET /settings/compliance/report - Generate compliance report + fastify.get('/compliance/report', async (request) => { + if (!['owner', 'admin'].includes(request.user!.role)) { + throw new ForbiddenError('Only admins can view compliance reports'); + } + + const orgId = request.user!.orgId; + + // Get organization info + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { + name: true, + slug: true, + plan: true, + settings: true, + createdAt: true, + }, + }); + + // Count users + const userCount = await prisma.organizationMember.count({ + where: { orgId }, + }); + + // Count users with 2FA enabled + const mfaEnabledCount = await prisma.user.count({ + where: { + organizationMemberships: { some: { orgId } }, + mfaEnabled: true, + }, + }); + + // Count API keys + const apiKeyCount = await prisma.apiKey.count({ + where: { orgId }, + }); + + // Count active sessions (from database) + const activeSessionCount = await prisma.session.count({ + where: { + user: { organizationMemberships: { some: { orgId } } }, + expiresAt: { gt: new Date() }, + }, + }); + + // Get settings + const settings = (org?.settings as Record) || {}; + const ipAllowlist = settings.ipAllowlist as { enabled?: boolean } | undefined; + const dataRetention = settings.dataRetention as Record | undefined; + + const report = { + generatedAt: new Date(), + organization: { + name: org?.name, + slug: org?.slug, + plan: org?.plan, + createdAt: org?.createdAt, + }, + security: { + totalUsers: userCount, + mfaEnabledUsers: mfaEnabledCount, + mfaAdoptionRate: userCount > 0 ? Math.round((mfaEnabledCount / userCount) * 100) : 0, + ipAllowlistEnabled: ipAllowlist?.enabled ?? false, + activeApiKeys: apiKeyCount, + activeSessions: activeSessionCount, + }, + dataRetention: { + metricsRetentionDays: dataRetention?.metricsRetentionDays ?? 30, + auditLogsRetentionDays: dataRetention?.auditLogsRetentionDays ?? 90, + alertEventsRetentionDays: dataRetention?.alertEventsRetentionDays ?? 90, + messageSamplesRetentionDays: dataRetention?.messageSamplesRetentionDays ?? 7, + }, + recommendations: [] as string[], + }; + + // Generate recommendations + if (report.security.mfaAdoptionRate < 100) { + report.recommendations.push( + `${100 - report.security.mfaAdoptionRate}% of users don't have 2FA enabled. Consider enforcing 2FA for all users.` + ); + } + if (!report.security.ipAllowlistEnabled) { + report.recommendations.push( + 'IP allowlist is not enabled. Consider enabling it to restrict access to trusted IP addresses.' + ); + } + + return { report }; + }); }; diff --git a/apps/shared/src/schemas/index.ts b/apps/shared/src/schemas/index.ts index daf9fbd..6c0060c 100644 --- a/apps/shared/src/schemas/index.ts +++ b/apps/shared/src/schemas/index.ts @@ -254,18 +254,21 @@ export const UpdateConsumerSchema = CreateConsumerSchema.partial().omit({ name: // ==================== Dashboard Schemas ==================== export const DashboardWidgetSchema = z.object({ - id: z.string().uuid(), - type: z.enum(['chart', 'stat', 'table', 'text']), + id: z.string(), + type: z.enum(['line-chart', 'bar-chart', 'gauge', 'stat', 'table', 'pie-chart']), title: z.string().min(1).max(100), - x: z.number().int().min(0), - y: z.number().int().min(0), - w: z.number().int().min(1), - h: z.number().int().min(1), + position: z.object({ + x: z.number().int().min(0), + y: z.number().int().min(0), + w: z.number().int().min(1), + h: z.number().int().min(1), + }), config: z.record(z.unknown()), }); export const CreateDashboardSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), + description: z.string().max(500).optional(), layout: z.object({ columns: z.number().int().min(1).max(24).default(12), rowHeight: z.number().int().min(20).max(200).default(80), diff --git a/apps/web/app/(dashboard)/dashboards/[id]/page.tsx b/apps/web/app/(dashboard)/dashboards/[id]/page.tsx new file mode 100644 index 0000000..0399bdd --- /dev/null +++ b/apps/web/app/(dashboard)/dashboards/[id]/page.tsx @@ -0,0 +1,438 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + ArrowLeft, + Plus, + Save, + Settings, + Trash2, + LayoutDashboard, + Loader2, + GripVertical, + BarChart3, + LineChart, + Activity, + Gauge, + List, + PieChart, + X, +} from 'lucide-react'; +import Link from 'next/link'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { DashboardWidget } from '@/components/dashboard/dashboard-widget'; + +// Widget types +const WIDGET_TYPES = [ + { id: 'line-chart', name: 'Line Chart', icon: LineChart, description: 'Time series data' }, + { id: 'bar-chart', name: 'Bar Chart', icon: BarChart3, description: 'Comparison data' }, + { id: 'gauge', name: 'Gauge', icon: Gauge, description: 'Single metric value' }, + { id: 'stat', name: 'Stat Card', icon: Activity, description: 'Key statistics' }, + { id: 'table', name: 'Table', icon: List, description: 'Tabular data' }, + { id: 'pie-chart', name: 'Pie Chart', icon: PieChart, description: 'Proportional data' }, +]; + +interface Widget { + id: string; + type: string; + title: string; + config: Record; + position: { x: number; y: number; w: number; h: number }; +} + +export default function DashboardBuilderPage() { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const dashboardId = params.id as string; + + const [widgets, setWidgets] = useState([]); + const [dashboardName, setDashboardName] = useState(''); + const [dashboardDescription, setDashboardDescription] = useState(''); + const [showAddWidget, setShowAddWidget] = useState(false); + const [showWidgetConfig, setShowWidgetConfig] = useState(null); + const [selectedWidgetType, setSelectedWidgetType] = useState(''); + const [newWidgetTitle, setNewWidgetTitle] = useState(''); + const [newWidgetCluster, setNewWidgetCluster] = useState(''); + const [newWidgetMetric, setNewWidgetMetric] = useState(''); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const { data: dashboardData, isLoading } = useQuery({ + queryKey: ['dashboard', dashboardId], + queryFn: () => api.dashboards.get(dashboardId), + enabled: dashboardId !== 'new', + }); + + const { data: clustersData } = useQuery({ + queryKey: ['clusters'], + queryFn: () => api.clusters.list(), + }); + + // Initialize dashboard data + useEffect(() => { + if (dashboardData?.dashboard) { + setDashboardName(dashboardData.dashboard.name); + setDashboardDescription(dashboardData.dashboard.description || ''); + setWidgets(dashboardData.dashboard.widgets || []); + } + }, [dashboardData]); + + const saveMutation = useMutation({ + mutationFn: () => + api.dashboards.update(dashboardId, { + name: dashboardName, + description: dashboardDescription, + widgets, + }), + onSuccess: () => { + setHasUnsavedChanges(false); + queryClient.invalidateQueries({ queryKey: ['dashboard', dashboardId] }); + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + }, + }); + + const addWidget = () => { + if (!selectedWidgetType || !newWidgetTitle) return; + + const newWidget: Widget = { + id: `widget-${Date.now()}`, + type: selectedWidgetType, + title: newWidgetTitle, + config: { + clusterId: newWidgetCluster, + metric: newWidgetMetric, + }, + position: { + x: (widgets.length % 2) * 6, + y: Math.floor(widgets.length / 2) * 4, + w: 6, + h: 4, + }, + }; + + setWidgets([...widgets, newWidget]); + setHasUnsavedChanges(true); + setShowAddWidget(false); + setSelectedWidgetType(''); + setNewWidgetTitle(''); + setNewWidgetCluster(''); + setNewWidgetMetric(''); + }; + + const removeWidget = (widgetId: string) => { + setWidgets(widgets.filter((w) => w.id !== widgetId)); + setHasUnsavedChanges(true); + }; + + const updateWidgetConfig = (widgetId: string, config: Record) => { + setWidgets( + widgets.map((w) => (w.id === widgetId ? { ...w, config: { ...w.config, ...config } } : w)) + ); + setHasUnsavedChanges(true); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + +
+ { + setDashboardName(e.target.value); + setHasUnsavedChanges(true); + }} + className="text-2xl font-bold border-none shadow-none px-0 h-auto focus-visible:ring-0" + placeholder="Dashboard Name" + /> + { + setDashboardDescription(e.target.value); + setHasUnsavedChanges(true); + }} + className="text-sm text-muted-foreground border-none shadow-none px-0 h-auto focus-visible:ring-0" + placeholder="Add a description..." + /> +
+
+
+ + +
+
+ + {/* Dashboard Grid */} + {widgets.length === 0 ? ( + + + +

No widgets yet

+

+ Add widgets to visualize your NATS metrics +

+ +
+
+ ) : ( +
+ {widgets.map((widget) => ( +
+ +
+ + +
+ +
+ + {widget.title} +
+
+ + + +
+
+ ))} +
+ )} + + {/* Add Widget Dialog */} + + + + Add Widget + + Choose a widget type and configure it + + +
+ {/* Widget Type Selection */} +
+ {WIDGET_TYPES.map((type) => { + const Icon = type.icon; + return ( + + ); + })} +
+ + {selectedWidgetType && ( + <> +
+ + setNewWidgetTitle(e.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ + )} +
+ + + + +
+
+ + {/* Widget Config Dialog */} + setShowWidgetConfig(null)}> + + + Configure Widget + + Update the widget settings + + + {showWidgetConfig && ( +
+
+ + + setWidgets( + widgets.map((w) => + w.id === showWidgetConfig.id ? { ...w, title: e.target.value } : w + ) + ) + } + /> +
+
+ + +
+
+ + +
+
+ )} + + + +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/dashboards/page.tsx b/apps/web/app/(dashboard)/dashboards/page.tsx index 9f50048..4210e1b 100644 --- a/apps/web/app/(dashboard)/dashboards/page.tsx +++ b/apps/web/app/(dashboard)/dashboards/page.tsx @@ -168,7 +168,7 @@ export default function DashboardsPage() {
- {dashboard.isPublic ? 'Public' : 'Private'} + {dashboard.isShared ? 'Shared' : 'Private'}
diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index cfc77d6..0c2fa09 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -20,6 +20,11 @@ import { EyeOff, Plus, Clock, + Globe, + FileText, + Database, + Download, + AlertTriangle, } from 'lucide-react'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { useAuthStore } from '@/stores/auth'; @@ -61,6 +66,9 @@ const tabs = [ { id: 'security', label: 'Security', icon: Shield }, { id: 'api-keys', label: 'API Keys', icon: Key }, { id: 'appearance', label: 'Appearance', icon: Palette }, + { id: 'ip-allowlist', label: 'IP Allowlist', icon: Globe, adminOnly: true }, + { id: 'compliance', label: 'Compliance', icon: FileText, adminOnly: true }, + { id: 'data-privacy', label: 'Data Privacy', icon: Database }, ]; function SettingsPageContent() { @@ -120,6 +128,22 @@ function SettingsPageContent() { const [mfaError, setMfaError] = useState(''); const [showDisable2faDialog, setShowDisable2faDialog] = useState(false); + // Enterprise features state + const [ipAllowlistForm, setIpAllowlistForm] = useState({ + enabled: false, + allowedIps: [] as string[], + allowedCidrs: [] as string[], + }); + const [newIp, setNewIp] = useState(''); + const [newCidr, setNewCidr] = useState(''); + const [retentionForm, setRetentionForm] = useState({ + metricsRetentionDays: 30, + auditLogsRetentionDays: 90, + alertEventsRetentionDays: 90, + messageSamplesRetentionDays: 7, + }); + const [showDeleteAccountDialog, setShowDeleteAccountDialog] = useState(false); + // Initialize form with user data useEffect(() => { if (user) { @@ -143,6 +167,40 @@ function SettingsPageContent() { queryFn: () => api.settings.listApiKeys(), }); + // Fetch IP allowlist (admin only) + const { data: ipAllowlistData, isLoading: ipAllowlistLoading } = useQuery({ + queryKey: ['ip-allowlist'], + queryFn: () => api.settings.getIpAllowlist(), + enabled: user && ['owner', 'admin'].includes(user.role || ''), + }); + + // Fetch retention policy (admin only) + const { data: retentionData, isLoading: retentionLoading } = useQuery({ + queryKey: ['retention-policy'], + queryFn: () => api.settings.getRetentionPolicy(), + enabled: user && ['owner', 'admin'].includes(user.role || ''), + }); + + // Fetch compliance report (admin only) + const { data: complianceData, isLoading: complianceLoading } = useQuery({ + queryKey: ['compliance-report'], + queryFn: () => api.settings.getComplianceReport(), + enabled: user && ['owner', 'admin'].includes(user.role || ''), + }); + + // Initialize enterprise forms + useEffect(() => { + if (ipAllowlistData?.ipAllowlist) { + setIpAllowlistForm(ipAllowlistData.ipAllowlist); + } + }, [ipAllowlistData]); + + useEffect(() => { + if (retentionData?.retention) { + setRetentionForm(retentionData.retention); + } + }, [retentionData]); + // Profile update mutation const profileMutation = useMutation({ mutationFn: (data: { firstName?: string; lastName?: string; email?: string }) => @@ -261,6 +319,32 @@ function SettingsPageContent() { }, }); + // IP Allowlist mutation + const ipAllowlistMutation = useMutation({ + mutationFn: (data: typeof ipAllowlistForm) => api.settings.updateIpAllowlist(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ip-allowlist'] }); + }, + }); + + // Retention policy mutation + const retentionMutation = useMutation({ + mutationFn: (data: typeof retentionForm) => api.settings.updateRetentionPolicy(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['retention-policy'] }); + }, + }); + + // Delete account mutation + const deleteAccountMutation = useMutation({ + mutationFn: () => api.settings.deleteAccount(), + onSuccess: () => { + // Logout and redirect + useAuthStore.getState().logout(); + window.location.href = '/login'; + }, + }); + const handleProfileSubmit = (e: React.FormEvent) => { e.preventDefault(); profileMutation.mutate(profileForm); @@ -323,23 +407,25 @@ function SettingsPageContent() {
{/* Sidebar */}
- {tabs.map((tab) => { - const Icon = tab.icon; - return ( - - ); - })} + {tabs + .filter((tab) => !tab.adminOnly || (user && ['owner', 'admin'].includes(user.role || ''))) + .map((tab) => { + const Icon = tab.icon; + return ( + + ); + })}
{/* Content */} @@ -974,9 +1060,483 @@ function SettingsPageContent() { )} + + {activeTab === 'ip-allowlist' && ( + + + IP Allowlist + + Restrict access to your organization from specific IP addresses or CIDR ranges + + + + {ipAllowlistLoading && ( +
+ +
+ )} + + {!ipAllowlistLoading && ( + <> + {ipAllowlistMutation.isSuccess && ( +
+ + IP allowlist updated successfully +
+ )} + +
+
+

Enable IP Allowlist

+

+ When enabled, only listed IPs can access the system +

+
+ + setIpAllowlistForm({ ...ipAllowlistForm, enabled: e.target.checked }) + } + className="h-4 w-4" + /> +
+ +
+
+ +
+ setNewIp(e.target.value)} + /> + +
+
+ {ipAllowlistForm.allowedIps.map((ip) => ( + + {ip} + + + ))} +
+
+ +
+ +
+ setNewCidr(e.target.value)} + /> + +
+
+ {ipAllowlistForm.allowedCidrs.map((cidr) => ( + + {cidr} + + + ))} +
+
+
+ +
+

+ + Warning: Enabling IP allowlist without adding your current IP may lock you out. + Localhost (127.0.0.1, ::1) is always allowed. +

+
+ + + + )} +
+
+ )} + + {activeTab === 'compliance' && ( +
+ + + Data Retention Policies + + Configure how long data is retained in the system + + + + {retentionLoading && ( +
+ +
+ )} + + {!retentionLoading && ( + <> + {retentionMutation.isSuccess && ( +
+ + Retention policies updated successfully +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + )} +
+
+ + + + Audit Log Export + Export audit logs for compliance purposes + + +

+ Export audit logs as JSON or CSV files for compliance review and record-keeping. +

+ +
+
+ + + + Compliance Report + View your organization's compliance status + + + {complianceLoading && ( +
+ +
+ )} + + {!complianceLoading && complianceData?.report && ( +
+
+
+

Total Users

+

{complianceData.report.security.totalUsers}

+
+
+

2FA Adoption

+

{complianceData.report.security.mfaAdoptionRate}%

+
+
+

Active API Keys

+

{complianceData.report.security.activeApiKeys}

+
+
+ +
+

Security Status

+
+
+ IP Allowlist + + {complianceData.report.security.ipAllowlistEnabled ? 'Enabled' : 'Disabled'} + +
+
+ Active Sessions + + {complianceData.report.security.activeSessions} + +
+
+
+ + {complianceData.report.recommendations.length > 0 && ( +
+

Recommendations

+
+ {complianceData.report.recommendations.map((rec, idx) => ( +
+ +

{rec}

+
+ ))} +
+
+ )} +
+ )} +
+
+
+ )} + + {activeTab === 'data-privacy' && ( +
+ + + Export Your Data + + Download a copy of all your personal data (GDPR Article 20) + + + +

+ Export includes your profile information, organization memberships, dashboards, + saved queries, and API keys. +

+ +
+
+ + + + Delete Account + + Permanently delete your account and all associated data (GDPR Article 17) + + + +
+

+ + Warning: This action cannot be undone. All your data will be permanently deleted. +

+
+ +
+
+
+ )}
+ {/* Delete Account Dialog */} + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account and remove all + your data from our servers, including organization memberships, dashboards, and API keys. + + + + Cancel + deleteAccountMutation.mutate()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteAccountMutation.isPending ? ( + + ) : null} + Yes, Delete My Account + + + + + {/* Create API Key Dialog */} { if (!open) { diff --git a/apps/web/components/dashboard/dashboard-widget.tsx b/apps/web/components/dashboard/dashboard-widget.tsx new file mode 100644 index 0000000..6bbda07 --- /dev/null +++ b/apps/web/components/dashboard/dashboard-widget.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { Loader2, AlertTriangle } from 'lucide-react'; +import { api } from '@/lib/api'; +import { LineChart, BarChart, GaugeChart } from '@/components/charts'; +import { formatNumber, formatBytes } from '@nats-console/shared'; + +interface Widget { + id: string; + type: string; + title: string; + config: Record; + position: { x: number; y: number; w: number; h: number }; +} + +interface DashboardWidgetProps { + widget: Widget; +} + +export function DashboardWidget({ widget }: DashboardWidgetProps) { + const clusterId = widget.config.clusterId as string; + const metric = widget.config.metric as string; + + // Fetch data based on metric type + const { data, isLoading, error } = useQuery({ + queryKey: ['widget-data', widget.id, clusterId, metric], + queryFn: async () => { + if (!clusterId) return null; + + switch (metric) { + case 'messages_rate': + case 'bytes_rate': + return api.analytics.chartThroughput(clusterId, '1h'); + case 'consumer_lag': + return api.analytics.chartConsumerLag(clusterId, '1h'); + default: + return api.analytics.overview(clusterId, '1h'); + } + }, + enabled: !!clusterId, + refetchInterval: 30000, // Refresh every 30 seconds + }); + + if (!clusterId) { + return ( +
+
+ +

Select a cluster to display data

+
+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ +

Failed to load data

+
+
+ ); + } + + // Render based on widget type + switch (widget.type) { + case 'line-chart': + return ( + + ); + + case 'bar-chart': + return ( + + ); + + case 'gauge': + const gaugeValue = data?.totalMessages || data?.avgThroughput || 0; + const maxValue = metric === 'cpu_percent' ? 100 : gaugeValue * 2 || 100; + return ( +
+ +
+ ); + + case 'stat': + return ( +
+
+ {formatMetricValue(data, metric)} +
+
+ {getMetricLabel(metric)} +
+
+ ); + + case 'table': + return ( +
+ {data?.data?.length > 0 ? ( + + + + + + + + + {data.data.slice(0, 5).map((item: any, idx: number) => ( + + + + + ))} + +
NameValue
{item.name}{formatNumber(item.value)}
+ ) : ( +
+ No data available +
+ )} +
+ ); + + case 'pie-chart': + // For pie charts, we'd need a different chart component + // For now, show as a placeholder + return ( +
+
+

Pie chart coming soon

+
+
+ ); + + default: + return ( +
+

Unknown widget type

+
+ ); + } +} + +function getMetricLabel(metric: string): string { + const labels: Record = { + messages_rate: 'Messages/sec', + bytes_rate: 'Bytes/sec', + consumer_lag: 'Consumer Lag', + connections: 'Connections', + cpu_percent: 'CPU Usage', + memory_bytes: 'Memory Usage', + }; + return labels[metric] || metric; +} + +function formatMetricValue(data: any, metric: string): string { + if (!data) return '0'; + + switch (metric) { + case 'messages_rate': + return formatNumber(data.avgThroughput || 0); + case 'bytes_rate': + return formatBytes(data.totalBytes || 0); + case 'consumer_lag': + return formatNumber(data.data?.[0]?.value || 0); + case 'connections': + return formatNumber(data.connections || 0); + case 'cpu_percent': + return `${(data.cpuPercent || 0).toFixed(1)}%`; + case 'memory_bytes': + return formatBytes(data.memoryBytes || 0); + default: + return formatNumber(data.totalMessages || 0); + } +} + +function getGaugeColor(value: number, max: number): string { + const ratio = value / max; + if (ratio < 0.5) return '#16a34a'; // green + if (ratio < 0.75) return '#ca8a04'; // yellow + return '#dc2626'; // red +} diff --git a/apps/web/components/layout/sidebar.tsx b/apps/web/components/layout/sidebar.tsx index f68765d..081fd24 100644 --- a/apps/web/components/layout/sidebar.tsx +++ b/apps/web/components/layout/sidebar.tsx @@ -11,12 +11,12 @@ import { Bell, Settings, LogOut, + PanelTop, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuthStore } from '@/stores/auth'; import { Button } from '@/components/ui/button'; import { Logo } from '@/components/logo'; -import { ConnectionStatus } from '@/components/ui/connection-status'; const navigation = [ { name: 'Overview', href: '/overview', icon: LayoutDashboard }, @@ -24,6 +24,7 @@ const navigation = [ { name: 'Streams', href: '/streams', icon: Database }, { name: 'Consumers', href: '/consumers', icon: Users }, { name: 'Analytics', href: '/analytics', icon: BarChart3 }, + { name: 'Dashboards', href: '/dashboards', icon: PanelTop }, { name: 'Alerts', href: '/alerts', icon: Bell }, { name: 'Settings', href: '/settings', icon: Settings }, ]; @@ -35,9 +36,8 @@ export function Sidebar() { return (
{/* Logo */} -
+
-
{/* Navigation */} diff --git a/apps/web/components/ui/dropdown-menu.tsx b/apps/web/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..f4e656e --- /dev/null +++ b/apps/web/components/ui/dropdown-menu.tsx @@ -0,0 +1,203 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface DropdownMenuProps { + children: React.ReactNode; +} + +interface DropdownMenuContextValue { + open: boolean; + setOpen: React.Dispatch>; +} + +const DropdownMenuContext = React.createContext(null); + +function useDropdownMenu() { + const context = React.useContext(DropdownMenuContext); + if (!context) { + throw new Error('DropdownMenu components must be used within a DropdownMenu'); + } + return context; +} + +export function DropdownMenu({ children }: DropdownMenuProps) { + const [open, setOpen] = React.useState(false); + + return ( + +
{children}
+
+ ); +} + +interface DropdownMenuTriggerProps { + children: React.ReactNode; + asChild?: boolean; +} + +export function DropdownMenuTrigger({ children, asChild }: DropdownMenuTriggerProps) { + const { open, setOpen } = useDropdownMenu(); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setOpen(!open); + }; + + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement, { + onClick: (e: React.MouseEvent) => { + handleClick(e); + const originalOnClick = (children as React.ReactElement).props.onClick; + if (originalOnClick) originalOnClick(e); + }, + }); + } + + return ( + + ); +} + +interface DropdownMenuContentProps extends React.HTMLAttributes { + align?: 'start' | 'center' | 'end'; + sideOffset?: number; +} + +export function DropdownMenuContent({ + className, + align = 'end', + children, + ...props +}: DropdownMenuContentProps) { + const { open, setOpen } = useDropdownMenu(); + const ref = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [open, setOpen]); + + if (!open) return null; + + const alignClasses = { + start: 'left-0', + center: 'left-1/2 -translate-x-1/2', + end: 'right-0', + }; + + return ( +
+ {children} +
+ ); +} + +interface DropdownMenuItemProps extends React.HTMLAttributes { + disabled?: boolean; + asChild?: boolean; +} + +export function DropdownMenuItem({ + className, + disabled, + children, + asChild, + onClick, + ...props +}: DropdownMenuItemProps) { + const { setOpen } = useDropdownMenu(); + + const handleClick = (e: React.MouseEvent) => { + if (disabled) return; + onClick?.(e); + setOpen(false); + }; + + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement, { + className: cn( + 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors', + 'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground', + disabled && 'pointer-events-none opacity-50', + className, + (children as React.ReactElement).props.className + ), + onClick: (e: React.MouseEvent) => { + if (disabled) return; + const originalOnClick = (children as React.ReactElement).props.onClick; + if (originalOnClick) originalOnClick(e); + setOpen(false); + }, + }); + } + + return ( +
+ {children} +
+ ); +} + +export function DropdownMenuSeparator({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export function DropdownMenuLabel({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index c9d4e73..091d329 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -464,6 +464,69 @@ export const settings = { }), deleteApiKey: (id: string) => request(`/settings/api-keys/${id}`, { method: 'DELETE' }), + + // Security settings (admin only) + getIpAllowlist: () => + request<{ ipAllowlist: { enabled: boolean; allowedIps: string[]; allowedCidrs: string[] } }>( + '/settings/security/ip-allowlist' + ), + + updateIpAllowlist: (data: { enabled: boolean; allowedIps: string[]; allowedCidrs: string[] }) => + request<{ ipAllowlist: any }>('/settings/security/ip-allowlist', { + method: 'PUT', + body: data, + }), + + // Compliance settings (admin only) + getRetentionPolicy: () => + request<{ + retention: { + metricsRetentionDays: number; + auditLogsRetentionDays: number; + alertEventsRetentionDays: number; + messageSamplesRetentionDays: number; + }; + }>('/settings/compliance/retention'), + + updateRetentionPolicy: (data: { + metricsRetentionDays: number; + auditLogsRetentionDays: number; + alertEventsRetentionDays: number; + messageSamplesRetentionDays: number; + }) => + request<{ retention: any }>('/settings/compliance/retention', { + method: 'PUT', + body: data, + }), + + exportAuditLogs: (params?: { from?: string; to?: string; action?: string; format?: 'json' | 'csv' }) => { + const query = params ? `?${new URLSearchParams(params as Record)}` : ''; + return `/settings/compliance/audit-export${query}`; + }, + + getComplianceReport: () => + request<{ + report: { + generatedAt: string; + organization: any; + security: { + totalUsers: number; + mfaEnabledUsers: number; + mfaAdoptionRate: number; + ipAllowlistEnabled: boolean; + activeApiKeys: number; + activeSessions: number; + }; + dataRetention: any; + recommendations: string[]; + }; + }>('/settings/compliance/report'), + + // GDPR (all users) + exportUserData: () => `/settings/gdpr/export`, + + deleteAccount: () => + request<{ message: string }>('/settings/gdpr/delete-account', { method: 'DELETE' }), }; // MFA API (via auth endpoints) diff --git a/docker/scripts/init-databases.sh b/docker/scripts/init-databases.sh index e170673..81c6554 100644 --- a/docker/scripts/init-databases.sh +++ b/docker/scripts/init-databases.sh @@ -113,9 +113,9 @@ init_clickhouse() { clickhouse-client --query "CREATE DATABASE IF NOT EXISTS nats_console" # Run schema initialization - if [ -f /docker-entrypoint-initdb.d/clickhouse-init.sql ]; then + if [ -f /app/infrastructure/clickhouse/init/init.sql ]; then echo "[ClickHouse] Running schema initialization..." - clickhouse-client --database nats_console --multiquery < /docker-entrypoint-initdb.d/clickhouse-init.sql + clickhouse-client --database nats_console --multiquery < /app/infrastructure/clickhouse/init/init.sql fi # Stop ClickHouse (will be started by supervisord) diff --git a/infrastructure/clickhouse/init/01-schema.sql b/infrastructure/clickhouse/init/01-schema.sql deleted file mode 100644 index 4a7fb7e..0000000 --- a/infrastructure/clickhouse/init/01-schema.sql +++ /dev/null @@ -1,159 +0,0 @@ --- Stream metrics table -CREATE TABLE IF NOT EXISTS stream_metrics -( - cluster_id UUID, - stream_name String, - timestamp DateTime64(3), - messages_total UInt64, - bytes_total UInt64, - messages_rate Float64, - bytes_rate Float64, - consumer_count UInt32, - first_seq UInt64, - last_seq UInt64, - subjects Array(String) -) -ENGINE = MergeTree() -PARTITION BY toYYYYMM(timestamp) -ORDER BY (cluster_id, stream_name, timestamp) -TTL timestamp + INTERVAL 90 DAY; - --- Consumer metrics table -CREATE TABLE IF NOT EXISTS consumer_metrics -( - cluster_id UUID, - stream_name String, - consumer_name String, - timestamp DateTime64(3), - pending_count UInt64, - ack_pending UInt64, - redelivered UInt64, - waiting UInt64, - delivered_rate Float64, - ack_rate Float64, - lag Int64 -) -ENGINE = MergeTree() -PARTITION BY toYYYYMM(timestamp) -ORDER BY (cluster_id, stream_name, consumer_name, timestamp) -TTL timestamp + INTERVAL 90 DAY; - --- Cluster metrics table -CREATE TABLE IF NOT EXISTS cluster_metrics -( - cluster_id UUID, - server_id String, - server_name String, - timestamp DateTime64(3), - cpu_percent Float32, - memory_bytes UInt64, - connections UInt32, - subscriptions UInt32, - slow_consumers UInt32, - in_msgs UInt64, - out_msgs UInt64, - in_bytes UInt64, - out_bytes UInt64 -) -ENGINE = MergeTree() -PARTITION BY toYYYYMM(timestamp) -ORDER BY (cluster_id, server_id, timestamp) -TTL timestamp + INTERVAL 180 DAY; - --- Message samples table -CREATE TABLE IF NOT EXISTS message_samples -( - cluster_id UUID, - stream_name String, - subject String, - sequence UInt64, - timestamp DateTime64(3), - headers Map(String, String), - payload_preview String, - payload_size UInt32, - payload_type String -) -ENGINE = MergeTree() -PARTITION BY toYYYYMM(timestamp) -ORDER BY (cluster_id, stream_name, subject, timestamp) -TTL timestamp + INTERVAL 7 DAY; - --- Audit logs table -CREATE TABLE IF NOT EXISTS audit_logs -( - id UUID, - org_id UUID, - user_id UUID, - user_email String, - timestamp DateTime64(3), - action String, - resource_type String, - resource_id String, - resource_name String, - cluster_id Nullable(UUID), - ip_address IPv6, - user_agent String, - request_id String, - changes String, - status Enum8('success' = 1, 'failure' = 2, 'denied' = 3), - error_message Nullable(String) -) -ENGINE = MergeTree() -PARTITION BY toYYYYMM(timestamp) -ORDER BY (org_id, timestamp, action) -TTL timestamp + INTERVAL 365 DAY; - --- Alert events table -CREATE TABLE IF NOT EXISTS alert_events -( - id UUID, - org_id UUID, - alert_rule_id UUID, - cluster_id UUID, - timestamp DateTime64(3), - severity Enum8('info' = 1, 'warning' = 2, 'critical' = 3), - status Enum8('firing' = 1, 'resolved' = 2), - metric_value Float64, - threshold_value Float64, - message String, - notified_at Nullable(DateTime64(3)), - resolved_at Nullable(DateTime64(3)) -) -ENGINE = MergeTree() -PARTITION BY toYYYYMM(timestamp) -ORDER BY (org_id, alert_rule_id, timestamp) -TTL timestamp + INTERVAL 90 DAY; - --- Materialized view for hourly stream metrics -CREATE MATERIALIZED VIEW IF NOT EXISTS stream_metrics_hourly -ENGINE = SummingMergeTree() -PARTITION BY toYYYYMM(hour) -ORDER BY (cluster_id, stream_name, hour) -AS SELECT - cluster_id, - stream_name, - toStartOfHour(timestamp) AS hour, - sum(messages_total) AS messages_sum, - sum(bytes_total) AS bytes_sum, - avg(messages_rate) AS avg_rate, - max(messages_rate) AS max_rate, - count() AS sample_count -FROM stream_metrics -GROUP BY cluster_id, stream_name, hour; - --- Materialized view for daily stream metrics -CREATE MATERIALIZED VIEW IF NOT EXISTS stream_metrics_daily -ENGINE = SummingMergeTree() -PARTITION BY toYYYYMM(day) -ORDER BY (cluster_id, stream_name, day) -AS SELECT - cluster_id, - stream_name, - toDate(timestamp) AS day, - sum(messages_total) AS messages_sum, - sum(bytes_total) AS bytes_sum, - avg(messages_rate) AS avg_rate, - max(messages_rate) AS max_rate, - count() AS sample_count -FROM stream_metrics -GROUP BY cluster_id, stream_name, day; diff --git a/infrastructure/clickhouse/init/init.sql b/infrastructure/clickhouse/init/init.sql new file mode 100644 index 0000000..37689fa --- /dev/null +++ b/infrastructure/clickhouse/init/init.sql @@ -0,0 +1,233 @@ +-- ClickHouse Schema for NATS Console +-- This script creates all required tables for metrics storage and analytics + +-- ============================================================================= +-- Stream Metrics Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS stream_metrics ( + cluster_id UUID, + stream_name String, + timestamp DateTime64(3, 'UTC'), + messages_total UInt64, + bytes_total UInt64, + messages_rate Float64, + bytes_rate Float64, + consumer_count UInt32, + first_seq UInt64, + last_seq UInt64, + subjects Array(String) +) ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (cluster_id, stream_name, timestamp) +TTL toDateTime(timestamp) + INTERVAL 30 DAY; + +-- ============================================================================= +-- Consumer Metrics Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS consumer_metrics ( + cluster_id UUID, + stream_name String, + consumer_name String, + timestamp DateTime64(3, 'UTC'), + pending_count UInt64, + ack_pending UInt64, + redelivered UInt64, + waiting UInt64, + delivered_rate Float64, + ack_rate Float64, + lag UInt64 +) ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (cluster_id, stream_name, consumer_name, timestamp) +TTL toDateTime(timestamp) + INTERVAL 30 DAY; + +-- ============================================================================= +-- Cluster Metrics Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS cluster_metrics ( + cluster_id UUID, + server_id String, + server_name String, + timestamp DateTime64(3, 'UTC'), + cpu_percent Float64, + memory_bytes UInt64, + connections UInt32, + subscriptions UInt32, + slow_consumers UInt32, + in_msgs UInt64, + out_msgs UInt64, + in_bytes UInt64, + out_bytes UInt64 +) ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (cluster_id, server_id, timestamp) +TTL toDateTime(timestamp) + INTERVAL 30 DAY; + +-- ============================================================================= +-- Audit Logs Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID, + org_id UUID, + user_id UUID, + user_email String, + timestamp DateTime64(3, 'UTC'), + action String, + resource_type String, + resource_id String, + resource_name String, + cluster_id Nullable(UUID), + ip_address String, + user_agent String, + request_id String, + changes String DEFAULT '{}', + status String, + error_message Nullable(String) +) ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp) +TTL toDateTime(timestamp) + INTERVAL 90 DAY; + +-- ============================================================================= +-- Alert Events Table (for historical alert tracking) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS alert_events ( + id UUID, + org_id UUID, + rule_id UUID, + cluster_id Nullable(UUID), + timestamp DateTime64(3, 'UTC'), + severity String, + status String, + metric_name String, + metric_value Float64, + threshold_value Float64, + message String, + metadata String DEFAULT '{}' +) ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp) +TTL toDateTime(timestamp) + INTERVAL 90 DAY; + +-- ============================================================================= +-- Message Samples Table (for message inspection/debugging) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS message_samples ( + cluster_id UUID, + stream_name String, + subject String, + sequence UInt64, + timestamp DateTime64(3, 'UTC'), + size UInt32, + headers String DEFAULT '{}', + data_preview String +) ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (cluster_id, stream_name, timestamp) +TTL toDateTime(timestamp) + INTERVAL 7 DAY; + +-- ============================================================================= +-- Materialized Views for Aggregations +-- ============================================================================= + +-- Hourly stream metrics aggregation +CREATE TABLE IF NOT EXISTS stream_metrics_hourly ( + cluster_id UUID, + stream_name String, + hour DateTime, + messages_total_avg Float64, + bytes_total_avg Float64, + messages_rate_avg Float64, + bytes_rate_avg Float64, + messages_rate_max Float64, + bytes_rate_max Float64, + consumer_count_avg Float64, + sample_count UInt64 +) ENGINE = SummingMergeTree() +PARTITION BY toYYYYMM(hour) +ORDER BY (cluster_id, stream_name, hour) +TTL hour + INTERVAL 365 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS stream_metrics_hourly_mv +TO stream_metrics_hourly AS +SELECT + cluster_id, + stream_name, + toStartOfHour(timestamp) as hour, + avg(messages_total) as messages_total_avg, + avg(bytes_total) as bytes_total_avg, + avg(messages_rate) as messages_rate_avg, + avg(bytes_rate) as bytes_rate_avg, + max(messages_rate) as messages_rate_max, + max(bytes_rate) as bytes_rate_max, + avg(consumer_count) as consumer_count_avg, + count() as sample_count +FROM stream_metrics +GROUP BY cluster_id, stream_name, hour; + +-- Daily stream metrics aggregation +CREATE TABLE IF NOT EXISTS stream_metrics_daily ( + cluster_id UUID, + stream_name String, + day Date, + messages_total_avg Float64, + bytes_total_avg Float64, + messages_rate_avg Float64, + bytes_rate_avg Float64, + messages_rate_max Float64, + bytes_rate_max Float64, + consumer_count_avg Float64, + sample_count UInt64 +) ENGINE = SummingMergeTree() +PARTITION BY toYYYYMM(day) +ORDER BY (cluster_id, stream_name, day); + +CREATE MATERIALIZED VIEW IF NOT EXISTS stream_metrics_daily_mv +TO stream_metrics_daily AS +SELECT + cluster_id, + stream_name, + toDate(timestamp) as day, + avg(messages_total) as messages_total_avg, + avg(bytes_total) as bytes_total_avg, + avg(messages_rate) as messages_rate_avg, + avg(bytes_rate) as bytes_rate_avg, + max(messages_rate) as messages_rate_max, + max(bytes_rate) as bytes_rate_max, + avg(consumer_count) as consumer_count_avg, + count() as sample_count +FROM stream_metrics +GROUP BY cluster_id, stream_name, day; + +-- Hourly consumer lag aggregation +CREATE TABLE IF NOT EXISTS consumer_metrics_hourly ( + cluster_id UUID, + stream_name String, + consumer_name String, + hour DateTime, + pending_count_avg Float64, + lag_avg Float64, + lag_max UInt64, + delivered_rate_avg Float64, + ack_rate_avg Float64, + sample_count UInt64 +) ENGINE = SummingMergeTree() +PARTITION BY toYYYYMM(hour) +ORDER BY (cluster_id, stream_name, consumer_name, hour) +TTL hour + INTERVAL 365 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS consumer_metrics_hourly_mv +TO consumer_metrics_hourly AS +SELECT + cluster_id, + stream_name, + consumer_name, + toStartOfHour(timestamp) as hour, + avg(pending_count) as pending_count_avg, + avg(lag) as lag_avg, + max(lag) as lag_max, + avg(delivered_rate) as delivered_rate_avg, + avg(ack_rate) as ack_rate_avg, + count() as sample_count +FROM consumer_metrics +GROUP BY cluster_id, stream_name, consumer_name, hour; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91f4498..e8be1af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: qrcode: specifier: ^1.5.4 version: 1.5.4 + ws: + specifier: ^8.18.0 + version: 8.18.3 zod: specifier: ^3.23.8 version: 3.25.76 @@ -75,6 +78,9 @@ importers: '@types/node': specifier: ^22.10.1 version: 22.19.1 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 prisma: specifier: ^6.0.1 version: 6.19.0(typescript@5.9.3) @@ -1488,6 +1494,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -3826,6 +3835,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.1 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9