diff --git a/.gitignore b/.gitignore index 51e874a..b62c050 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules .DS_Store *.tsbuildinfo .vercel +.claude/ \ No newline at end of file diff --git a/__tests__/api/routes.test.ts b/__tests__/api/routes.test.ts new file mode 100644 index 0000000..e1953f9 --- /dev/null +++ b/__tests__/api/routes.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('@/lib/auth0', () => ({ + auth0: { + getAccessToken: vi.fn(), + getSession: vi.fn(), + }, +})) + +vi.mock('@/lib/api', () => ({ + fetchBackend: vi.fn(), +})) + +import { GET as getDevToken } from '@/app/api/dev-token/route' +import { GET as getUser } from '@/app/api/user/route' +import { GET as getApplications, POST as createApplication } from '@/app/api/applications/route' +import { GET as getAppById, PUT as updateAppById, DELETE as deleteAppById } from '@/app/api/applications/[id]/route' +import { POST as acceptInvitation } from '@/app/api/invitations/[token]/accept/route' +import { auth0 } from '@/lib/auth0' +import { fetchBackend } from '@/lib/api' + +describe('app/api Routes (Endpoints Internos)', () => { + const originalEnv = process.env.VERCEL_ENV + + beforeEach(() => { + vi.resetAllMocks() + delete process.env.VERCEL_ENV + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.VERCEL_ENV = originalEnv + } else { + delete process.env.VERCEL_ENV + } + }) + + describe('GET /api/dev-token', () => { + it('should return 404 if running on Vercel environment', async () => { + process.env.VERCEL_ENV = 'production' + const res = await getDevToken() + expect(res.status).toBe(404) + }) + + it('should return 401 if access token is not available', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce(null as any) + const res = await getDevToken() + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe('No access token available') + }) + + it('should return token if user is authenticated locally', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce('secret-dev-token' as any) + const res = await getDevToken() + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toBe('secret-dev-token') + }) + }) + + describe('GET /api/user', () => { + it('should return 401 if no active session', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce(null as any) + const res = await getUser() + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe('Not authenticated') + }) + + it('should return user session data when authenticated', async () => { + const mockUser = { sub: 'auth0|123', name: 'John Doe', email: 'john@example.com' } + vi.mocked(auth0.getSession).mockResolvedValueOnce({ user: mockUser } as any) + + const res = await getUser() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user).toEqual(mockUser) + }) + }) + + describe('/api/applications', () => { + it('GET should call fetchBackend with pagination params and return backend json', async () => { + const mockBackendResponse = new Response( + JSON.stringify({ content: [{ id: 'app-1', name: 'payment-sync' }] }), + { status: 200 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications?page=0&size=10') + const res = await getApplications(req) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications?page=0&size=10&sort=name%2Casc') + const body = await res.json() + expect(body.content).toHaveLength(1) + }) + + it('POST should forward payload to backend and return created application', async () => { + const payload = { name: 'new-app', description: 'Test app' } + const mockBackendResponse = new Response( + JSON.stringify({ id: 'app-99', ...payload }), + { status: 201 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications', { + method: 'POST', + body: JSON.stringify(payload), + }) + const res = await createApplication(req) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications', { + method: 'POST', + body: JSON.stringify(payload), + }) + const body = await res.json() + expect(body.id).toBe('app-99') + }) + }) + + describe('/api/applications/[id]', () => { + it('GET /api/applications/[id] should fetch specific application', async () => { + const mockBackendResponse = new Response( + JSON.stringify({ id: 'app-123', name: 'my-app' }), + { status: 200 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications/app-123') + const res = await getAppById(req, { params: Promise.resolve({ id: 'app-123' }) }) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123') + const body = await res.json() + expect(body.id).toBe('app-123') + }) + + it('PUT /api/applications/[id] should update application', async () => { + const updateData = { name: 'updated-name' } + const mockBackendResponse = new Response( + JSON.stringify({ id: 'app-123', ...updateData }), + { status: 200 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications/app-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + const res = await updateAppById(req, { params: Promise.resolve({ id: 'app-123' }) }) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + }) + + it('DELETE /api/applications/[id] should delete application and return 204', async () => { + const mockBackendResponse = new Response(null, { status: 204 }) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications/app-123', { method: 'DELETE' }) + const res = await deleteAppById(req, { params: Promise.resolve({ id: 'app-123' }) }) + + expect(res.status).toBe(204) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123', { method: 'DELETE' }) + }) + }) + + describe('/api/invitations/[token]/accept', () => { + it('POST /api/invitations/[token]/accept should call backend accept endpoint', async () => { + const mockBackendResponse = new Response(null, { status: 200 }) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/invitations/token-abc/accept', { method: 'POST' }) + const res = await acceptInvitation(req, { params: Promise.resolve({ token: 'token-abc' }) }) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/invitations/token-abc/accept', { method: 'POST' }) + }) + }) +}) diff --git a/__tests__/lib/api.test.ts b/__tests__/lib/api.test.ts new file mode 100644 index 0000000..d591e6c --- /dev/null +++ b/__tests__/lib/api.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('@/lib/auth0', () => ({ + auth0: { + getAccessToken: vi.fn(), + }, +})) + +import { getBackendHeaders, fetchBackend } from '@/lib/api' +import { auth0 } from '@/lib/auth0' + +describe('lib/api', () => { + const originalFetch = global.fetch + + beforeEach(() => { + vi.resetAllMocks() + global.fetch = vi.fn() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + describe('getBackendHeaders', () => { + it('should return headers with Bearer token when token is available', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-123' } as any) + + const headers = await getBackendHeaders() + expect(headers).toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer mock-token-123', + }) + }) + + it('should throw Unauthorized error when token is missing', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce(null as any) + + await expect(getBackendHeaders()).rejects.toThrow('Unauthorized: Could not retrieve access token') + }) + + it('should throw error when getAccessToken rejects', async () => { + vi.mocked(auth0.getAccessToken).mockRejectedValueOnce(new Error('Auth error')) + + await expect(getBackendHeaders()).rejects.toThrow('Unauthorized: Could not retrieve access token') + }) + }) + + describe('fetchBackend', () => { + it('should call fetch with backend URL and authorization headers', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-abc' } as any) + const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 }) + vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse) + + const response = await fetchBackend('/v1/test', { method: 'POST' }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + const [url, init] = vi.mocked(global.fetch).mock.calls[0] + expect(url).toContain('/v1/test') + expect(init?.method).toBe('POST') + + const headers = init?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer mock-token-abc') + expect(headers.get('Content-Type')).toBe('application/json') + expect(response).toBe(mockResponse) + }) + + it('should merge custom headers properly', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-xyz' } as any) + vi.mocked(global.fetch).mockResolvedValueOnce(new Response('{}', { status: 200 })) + + await fetchBackend('/v1/custom', { + headers: { 'X-Custom-Header': 'CustomValue' }, + }) + + const [, init] = vi.mocked(global.fetch).mock.calls[0] + const headers = init?.headers as Headers + expect(headers.get('X-Custom-Header')).toBe('CustomValue') + expect(headers.get('Authorization')).toBe('Bearer mock-token-xyz') + }) + }) +}) diff --git a/__tests__/lib/utils.test.ts b/__tests__/lib/utils.test.ts new file mode 100644 index 0000000..0275d97 --- /dev/null +++ b/__tests__/lib/utils.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { cn, getEnvColors } from '@/lib/utils' + +describe('lib/utils', () => { + describe('cn', () => { + it('should merge class names correctly', () => { + expect(cn('bg-red-500', 'text-white')).toBe('bg-red-500 text-white') + }) + + it('should handle conditional classes', () => { + expect(cn('base-class', true && 'active', false && 'disabled')).toBe('base-class active') + }) + + it('should resolve tailwind conflicts using tailwind-merge', () => { + expect(cn('px-2 py-1', 'p-4')).toBe('p-4') + expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500') + }) + }) + + describe('getEnvColors', () => { + it('should return dev colors for dev/development or unknown environment', () => { + const devColors = getEnvColors('dev') + expect(devColors.dot).toBe('bg-blue-500') + expect(devColors.text).toBe('text-blue-400') + + const developmentColors = getEnvColors('DEVELOPMENT') + expect(developmentColors.dot).toBe('bg-blue-500') + + const nullColors = getEnvColors(null) + expect(nullColors.dot).toBe('bg-blue-500') + }) + + it('should return staging colors for staging/stage environment', () => { + const stagingColors = getEnvColors('staging') + expect(stagingColors.dot).toBe('bg-yellow-500') + expect(stagingColors.text).toBe('text-yellow-400') + + const stageColors = getEnvColors('STAGE') + expect(stageColors.dot).toBe('bg-yellow-500') + }) + + it('should return prod colors for prod/production environment', () => { + const prodColors = getEnvColors('prod') + expect(prodColors.dot).toBe('bg-green-500') + expect(prodColors.text).toBe('text-green-400') + + const productionColors = getEnvColors('PRODUCTION') + expect(productionColors.dot).toBe('bg-green-500') + }) + }) +}) diff --git a/__tests__/proxy.test.ts b/__tests__/proxy.test.ts new file mode 100644 index 0000000..ca5059d --- /dev/null +++ b/__tests__/proxy.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest, NextResponse } from 'next/server' + +vi.mock('@/lib/auth0', () => ({ + auth0: { + middleware: vi.fn((req: any) => Promise.resolve(NextResponse.next())), + getSession: vi.fn(), + }, +})) + +import { proxy } from '@/proxy' +import { auth0 } from '@/lib/auth0' + +describe('proxy.ts (Middleware de Red y Seguridad)', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(auth0.middleware).mockImplementation(() => NextResponse.next()) + }) + + it('should redirect unauthenticated users accessing /dashboard to /auth/login', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce(null) + + const req = new NextRequest('http://localhost:3000/dashboard/applications') + const res = await proxy(req) + + expect(res.status).toBe(307) // Next.js NextResponse.redirect default status + expect(res.headers.get('location')).toBe('http://localhost:3000/auth/login') + }) + + it('should allow authenticated users accessing /dashboard', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce({ user: { sub: 'user-123' } } as any) + + const req = new NextRequest('http://localhost:3000/dashboard/applications') + const res = await proxy(req) + + expect(res.headers.get('location')).toBeNull() + }) + + it('should redirect unauthenticated users accessing /accept-invite to /auth/login with returnTo', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce(null) + + const req = new NextRequest('http://localhost:3000/accept-invite?token=invite-token-abc') + const res = await proxy(req) + + expect(res.status).toBe(307) + const expectedReturnTo = encodeURIComponent('/accept-invite?token=invite-token-abc') + expect(res.headers.get('location')).toBe(`http://localhost:3000/auth/login?returnTo=${expectedReturnTo}`) + }) + + it('should pass through requests for public routes like landing page', async () => { + const req = new NextRequest('http://localhost:3000/') + const res = await proxy(req) + + expect(res.headers.get('location')).toBeNull() + }) +}) diff --git a/__tests__/schemas/validation.test.ts b/__tests__/schemas/validation.test.ts new file mode 100644 index 0000000..3977c81 --- /dev/null +++ b/__tests__/schemas/validation.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import * as z from 'zod' + +// Resource form schema rules +const resourceSchema = z.object({ + name: z.string().min(2).regex(/^[a-z0-9_]+$/), + description: z.string().optional(), + mode: z.enum(["unit", "multiple"]), + ttl: z.coerce.number().min(1), + saveMetadata: z.boolean().default(false), + conflictStrategy: z.enum(["fail", "retry", "queue"]), + retryInterval: z.coerce.number().min(1).max(10).optional(), + maxRetries: z.coerce.number().min(1).max(5).optional(), + idempotency: z.boolean().default(false), + notificationWebhookUrl: z.string().max(255).optional().or(z.literal("")), +}) + +// Lock form schema rules +const lockSchema = z.object({ + namespace: z.string().min(2).regex(/^[a-z0-9_]+$/), + description: z.string().optional(), + type: z.enum(["exclusive", "read-write"]), + ttl: z.coerce.number().min(1), + deadlockStrategy: z.enum(["alert", "kill"]), + webhookUrl: z.string().url().optional().or(z.literal('')), + acquisitionStrategy: z.enum(["fail", "retry", "blocking"]), + retryInterval: z.coerce.number().min(10).optional(), + maxRetries: z.coerce.number().min(1).optional(), + requireFencingToken: z.boolean().default(false), +}) + +describe('Validation Schemas (Reglas de Negocio Zod)', () => { + describe('Resource Form Schema', () => { + it('should pass with valid resource payload', () => { + const validPayload = { + name: 'seat_reservation_v1', + mode: 'multiple', + ttl: 300, + conflictStrategy: 'retry', + retryInterval: 2, + maxRetries: 3, + } + const result = resourceSchema.safeParse(validPayload) + expect(result.success).toBe(true) + }) + + it('should reject names with uppercase or special characters', () => { + const invalidPayload = { + name: 'Seat-Reservation!', + mode: 'unit', + ttl: 10, + conflictStrategy: 'fail', + } + const result = resourceSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + + it('should reject TTL less than 1 second', () => { + const invalidPayload = { + name: 'valid_name', + mode: 'unit', + ttl: 0, + conflictStrategy: 'fail', + } + const result = resourceSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + }) + + describe('Lock Form Schema', () => { + it('should pass with valid lock payload', () => { + const validPayload = { + namespace: 'payment_sync_lock', + type: 'exclusive', + ttl: 60, + deadlockStrategy: 'alert', + acquisitionStrategy: 'retry', + retryInterval: 15, + maxRetries: 3, + requireFencingToken: true, + } + const result = lockSchema.safeParse(validPayload) + expect(result.success).toBe(true) + }) + + it('should reject invalid lock namespace formats', () => { + const invalidPayload = { + namespace: 'Payment Sync', + type: 'exclusive', + ttl: 60, + deadlockStrategy: 'alert', + acquisitionStrategy: 'fail', + } + const result = lockSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + + it('should reject invalid webhook URL', () => { + const invalidPayload = { + namespace: 'valid_lock', + type: 'exclusive', + ttl: 60, + deadlockStrategy: 'alert', + acquisitionStrategy: 'fail', + webhookUrl: 'not-a-valid-url', + } + const result = lockSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + }) +}) diff --git a/app/api/shared-resource-templates/route.ts b/app/api/shared-resource-templates/route.ts index 60d5333..84f25a6 100644 --- a/app/api/shared-resource-templates/route.ts +++ b/app/api/shared-resource-templates/route.ts @@ -42,8 +42,13 @@ export async function POST(request: NextRequest) { if (!response.ok) { const errorText = await response.text(); + let errorMsg = errorText; + try { + const parsed = JSON.parse(errorText); + errorMsg = parsed.message || parsed.error || errorText; + } catch {} return NextResponse.json( - { error: errorText || "Failed to create resource template" }, + { error: errorMsg || "Failed to create resource template" }, { status: response.status } ); } diff --git a/app/dashboard/applications/[id]/page.tsx b/app/dashboard/applications/[id]/page.tsx index 439550d..0a77d14 100644 --- a/app/dashboard/applications/[id]/page.tsx +++ b/app/dashboard/applications/[id]/page.tsx @@ -2,7 +2,9 @@ import Link from "next/link" import { use, useState, useEffect } from "react" +import { useSearchParams, useRouter } from "next/navigation" import { + ChevronDown, Box, Lock, Key, @@ -10,20 +12,16 @@ import { Settings, Activity, ArrowLeft, - Plus, - MoreVertical, - Play, - Pause, - Trash2, Loader2, Copy, Check, + Plus, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { cn, getEnvColors } from "@/lib/utils" import { DropdownMenu, DropdownMenuContent, @@ -31,6 +29,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { cn, getEnvColors } from "@/lib/utils" import { Select, SelectContent, @@ -47,211 +46,12 @@ import { DialogTitle, } from "@/components/ui/dialog" - -interface EnvData { - resources: Array<{ - id: string - name: string - mode: string - status: string - activeReservations: number - }> - locks: Array<{ - id: string - name: string - type: string - status: string - activeLocks: number - }> - apiKeys: Array<{ - id: string - name: string - prefix: string - createdAt: string - lastUsed: string - }> -} - -type AppEnvironmentsData = Record<"dev" | "staging" | "prod", EnvData> - -const appEnvironmentsMock: Record = { - "reserva-engine": { - dev: { - resources: [ - { id: "1-dev", name: "seat_reservation_dev", mode: "multiple", status: "active", activeReservations: 12 }, - { id: "2-dev", name: "test_lounge_dev", mode: "unit", status: "active", activeReservations: 0 }, - ], - locks: [ - { id: "1-dev", name: "payment_mock_lock", type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: "1-dev", name: "Clave Desarrollo", prefix: "ck_test_", createdAt: "2024-01-15", lastUsed: "Hace 5m" }, - ], - }, - staging: { - resources: [ - { id: "1-stage", name: "seat_reservation_staging", mode: "multiple", status: "active", activeReservations: 5 }, - ], - locks: [ - { id: "1-stage", name: "payment_stage_lock", type: "exclusive", status: "paused", activeLocks: 0 }, - ], - apiKeys: [ - { id: "1-stage", name: "Clave Staging", prefix: "ck_stage_", createdAt: "2024-01-15", lastUsed: "Hace 2h" }, - ], - }, - prod: { - resources: [ - { id: "1-prod", name: "seat_reservation", mode: "multiple", status: "active", activeReservations: 45 }, - { id: "2-prod", name: "vip_lounge", mode: "unit", status: "active", activeReservations: 2 }, - ], - locks: [ - { id: "1-prod", name: "payment_processor", type: "exclusive", status: "active", activeLocks: 3 }, - ], - apiKeys: [ - { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-01-15", lastUsed: "Hace 2m" }, - ], - }, - }, - "lock-service": { - dev: { - resources: [], - locks: [ - { id: "1-dev", name: "order_processing_dev", type: "exclusive", status: "active", activeLocks: 1 }, - { id: "2-dev", name: "inventory_sync_dev", type: "read-write", status: "active", activeLocks: 2 }, - ], - apiKeys: [ - { id: "1-dev", name: "Development Key", prefix: "ck_test_", createdAt: "2024-02-20", lastUsed: "Hace 5m" }, - ], - }, - staging: { - resources: [], - locks: [ - { id: "1-stage", name: "inventory_sync_staging", type: "read-write", status: "active", activeLocks: 0 }, - ], - apiKeys: [ - { id: "1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: "2024-02-20", lastUsed: "Hace 1d" }, - ], - }, - prod: { - resources: [], - locks: [ - { id: "1-prod", name: "order_processing", type: "exclusive", status: "active", activeLocks: 1 }, - { id: "2-prod", name: "inventory_sync", type: "read-write", status: "active", activeLocks: 5 }, - ], - apiKeys: [ - { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-02-20", lastUsed: "Hace 10m" }, - ], - }, - }, - "payment-sync": { - dev: { - resources: [ - { id: "1-dev", name: "transaction_slot_dev", mode: "unit", status: "active", activeReservations: 1 }, - ], - locks: [ - { id: "1-dev", name: "payment_gateway_dev", type: "exclusive", status: "active", activeLocks: 0 }, - ], - apiKeys: [ - { id: "1-dev", name: "Dev Key", prefix: "ck_test_", createdAt: "2024-03-10", lastUsed: "Hace 1h" }, - ], - }, - staging: { - resources: [ - { id: "1-stage", name: "transaction_slot_staging", mode: "unit", status: "active", activeReservations: 2 }, - ], - locks: [ - { id: "1-stage", name: "payment_gateway_staging", type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: "1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: "2024-03-10", lastUsed: "Hace 30m" }, - ], - }, - prod: { - resources: [ - { id: "1-prod", name: "transaction_slot", mode: "unit", status: "active", activeReservations: 12 }, - ], - locks: [ - { id: "1-prod", name: "payment_gateway", type: "exclusive", status: "active", activeLocks: 8 }, - ], - apiKeys: [ - { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-03-10", lastUsed: "Hace 30s" }, - ], - }, - }, -} - -const generateMockDataForApp = (name: string): AppEnvironmentsData => { - return { - dev: { - resources: [ - { id: "custom-1-dev", name: `${name}_resource_dev`, mode: "multiple", status: "active", activeReservations: 1 }, - ], - locks: [ - { id: "custom-lock-1-dev", name: `${name}_lock_dev`, type: "exclusive", status: "active", activeLocks: 0 }, - ], - apiKeys: [ - { id: "custom-key-1-dev", name: "Development Key", prefix: "ck_test_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 10m" }, - ], - }, - staging: { - resources: [ - { id: "custom-1-stage", name: `${name}_resource_staging`, mode: "multiple", status: "active", activeReservations: 2 }, - ], - locks: [ - { id: "custom-lock-1-stage", name: `${name}_lock_staging`, type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: "custom-key-1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 1d" }, - ], - }, - prod: { - resources: [ - { id: "custom-1-prod", name: `${name}_resource`, mode: "multiple", status: "active", activeReservations: 5 }, - ], - locks: [ - { id: "custom-lock-1-prod", name: `${name}_lock`, type: "exclusive", status: "active", activeLocks: 2 }, - ], - apiKeys: [ - { id: "custom-key-1-prod", name: "Production Key", prefix: "ck_live_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 30s" }, - ], - }, - } -} - -const getMockDataForEnv = (appName: string, envName: string): EnvData => { - const predefined = appEnvironmentsMock[appName]; - if (predefined) { - if (envName === "dev" || envName === "development") return predefined.dev; - if (envName === "stage" || envName === "staging") return predefined.staging; - if (envName === "prod" || envName === "production") return predefined.prod; - } - - return { - resources: [ - { id: `${envName}-res-1`, name: `${appName}_res_${envName}`, mode: "multiple", status: "active", activeReservations: 3 }, - ], - locks: [ - { id: `${envName}-lock-1`, name: `${appName}_lock_${envName}`, type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: `${envName}-key-1`, name: `Key ${envName}`, prefix: "ck_live_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 5m" }, - ], - }; -} - -const formatTtl = (ms: any): string => { - const num = Number(ms) - if (isNaN(num)) return String(ms) - if (num < 1000) return `${num} ms` - const seconds = num / 1000 - if (seconds < 60) return `${seconds.toFixed(seconds % 1 === 0 ? 0 : 1)} s` - const minutes = seconds / 60 - if (minutes < 60) return `${minutes.toFixed(minutes % 1 === 0 ? 0 : 1)} min` - const hours = minutes / 60 - if (hours < 24) return `${hours.toFixed(hours % 1 === 0 ? 0 : 1)} h` - const days = hours / 24 - return `${days.toFixed(days % 1 === 0 ? 0 : 1)} d` -} +import { Skeleton } from "@/components/ui/skeleton" +import { ApplicationDetailSkeleton } from "@/components/dashboard/applications/application-detail-skeleton" +import { ResourcesTab } from "@/components/dashboard/applications/tabs/resources-tab" +import { LocksTab } from "@/components/dashboard/applications/tabs/locks-tab" +import { ApiKeysTab } from "@/components/dashboard/applications/tabs/api-keys-tab" +import { DuplicateTemplateDialog } from "@/components/dashboard/applications/duplicate-template-dialog" export default function ApplicationDetailPage({ params, @@ -259,27 +59,30 @@ export default function ApplicationDetailPage({ params: Promise<{ id: string }> }) { const { id } = use(params) + const searchParams = useSearchParams() + const router = useRouter() + const [app, setApp] = useState(null) const [isLoading, setIsLoading] = useState(true) const [selectedEnv, setSelectedEnv] = useState("") const [currentEnvDetails, setCurrentEnvDetails] = useState(null) const [templates, setTemplates] = useState([]) - const [isTemplatesLoading, setIsTemplatesLoading] = useState(false) + const [locks, setLocks] = useState([]) + const [isTemplatesLoading, setIsTemplatesLoading] = useState(true) const [confirmDeleteTemplateOpen, setConfirmDeleteTemplateOpen] = useState(false) const [templateToDelete, setTemplateToDelete] = useState(null) + const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) + const [templateToDuplicate, setTemplateToDuplicate] = useState(null) + const [apiKeys, setApiKeys] = useState([]) - const [isApiKeysLoading, setIsApiKeysLoading] = useState(false) + const [isApiKeysLoading, setIsApiKeysLoading] = useState(true) const [confirmRevokeKeyOpen, setConfirmRevokeKeyOpen] = useState(false) const [keyToRevoke, setKeyToRevoke] = useState(null) const [showCreatedKeyDialog, setShowCreatedKeyDialog] = useState(false) const [createdRawKey, setCreatedRawKey] = useState("") const [copiedKey, setCopiedKey] = useState(false) - const currentEnvData = app && selectedEnv - ? getMockDataForEnv(app.name, selectedEnv) - : { resources: [], locks: [], apiKeys: [] } - useEffect(() => { const fetchApp = async () => { try { @@ -287,16 +90,21 @@ export default function ApplicationDetailPage({ if (res.ok) { const data = await res.json() const envs = data.environments || [] + + const queryEnv = searchParams.get("env") || searchParams.get("envName") + const savedEnv = typeof window !== "undefined" ? localStorage.getItem(`caerus_env_${id}`) : null - let initialEnv = "" - if (envs.length > 0) { + let initialEnv = queryEnv || savedEnv || "" + + if (!initialEnv || !envs.some((e: any) => e.name === initialEnv)) { const pref = envs.find((e: any) => e.name === "dev" || e.name === "development") || envs.find((e: any) => e.name === "stage" || e.name === "staging") || envs.find((e: any) => e.name === "prod" || e.name === "production") || - envs[0]; - initialEnv = pref.name; + envs[0] + initialEnv = pref ? pref.name : "" } - setSelectedEnv(initialEnv) + + setSelectedEnv(initialEnv || "dev") setApp({ id: data.id.toString(), @@ -311,7 +119,7 @@ export default function ApplicationDetailPage({ myRole: data.myRole || "VIEWER", }) } else { - console.error("Failed to fetch application details") + console.error("Failed to fetch application details from backend") } } catch (error) { console.error("Error fetching application details:", error) @@ -320,46 +128,69 @@ export default function ApplicationDetailPage({ } } fetchApp() - }, [id]) + }, [id, searchParams]) useEffect(() => { - if (!app || !app.environments || !selectedEnv) return; - const activeEnvObj = app.environments.find((env: any) => env.name === selectedEnv); - if (!activeEnvObj) return; + if (!app || !app.environments || !selectedEnv) return + const activeEnvObj = app.environments.find((env: any) => env.name === selectedEnv) + if (!activeEnvObj) return + + let isSubscribed = true const fetchEnvDetailsAndTemplates = async () => { - setIsTemplatesLoading(true); - setIsApiKeysLoading(true); + setIsTemplatesLoading(true) + setIsApiKeysLoading(true) try { const [envRes, templatesRes, keysRes] = await Promise.all([ fetch(`/api/applications/${id}/environments/${activeEnvObj.id}`), fetch(`/api/shared-resource-templates?environmentId=${activeEnvObj.id}`), fetch(`/api/environments/${activeEnvObj.id}/api-keys`), - ]); + new Promise((resolve) => setTimeout(resolve, 300)), + ]) + + if (!isSubscribed) return if (envRes.ok) { - const envData = await envRes.json(); - setCurrentEnvDetails(envData); + const envData = await envRes.json() + setCurrentEnvDetails(envData) + setLocks(envData.locks || []) } if (templatesRes.ok) { - const templatesData = await templatesRes.json(); - setTemplates(templatesData.content || []); + const templatesData = await templatesRes.json() + setTemplates(templatesData.content || []) } if (keysRes.ok) { - const keysData = await keysRes.json(); - setApiKeys(keysData.content || []); + const keysData = await keysRes.json() + setApiKeys(keysData.content || []) } } catch (error) { - console.error("Error fetching environment details, templates or keys:", error); + console.error("Error fetching environment details from backend:", error) } finally { - setIsTemplatesLoading(false); - setIsApiKeysLoading(false); + if (isSubscribed) { + setIsTemplatesLoading(false) + setIsApiKeysLoading(false) + } } - }; - fetchEnvDetailsAndTemplates(); - }, [selectedEnv, app]) + } + fetchEnvDetailsAndTemplates() + + return () => { + isSubscribed = false + } + }, [selectedEnv, app?.id]) + + const handleEnvChange = (val: string) => { + if (val === selectedEnv) return + setIsTemplatesLoading(true) + setIsApiKeysLoading(true) + setSelectedEnv(val) + if (typeof window !== "undefined") { + localStorage.setItem(`caerus_env_${id}`, val) + } + router.replace(`/dashboard/applications/${id}?env=${encodeURIComponent(val)}`, { scroll: false }) + } const handleCreateApiKey = async () => { if (!currentEnvDetails) return @@ -373,7 +204,7 @@ export default function ApplicationDetailPage({ setShowCreatedKeyDialog(true) setApiKeys((prev) => [data, ...prev]) } else { - console.error("Failed to create API key") + console.error("Failed to create API Key") } } catch (error) { console.error("Error creating API key:", error) @@ -386,15 +217,15 @@ export default function ApplicationDetailPage({ } const handleRevokeKeyConfirm = async () => { - if (!keyToRevoke) return + if (!keyToRevoke || !currentEnvDetails) return try { - const res = await fetch(`/api/environments/${currentEnvDetails.id}/api-keys/${keyToRevoke.id}/revoke`, { - method: "POST", - }) + const res = await fetch( + `/api/environments/${currentEnvDetails.id}/api-keys/${keyToRevoke.id}/revoke`, + { method: "POST" } + ) if (res.ok) { - const data = await res.json() setApiKeys((prev) => - prev.map((k) => (k.id === keyToRevoke.id ? data : k)) + prev.map((k) => (k.id === keyToRevoke.id ? { ...k, state: "REVOKED" } : k)) ) setConfirmRevokeKeyOpen(false) setKeyToRevoke(null) @@ -406,11 +237,12 @@ export default function ApplicationDetailPage({ } } - const copyRawKeyToClipboard = async () => { - if (!createdRawKey) return - await navigator.clipboard.writeText(createdRawKey) - setCopiedKey(true) - setTimeout(() => setCopiedKey(false), 2000) + const copyRawKeyToClipboard = () => { + if (createdRawKey) { + navigator.clipboard.writeText(createdRawKey) + setCopiedKey(true) + setTimeout(() => setCopiedKey(false), 2000) + } } const handleOpenDeleteTemplate = (template: any) => { @@ -436,16 +268,19 @@ export default function ApplicationDetailPage({ } } - const getEnvironmentBadgeClass = (env: string) => { - switch (env) { - case "prod": - return "bg-primary/20 text-primary" - case "dev": - return "bg-chart-2/20 text-chart-2" - case "staging": - return "bg-chart-4/20 text-chart-4" - default: - return "bg-secondary text-muted-foreground" + const handleOpenDuplicateTemplate = (template: any) => { + setTemplateToDuplicate(template) + setDuplicateDialogOpen(true) + } + + const handleDuplicateSuccess = () => { + if (app && selectedEnv) { + const activeEnvObj = app.environments.find((env: any) => env.name === selectedEnv) + if (activeEnvObj) { + fetch(`/api/shared-resource-templates?environmentId=${activeEnvObj.id}`) + .then((res) => res.json()) + .then((data) => setTemplates(data.content || [])) + } } } @@ -461,11 +296,7 @@ export default function ApplicationDetailPage({ } if (isLoading) { - return ( -
- -
- ) + return } if (!app) { @@ -479,6 +310,8 @@ export default function ApplicationDetailPage({ ) } + const descriptionText = currentEnvDetails?.description || (currentEnvDetails?.name && app?.name ? `Entorno de ${currentEnvDetails.name} para ${app.name}` : null) + return (
{/* Breadcrumb and header */} @@ -490,89 +323,77 @@ export default function ApplicationDetailPage({ Volver a Aplicaciones - {/* Row 1: Title and Controls */} -
-
-
- -
-
-

{app.name}

- - {app.status} - -
+
+
+

{app.name}

-
- + + -
- - + + {app.myRole !== "VIEWER" && ( + + - {app.myRole !== "VIEWER" && ( - - - - )} -
+ )}
- {/* Row 2: Description */} {app.description && ( -
-

- {app.description} -

-
+

+ {app.description} +

)}
@@ -597,7 +418,7 @@ export default function ApplicationDetailPage({
-
{currentEnvData.locks.length}
+
{locks.length}
@@ -622,44 +443,15 @@ export default function ApplicationDetailPage({
- {((((selectedEnv === "prod" || selectedEnv === "production") - ? 145230900 - : (selectedEnv === "stage" || selectedEnv === "staging") - ? 28920400 - : 4520300) + - currentEnvData.resources.reduce((sum: number, r: any) => sum + r.activeReservations, 0) + - currentEnvData.locks.reduce((sum: number, l: any) => sum + l.activeLocks, 0) - ).toLocaleString("es-AR"))} + 0
- {/* Selected environment description — compact but color-coded so it's obvious at a glance which - environment's resources are being shown below (the env name itself is already in the selector above) */} - {currentEnvDetails?.description && ( -
- - - - -

{currentEnvDetails.description}

-
- )} - - {/* Tabs for Resources, Locks, API Keys */} - -
+ {/* Tabs */} + +
@@ -680,275 +472,53 @@ export default function ApplicationDetailPage({
- -
-

- {templates.length === 1 ? "1 recurso configurado" : `${templates.length} recursos configurados`} -

- {app.myRole !== "VIEWER" && ( - - - - )} -
- - {templates.length === 0 ? ( - - - -

Aún no hay recursos configurados

- {app.myRole !== "VIEWER" && ( - - - - )} -
-
- ) : ( -
- {templates.map((template: any) => ( - - -
-
- -
-
-

{template.name}

-
- - Tipo {template.type === "UNITARY" ? "Unitario" : "Múltiple"} - - {template.defaultTtlSec && ( - <> - - - TTL: {formatTtl(template.defaultTtlSec * 1000)} - - - )} - - - Res: {template.conflictResolution} - -
- {template.description && ( -

- {template.description} -

- )} -
-
-
- {app.myRole !== "VIEWER" && ( - - - - - - - - - - Configurar - - - - - handleOpenDeleteTemplate(template)} - > - - Eliminar - - - - )} -
-
-
- ))} -
- )} + + - -
-

- {currentEnvData.locks.length === 1 ? "1 lock configurado" : `${currentEnvData.locks.length} locks configurados`} -

- {app.myRole !== "VIEWER" && ( - - - - )} -
- - {currentEnvData.locks.length === 0 ? ( - - - -

Aún no hay configuraciones de locks

- {app.myRole !== "VIEWER" && ( - - - - )} -
-
- ) : ( -
- {currentEnvData.locks.map((lock: any) => ( - - -
-
- -
-
-

{lock.name}

-
- Tipo {lock.type === "exclusive" ? "exclusivo" : "lectura-escritura"} - - {lock.activeLocks} activos -
-
-
-
- - {lock.status} - - {app.myRole !== "VIEWER" && ( - - - - - - - - - Configurar - - - - - Liberar Todos - - - - - Eliminar - - - - )} -
-
-
- ))} -
- )} + + -
-

- {apiKeys.length === 1 ? "1 API Key" : `${apiKeys.length} API Keys`} -

- {app.myRole !== "VIEWER" && ( - - )} -
- - {isApiKeysLoading ? ( -
- -
- ) : apiKeys.length === 0 ? ( - - - -

No hay API Keys configuradas para este ambiente

- {app.myRole !== "VIEWER" && ( - - )} -
-
- ) : ( -
- {apiKeys.map((key: any) => ( - - -
-
- -
-
-
-

{key.keyPrefix}••••••••••••

- - {key.state === "ACTIVE" ? "Activa" : "Revocada"} - -
-

- Creada el: {new Date(key.createdAt).toLocaleString()} - {key.revokedAt && ` • Revocada el: ${new Date(key.revokedAt).toLocaleString()}`} -

-
-
- {key.state === "ACTIVE" && app.myRole !== "VIEWER" && ( -
- -
- )} -
-
- ))} -
- )} +
+ {/* Duplicate Shared Resource Template Dialog */} + + {/* Delete Shared Resource Template Confirmation Dialog */} diff --git a/app/dashboard/applications/[id]/settings/page.tsx b/app/dashboard/applications/[id]/settings/page.tsx index 6b1be27..a7713ad 100644 --- a/app/dashboard/applications/[id]/settings/page.tsx +++ b/app/dashboard/applications/[id]/settings/page.tsx @@ -1,7 +1,7 @@ "use client" import { use, useState, useEffect } from "react" -import { useRouter } from "next/navigation" +import { useRouter, useSearchParams } from "next/navigation" import Link from "next/link" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -19,7 +19,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import { ArrowLeft, Loader2, Plus, Trash2, AlertTriangle, Pencil } from "lucide-react" -import { cn } from "@/lib/utils" +import { cn, ENV_COLOR_PRESETS, getEnvColors } from "@/lib/utils" interface Environment { id: string name: string @@ -35,6 +35,10 @@ export default function ApplicationSettingsPage({ }) { const { id } = use(params) const router = useRouter() + const searchParams = useSearchParams() + const actionParam = searchParams.get("action") + const envParam = searchParams.get("env") + const backUrl = envParam ? `/dashboard/applications/${id}?env=${encodeURIComponent(envParam)}` : `/dashboard/applications/${id}` const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) @@ -54,6 +58,15 @@ export default function ApplicationSettingsPage({ const [confirmDeleteEnvOpen, setConfirmDeleteEnvOpen] = useState(false) const [selectedEnvForDelete, setSelectedEnvForDelete] = useState(null) + useEffect(() => { + if (actionParam === "create_env") { + setEnvDialogMode("create") + setEnvForm({ name: "", description: "" }) + setEnvFormError("") + setEnvDialogOpen(true) + } + }, [actionParam]) + useEffect(() => { const fetchAppAndEnvs = async () => { try { @@ -104,15 +117,18 @@ export default function ApplicationSettingsPage({ const handleOpenCreateEnv = () => { setEnvDialogMode("create") setSelectedEnvForEdit(null) - setEnvForm({ name: "", description: "" }) + setEnvForm({ name: "", description: "", color: "blue" }) setEnvFormError("") setEnvDialogOpen(true) } const handleOpenEditEnv = (env: Environment) => { setEnvDialogMode("edit") - setSelectedEnvForEdit(env) - setEnvForm({ name: env.name, description: env.description || "" }) + const savedColor = typeof window !== "undefined" + ? (localStorage.getItem(`caerus_env_color_${env.id}`) || localStorage.getItem(`caerus_env_color_name_${env.name.toLowerCase()}`)) + : null + const defaultColor = (env.name === "prod" || env.name === "production") ? "green" : (env.name === "stage" || env.name === "staging" || env.name === "qa") ? "yellow" : "blue" + setEnvForm({ name: env.name, description: env.description || "", color: savedColor || defaultColor }) setEnvFormError("") setEnvDialogOpen(true) } @@ -140,6 +156,10 @@ export default function ApplicationSettingsPage({ }) if (res.ok) { const newEnv = await res.json() + if (typeof window !== "undefined") { + localStorage.setItem(`caerus_env_color_${newEnv.id}`, envForm.color) + localStorage.setItem(`caerus_env_color_name_${newEnv.name.toLowerCase()}`, envForm.color) + } setEnvironments((prev) => [ ...prev, { @@ -166,6 +186,10 @@ export default function ApplicationSettingsPage({ }) if (res.ok) { const updatedEnv = await res.json() + if (typeof window !== "undefined") { + localStorage.setItem(`caerus_env_color_${selectedEnvForEdit.id}`, envForm.color) + localStorage.setItem(`caerus_env_color_name_${updatedEnv.name.toLowerCase()}`, envForm.color) + } setEnvironments((prev) => prev.map((e) => e.id === selectedEnvForEdit.id @@ -249,7 +273,8 @@ export default function ApplicationSettingsPage({ if (response.ok) { router.push("/dashboard/applications") } else { - console.error("Failed to delete application") + const errorData = await response.json().catch(() => ({})) + console.error("Failed to delete application:", errorData.error || response.statusText) } } catch (error) { console.error("Error deleting application:", error) @@ -267,152 +292,148 @@ export default function ApplicationSettingsPage({ } return ( - -
- {/* Header */} -
- - - -
-

- Configuración de Aplicación -

-

- Modifica la configuración de tu aplicación -

-
+
+ {/* Header */} +
+ + + +
+

+ Configuración de Aplicación +

+

+ Modifica la configuración de tu aplicación +

- -
+
+
{/* Basic Info */} - - - Información General - - Actualiza el nombre y descripción de tu aplicación - - - -
-
- - = 100 ? "text-destructive font-semibold" : formData.name.length >= 90 ? "text-yellow-500 font-medium" : "text-muted-foreground" - )}> - {formData.name.length} / 100 - +
+ + + Información General + + Actualiza el nombre y descripción de tu aplicación + + + +
+
+ + = 100 ? "text-destructive font-semibold" : formData.name.length >= 90 ? "text-yellow-500 font-medium" : "text-muted-foreground" + )}> + {formData.name.length} / 100 + +
+ handleChange("name", e.target.value)} + disabled={isSaving} + maxLength={100} + />
- handleChange("name", e.target.value)} - disabled={isSaving} - maxLength={100} - /> -
- -
-
- - = 500 ? "text-destructive font-semibold" : formData.description.length >= 450 ? "text-yellow-500 font-medium" : "text-muted-foreground" - )}> - {formData.description.length} / 500 - +
+
+ + = 500 ? "text-destructive font-semibold" : formData.description.length >= 450 ? "text-yellow-500 font-medium" : "text-muted-foreground" + )}> + {formData.description.length} / 500 + +
+