Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15,110 changes: 3,507 additions & 11,603 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

462 changes: 462 additions & 0 deletions src/app/admin/certificates/page.tsx

Large diffs are not rendered by default.

89 changes: 62 additions & 27 deletions src/app/api/certificates/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,26 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/authMiddleware';
import { createLogger } from '@/lib/logging';
import { appendAuditLog } from '@/lib/audit';
import { getCertificateById, getCertificateForDownload } from '@/services/certificate-service';
import {
getCertificateById,
getCertificateForDownload,
revokeCertificate,
} from '@/services/certificate-service';

const logger = createLogger('certificates-retrieve');

/**
* GET /api/certificates/:id
*
*
* Retrieve certificate metadata (requires ownership verification).
*
*
* SECURITY CHECKS:
* ✓ T4: Auth middleware (requireAuth)
* ✓ T1: Ownership verification (IDOR mitigation)
* ✓ T7: Opaque UUIDs (no sequential ID enumeration)
* ✓ T8: Audit logging of access attempts
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } },
) {
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
// T4 MITIGATION: Require authentication
const authError = requireAuth(request);
if (authError) {
Expand All @@ -33,10 +34,7 @@ export async function GET(

if (userId === 'anonymous') {
logger.error('User ID not provided in request headers');
return NextResponse.json(
{ error: 'User identification failed' },
{ status: 500 },
);
return NextResponse.json({ error: 'User identification failed' }, { status: 500 });
}

try {
Expand All @@ -62,10 +60,7 @@ export async function GET(
metadata: { reason: 'not_found' },
});

return NextResponse.json(
{ error: 'Not found' },
{ status: 404 },
);
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}

// T1 MITIGATION: Ownership verification (IDOR prevention)
Expand Down Expand Up @@ -98,18 +93,12 @@ export async function GET(
// SECURITY: Return 404, not 403, to avoid leaking certificate existence
// Tradeoff: Legitimate owner cannot distinguish "doesn't exist" from "not mine"
// Rationale: Prevents enumeration of valid certificate IDs by iterating numbers
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 },
);
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}

const response = await getCertificateForDownload(certificateId);
if (!response) {
return NextResponse.json(
{ error: 'Certificate revoked or deleted' },
{ status: 404 },
);
return NextResponse.json({ error: 'Certificate revoked or deleted' }, { status: 404 });
}

// T8 MITIGATION: Log successful access
Expand Down Expand Up @@ -150,10 +139,7 @@ export async function GET(
metadata: { reason: 'internal_error' },
});

return NextResponse.json(
{ error: 'Failed to retrieve certificate' },
{ status: 500 },
);
return NextResponse.json({ error: 'Failed to retrieve certificate' }, { status: 500 });
}
}

Expand All @@ -172,3 +158,52 @@ function getClientIp(request: NextRequest): string {

return '127.0.0.1';
}

/**
* DELETE /api/certificates/:id
*
* Soft-delete / revoke a certificate.
*/
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
const authError = requireAuth(request);
if (authError) {
logger.warn('Unauthorized attempt to revoke certificate');
return authError;
}

const certificateId = params.id;
const userId = request.headers.get('x-user-id') || 'anonymous';

try {
const cert = await getCertificateById(certificateId);
if (!cert) {
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 });
}

const success = await revokeCertificate(certificateId);
if (!success) {
return NextResponse.json({ error: 'Failed to revoke certificate' }, { status: 500 });
}

appendAuditLog({
actorId: userId,
action: 'delete',
targetType: 'certificate',
targetId: certificateId,
path: request.nextUrl.pathname,
method: request.method,
ip: getClientIp(request),
userAgent: request.headers.get('user-agent') || 'unknown',
statusCode: 200,
metadata: { revokedBy: userId },
});

return NextResponse.json(
{ success: true, message: 'Certificate successfully revoked' },
{ status: 200 },
);
} catch (error) {
logger.error('Failed to revoke certificate', { certificateId, error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
55 changes: 55 additions & 0 deletions src/app/api/certificates/__tests__/certificate-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET as getStats } from '../stats/route';
import { DELETE as revokeCert } from '../[id]/route';
import { getCertificateStats, getCertificateById } from '@/services/certificate-service';
import { requireAuth } from '@/lib/authMiddleware';

vi.mock('@/lib/authMiddleware', () => ({
requireAuth: vi.fn(() => null),
}));

describe('Certificate Analytics API & Services', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('getCertificateStats returns valid aggregation data', async () => {
const stats = await getCertificateStats();
expect(stats).toBeDefined();
expect(stats.totalIssued).toBeGreaterThanOrEqual(9);
expect(stats.totalActive).toBeGreaterThanOrEqual(8);
expect(stats.totalRevoked).toBe(1);
expect(stats.completionsByCourse).toBeInstanceOf(Array);
expect(stats.completionsByMonth).toBeInstanceOf(Array);
});

it('GET /api/certificates/stats returns successfully when authenticated', async () => {
const req = new NextRequest('http://localhost/api/certificates/stats');
const res = await getStats(req);
expect(res.status).toBe(200);

const body = await res.json();
expect(body.totalIssued).toBeDefined();
expect(body.totalActive).toBeDefined();
expect(body.totalRevoked).toBeDefined();
});

it('DELETE /api/certificates/:id successfully revokes certificate', async () => {
// Let's use the first mock certificate ID
const targetCertId = 'cert-0000-0000-0000-000000000001';

const req = new NextRequest(`http://localhost/api/certificates/${targetCertId}`, {
method: 'DELETE',
});

const res = await revokeCert(req, { params: { id: targetCertId } });
expect(res.status).toBe(200);

const body = await res.json();
expect(body.success).toBe(true);

const cert = await getCertificateById(targetCertId);
expect(cert?.revokedAt).toBeDefined();
});
});
28 changes: 28 additions & 0 deletions src/app/api/certificates/stats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/authMiddleware';
import { createLogger } from '@/lib/logging';
import { getCertificateStats } from '@/services/certificate-service';

const logger = createLogger('certificates-stats');

/**
* GET /api/certificates/stats
*
* Returns aggregated statistics for certificate completion analytics.
*/
export async function GET(request: NextRequest) {
// Check auth
const authError = requireAuth(request);
if (authError) {
logger.warn('Unauthorized attempt to read certificate stats');
return authError;
}

try {
const stats = await getCertificateStats();
return NextResponse.json(stats, { status: 200 });
} catch (error) {
logger.error('Failed to retrieve certificate stats', { error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
43 changes: 38 additions & 5 deletions src/components/admin/AdminThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,44 @@
import React from 'react';
import { Moon, Sun, Monitor } from 'lucide-react';
import { useThemeContext, Theme } from '@/contexts/ThemeContext';
import { ErrorBoundary } from '@/components/errors/ErrorBoundarySystem';
import { errorReportingService } from '@/services/errorReporting';
import AdminThemeToggleFallback from './AdminThemeToggleFallback';

/**
* Admin-specific dark mode toggle for the admin panel header.
* Provides light/dark/system mode switching with visual feedback.
* Inner component that uses theme context and performs actions.
* Can throw if context is missing.
*/
export default function AdminThemeToggle() {
const { theme, setTheme, resolvedTheme } = useThemeContext();
function AdminThemeToggleControl() {
const { theme, setTheme } = useThemeContext();

const options: { value: Theme; icon: React.ReactNode; label: string }[] = [
{ value: 'light', icon: <Sun className="w-4 h-4" />, label: 'Light' },
{ value: 'dark', icon: <Moon className="w-4 h-4" />, label: 'Dark' },
{ value: 'system', icon: <Monitor className="w-4 h-4" />, label: 'System' },
];

const handleSelectTheme = (value: Theme) => {
try {
setTheme(value);
} catch (err) {
if (typeof errorReportingService?.reportError === 'function') {
errorReportingService.reportError(err instanceof Error ? err : new Error(String(err)), {
component: 'AdminThemeToggle',
action: 'handleSelectTheme',
selectedTheme: value,
});
}
}
};

return (
<div className="flex items-center gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
type="button"
onClick={() => handleSelectTheme(opt.value)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 ${
theme === opt.value
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
Expand All @@ -38,3 +56,18 @@ export default function AdminThemeToggle() {
</div>
);
}

/**
* Admin-specific dark mode toggle wrapped with ErrorBoundary.
*/
export default function AdminThemeToggle() {
return (
<ErrorBoundary
fallback={<AdminThemeToggleFallback />}
isolationId="admin-theme-toggle"
isolationLevel="component"
>
<AdminThemeToggleControl />
</ErrorBoundary>
);
}
37 changes: 37 additions & 0 deletions src/components/admin/AdminThemeToggleFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import React from 'react';
import { Moon, Sun, Monitor } from 'lucide-react';

/**
* Fallback component for AdminThemeToggle in case of theme context/state failure.
* Renders disabled but visually aligned buttons to prevent layout shifting.
*/
export default function AdminThemeToggleFallback() {
const options = [
{ key: 'light', icon: <Sun className="w-4 h-4" />, label: 'Light' },
{ key: 'dark', icon: <Moon className="w-4 h-4" />, label: 'Dark' },
{ key: 'system', icon: <Monitor className="w-4 h-4" />, label: 'System' },
];

return (
<div
className="flex items-center gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-850 border border-gray-200 dark:border-gray-800 opacity-60 cursor-not-allowed"
aria-label="Admin theme toggle temporarily unavailable"
title="Theme toggle unavailable"
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-gray-400 dark:text-gray-500 cursor-not-allowed pointer-events-none"
aria-label={`${opt.label} mode toggle unavailable`}
>
{opt.icon}
<span className="hidden sm:inline">{opt.label}</span>
</button>
))}
</div>
);
}
Loading