diff --git a/DEV-SETUP.md b/DEV-SETUP.md
index 1badacc..8cb5710 100644
--- a/DEV-SETUP.md
+++ b/DEV-SETUP.md
@@ -47,7 +47,7 @@ This starts only the Next.js development server without Docker services. Use thi
### 3. Access Services
- **Portfolio**: http://localhost:3000
-- **Admin Dashboard**: http://localhost:3000/admin
+- **Admin Dashboard**: http://localhost:3000/manage
- **PostgreSQL**: localhost:5432
- **Redis**: localhost:6379
@@ -235,5 +235,5 @@ The production environment uses the production Docker Compose configuration.
## 🔗 Links
- **Portfolio**: https://dk0.dev
-- **Admin**: https://dk0.dev/admin
+- **Admin**: https://dk0.dev/manage
- **GitHub**: https://github.com/denniskonkol/portfolio
diff --git a/README.md b/README.md
index a37651f..17babc9 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ npm run start # Production Server
## 🌐 URLs
- **Portfolio**: http://localhost:3000
-- **Admin Dashboard**: http://localhost:3000/admin
+- **Admin Dashboard**: http://localhost:3000/manage
- **PostgreSQL**: localhost:5432
- **Redis**: localhost:6379
@@ -54,5 +54,5 @@ npm run start # Production Server
## 🔗 Links
- **Live Portfolio**: https://dk0.dev
-- **Admin Dashboard**: https://dk0.dev/admin
+- **Admin Dashboard**: https://dk0.dev/manage
- **GitHub**: https://github.com/denniskonkol/portfolio
diff --git a/app/__tests__/components/Toast.test.tsx b/app/__tests__/components/Toast.test.tsx
new file mode 100644
index 0000000..2084bd3
--- /dev/null
+++ b/app/__tests__/components/Toast.test.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ToastProvider } from '@/components/Toast';
+
+// Simple test component
+const TestComponent = () => {
+ return (
+
+
Toast Test
+
+ );
+};
+
+const renderWithToast = (component: React.ReactElement) => {
+ return render(
+
+ {component}
+
+ );
+};
+
+describe('Toast Component', () => {
+ it('renders ToastProvider without crashing', () => {
+ renderWithToast();
+ expect(screen.getByText('Toast Test')).toBeInTheDocument();
+ });
+
+ it('provides toast context', () => {
+ // Simple test to ensure the provider works
+ const { container } = renderWithToast();
+ expect(container).toBeInTheDocument();
+ });
+});
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
deleted file mode 100644
index bac1b11..0000000
--- a/app/admin/page.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-"use client";
-
-import ModernAdminDashboard from '@/components/ModernAdminDashboard';
-
-const AdminPage = () => {
- return ;
-};
-
-export default AdminPage;
\ No newline at end of file
diff --git a/app/api/analytics/dashboard/route.ts b/app/api/analytics/dashboard/route.ts
index 6083264..59cea5b 100644
--- a/app/api/analytics/dashboard/route.ts
+++ b/app/api/analytics/dashboard/route.ts
@@ -1,27 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis';
+import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
- // Check admin authentication
- const authHeader = request.headers.get('authorization');
- const basicAuth = process.env.ADMIN_BASIC_AUTH;
-
- if (!basicAuth) {
- return new NextResponse('Admin access not configured', { status: 500 });
+ // Rate limiting - more generous for admin dashboard
+ const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
+ if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
+ return new NextResponse(
+ JSON.stringify({ error: 'Rate limit exceeded' }),
+ {
+ status: 429,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getRateLimitHeaders(ip, 5, 60000)
+ }
+ }
+ );
}
- if (!authHeader || !authHeader.startsWith('Basic ')) {
- return new NextResponse('Authentication required', { status: 401 });
- }
-
- const credentials = authHeader.split(' ')[1];
- const [username, password] = Buffer.from(credentials, 'base64').toString().split(':');
- const [expectedUsername, expectedPassword] = basicAuth.split(':');
-
- if (username !== expectedUsername || password !== expectedPassword) {
- return new NextResponse('Invalid credentials', { status: 401 });
+ // Check admin authentication - for admin dashboard requests, we trust the session
+ // The middleware has already verified the admin session for /manage routes
+ const isAdminRequest = request.headers.get('x-admin-request') === 'true';
+ if (!isAdminRequest) {
+ const authError = requireAdminAuth(request);
+ if (authError) {
+ return authError;
+ }
}
// Check cache first
diff --git a/app/api/analytics/performance/route.ts b/app/api/analytics/performance/route.ts
index 2f845c2..4a644b6 100644
--- a/app/api/analytics/performance/route.ts
+++ b/app/api/analytics/performance/route.ts
@@ -1,26 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
+import { requireAdminAuth } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
- // Check admin authentication
- const authHeader = request.headers.get('authorization');
- const basicAuth = process.env.ADMIN_BASIC_AUTH;
-
- if (!basicAuth) {
- return new NextResponse('Admin access not configured', { status: 500 });
- }
-
- if (!authHeader || !authHeader.startsWith('Basic ')) {
- return new NextResponse('Authentication required', { status: 401 });
- }
-
- const credentials = authHeader.split(' ')[1];
- const [username, password] = Buffer.from(credentials, 'base64').toString().split(':');
- const [expectedUsername, expectedPassword] = basicAuth.split(':');
-
- if (username !== expectedUsername || password !== expectedPassword) {
- return new NextResponse('Invalid credentials', { status: 401 });
+ // Check admin authentication - for admin dashboard requests, we trust the session
+ const isAdminRequest = request.headers.get('x-admin-request') === 'true';
+ if (!isAdminRequest) {
+ const authError = requireAdminAuth(request);
+ if (authError) {
+ return authError;
+ }
}
// Get performance data from database
diff --git a/app/api/analytics/reset/route.ts b/app/api/analytics/reset/route.ts
new file mode 100644
index 0000000..0b0a49c
--- /dev/null
+++ b/app/api/analytics/reset/route.ts
@@ -0,0 +1,199 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { analyticsCache } from '@/lib/redis';
+import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
+
+export async function POST(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
+ if (!checkRateLimit(ip, 3, 300000)) { // 3 requests per 5 minutes - more restrictive for reset
+ return new NextResponse(
+ JSON.stringify({ error: 'Rate limit exceeded' }),
+ {
+ status: 429,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getRateLimitHeaders(ip, 3, 300000)
+ }
+ }
+ );
+ }
+
+ // Check admin authentication
+ const isAdminRequest = request.headers.get('x-admin-request') === 'true';
+ if (!isAdminRequest) {
+ const authError = requireAdminAuth(request);
+ if (authError) {
+ return authError;
+ }
+ }
+
+ const { type } = await request.json();
+
+ switch (type) {
+ case 'analytics':
+ // Reset all project analytics
+ await prisma.project.updateMany({
+ data: {
+ analytics: {
+ views: 0,
+ likes: 0,
+ shares: 0,
+ comments: 0,
+ bookmarks: 0,
+ clickThroughs: 0,
+ bounceRate: 0,
+ avgTimeOnPage: 0,
+ uniqueVisitors: 0,
+ returningVisitors: 0,
+ conversionRate: 0,
+ socialShares: {
+ twitter: 0,
+ linkedin: 0,
+ facebook: 0,
+ github: 0
+ },
+ deviceStats: {
+ mobile: 0,
+ desktop: 0,
+ tablet: 0
+ },
+ locationStats: {},
+ referrerStats: {},
+ lastUpdated: new Date().toISOString()
+ }
+ }
+ });
+ break;
+
+ case 'pageviews':
+ // Clear PageView table
+ await prisma.pageView.deleteMany({});
+ break;
+
+ case 'interactions':
+ // Clear UserInteraction table
+ await prisma.userInteraction.deleteMany({});
+ break;
+
+ case 'performance':
+ // Reset performance metrics
+ await prisma.project.updateMany({
+ data: {
+ performance: {
+ lighthouse: 0,
+ loadTime: 0,
+ firstContentfulPaint: 0,
+ largestContentfulPaint: 0,
+ cumulativeLayoutShift: 0,
+ totalBlockingTime: 0,
+ speedIndex: 0,
+ accessibility: 0,
+ bestPractices: 0,
+ seo: 0,
+ performanceScore: 0,
+ mobileScore: 0,
+ desktopScore: 0,
+ coreWebVitals: {
+ lcp: 0,
+ fid: 0,
+ cls: 0
+ },
+ lastUpdated: new Date().toISOString()
+ }
+ }
+ });
+ break;
+
+ case 'all':
+ // Reset everything
+ await Promise.all([
+ // Reset analytics
+ prisma.project.updateMany({
+ data: {
+ analytics: {
+ views: 0,
+ likes: 0,
+ shares: 0,
+ comments: 0,
+ bookmarks: 0,
+ clickThroughs: 0,
+ bounceRate: 0,
+ avgTimeOnPage: 0,
+ uniqueVisitors: 0,
+ returningVisitors: 0,
+ conversionRate: 0,
+ socialShares: {
+ twitter: 0,
+ linkedin: 0,
+ facebook: 0,
+ github: 0
+ },
+ deviceStats: {
+ mobile: 0,
+ desktop: 0,
+ tablet: 0
+ },
+ locationStats: {},
+ referrerStats: {},
+ lastUpdated: new Date().toISOString()
+ }
+ }
+ }),
+ // Reset performance
+ prisma.project.updateMany({
+ data: {
+ performance: {
+ lighthouse: 0,
+ loadTime: 0,
+ firstContentfulPaint: 0,
+ largestContentfulPaint: 0,
+ cumulativeLayoutShift: 0,
+ totalBlockingTime: 0,
+ speedIndex: 0,
+ accessibility: 0,
+ bestPractices: 0,
+ seo: 0,
+ performanceScore: 0,
+ mobileScore: 0,
+ desktopScore: 0,
+ coreWebVitals: {
+ lcp: 0,
+ fid: 0,
+ cls: 0
+ },
+ lastUpdated: new Date().toISOString()
+ }
+ }
+ }),
+ // Clear tracking tables
+ prisma.pageView.deleteMany({}),
+ prisma.userInteraction.deleteMany({})
+ ]);
+ break;
+
+ default:
+ return NextResponse.json(
+ { error: 'Invalid reset type. Use: analytics, pageviews, interactions, performance, or all' },
+ { status: 400 }
+ );
+ }
+
+ // Clear cache
+ await analyticsCache.clearAll();
+
+ return NextResponse.json({
+ success: true,
+ message: `Successfully reset ${type} data`,
+ timestamp: new Date().toISOString()
+ });
+
+ } catch (error) {
+ console.error('Analytics reset error:', error);
+ return NextResponse.json(
+ { error: 'Failed to reset analytics data' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/auth/csrf/route.ts b/app/api/auth/csrf/route.ts
new file mode 100644
index 0000000..40dc59f
--- /dev/null
+++ b/app/api/auth/csrf/route.ts
@@ -0,0 +1,55 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+// Generate CSRF token
+async function generateCSRFToken(): Promise {
+ const crypto = await import('crypto');
+ return crypto.randomBytes(32).toString('hex');
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ // Rate limiting for CSRF token requests
+ const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
+ const now = Date.now();
+
+ // Simple in-memory rate limiting for CSRF tokens (in production, use Redis)
+ const key = `csrf_${ip}`;
+ const rateLimitMap = (global as unknown as Record>).csrfRateLimit || ((global as unknown as Record>).csrfRateLimit = new Map());
+
+ const current = rateLimitMap.get(key);
+ if (current && now - current.timestamp < 60000) { // 1 minute
+ if (current.count >= 10) {
+ return new NextResponse(
+ JSON.stringify({ error: 'Rate limit exceeded for CSRF tokens' }),
+ { status: 429, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+ current.count++;
+ } else {
+ rateLimitMap.set(key, { count: 1, timestamp: now });
+ }
+
+ const csrfToken = await generateCSRFToken();
+
+ return new NextResponse(
+ JSON.stringify({ csrfToken }),
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ 'X-XSS-Protection': '1; mode=block',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ }
+ }
+ );
+ } catch {
+ return new NextResponse(
+ JSON.stringify({ error: 'Internal server error' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+}
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..d6669ed
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
+
+export async function POST(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
+ if (!checkRateLimit(ip, 5, 60000)) { // 5 login attempts per minute
+ return new NextResponse(
+ JSON.stringify({ error: 'Rate limit exceeded' }),
+ {
+ status: 429,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getRateLimitHeaders(ip, 5, 60000)
+ }
+ }
+ );
+ }
+
+ const { password, csrfToken } = await request.json();
+
+ if (!password) {
+ return new NextResponse(
+ JSON.stringify({ error: 'Password required' }),
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // CSRF Protection
+ const expectedCSRF = request.headers.get('x-csrf-token');
+ if (!csrfToken || !expectedCSRF || csrfToken !== expectedCSRF) {
+ return new NextResponse(
+ JSON.stringify({ error: 'CSRF token validation failed' }),
+ { status: 403, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Get admin credentials from environment
+ const adminAuth = process.env.ADMIN_BASIC_AUTH || 'admin:default_password_change_me';
+ const [, expectedPassword] = adminAuth.split(':');
+
+ // Secure password comparison
+ if (password === expectedPassword) {
+ // Generate cryptographically secure session token
+ const timestamp = Date.now();
+ const crypto = await import('crypto');
+ const randomBytes = crypto.randomBytes(32);
+ const randomString = randomBytes.toString('hex');
+
+ // Create session data
+ const sessionData = {
+ timestamp,
+ random: randomString,
+ ip: ip,
+ userAgent: request.headers.get('user-agent') || 'unknown'
+ };
+
+ // Encrypt session data
+ const sessionJson = JSON.stringify(sessionData);
+ const sessionToken = btoa(sessionJson);
+
+ return new NextResponse(
+ JSON.stringify({
+ success: true,
+ message: 'Login successful',
+ sessionToken
+ }),
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ 'X-XSS-Protection': '1; mode=block'
+ }
+ }
+ );
+ } else {
+ return new NextResponse(
+ JSON.stringify({ error: 'Invalid password' }),
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+ } catch {
+ return new NextResponse(
+ JSON.stringify({ error: 'Internal server error' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+}
diff --git a/app/api/auth/validate/route.ts b/app/api/auth/validate/route.ts
new file mode 100644
index 0000000..117649a
--- /dev/null
+++ b/app/api/auth/validate/route.ts
@@ -0,0 +1,93 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(request: NextRequest) {
+ try {
+ const { sessionToken, csrfToken } = await request.json();
+
+ if (!sessionToken) {
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'No session token provided' }),
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // CSRF Protection
+ const expectedCSRF = request.headers.get('x-csrf-token');
+ if (!csrfToken || !expectedCSRF || csrfToken !== expectedCSRF) {
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'CSRF token validation failed' }),
+ { status: 403, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Decode and validate session token
+ try {
+ const decodedJson = atob(sessionToken);
+ const sessionData = JSON.parse(decodedJson);
+
+ // Validate session data structure
+ if (!sessionData.timestamp || !sessionData.random || !sessionData.ip || !sessionData.userAgent) {
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'Invalid session token structure' }),
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Check if session is still valid (2 hours)
+ const sessionTime = sessionData.timestamp;
+ const now = Date.now();
+ const sessionDuration = 2 * 60 * 60 * 1000; // 2 hours
+
+ if (now - sessionTime > sessionDuration) {
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'Session expired' }),
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Validate IP address (optional, but good security practice)
+ const currentIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
+ if (sessionData.ip !== currentIp) {
+ // Log potential session hijacking attempt
+ console.warn(`Session IP mismatch: expected ${sessionData.ip}, got ${currentIp}`);
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'Session validation failed' }),
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Validate User-Agent (optional)
+ const currentUserAgent = request.headers.get('user-agent') || 'unknown';
+ if (sessionData.userAgent !== currentUserAgent) {
+ console.warn(`Session User-Agent mismatch`);
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'Session validation failed' }),
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ return new NextResponse(
+ JSON.stringify({ valid: true, message: 'Session valid' }),
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ 'X-XSS-Protection': '1; mode=block'
+ }
+ }
+ );
+ } catch {
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'Invalid session token format' }),
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+ } catch {
+ return new NextResponse(
+ JSON.stringify({ valid: false, error: 'Internal server error' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+}
diff --git a/app/api/email/respond/route.tsx b/app/api/email/respond/route.tsx
index b488017..c19963d 100644
--- a/app/api/email/respond/route.tsx
+++ b/app/api/email/respond/route.tsx
@@ -319,6 +319,98 @@ const emailTemplates = {