Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31de474d16 | ||
|
|
975494b46b | ||
|
|
206fc3e8a9 | ||
|
|
b8233bdc2f | ||
|
|
301516fac7 | ||
|
|
919fe083f2 | ||
|
|
131c883425 | ||
|
|
5551226353 | ||
|
|
987e0fc10f | ||
|
|
23c79f1fa2 | ||
|
|
a792db0290 | ||
|
|
2337b15e53 | ||
|
|
d9f209c40c | ||
|
|
20cbbc439c | ||
|
|
99c80408e8 | ||
|
|
8f6579826b | ||
|
|
f66580baa1 | ||
|
|
1b89129b52 | ||
|
|
8752942af0 |
@@ -1,6 +1,12 @@
|
||||
import { connection } from 'next/server';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import DashboardContent from '@/components/auth/DashboardContent';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
// Force dynamic rendering to ensure auth checks run on every request
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -29,8 +35,19 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function DashboardPage({ params }: Props) {
|
||||
// CRITICAL: Use connection() API to guarantee dynamic rendering in Next.js 15
|
||||
await connection();
|
||||
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
// Server-side auth check (defense-in-depth)
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/${locale}/login`);
|
||||
}
|
||||
|
||||
return <DashboardContent />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, BarChart2, Users, Eye, Clock, TrendingUp, TrendingDown, LogOut } from 'lucide-react';
|
||||
import { BarChart2, Users, Eye, Clock, TrendingUp, TrendingDown, LogOut } from 'lucide-react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
interface MetricCard {
|
||||
title: string;
|
||||
@@ -20,6 +18,8 @@ export default function DashboardContent() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function DashboardContent() {
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
>>>>>>> origin/master
|
||||
|
||||
const handleLogout = async () => {
|
||||
const supabase = createClient();
|
||||
@@ -41,25 +42,6 @@ export default function DashboardContent() {
|
||||
router.push(`/${locale}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
|
||||
<p className="text-zinc-400 text-sm">{t('status.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-zinc-400">{t('status.notAuthenticated')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics: MetricCard[] = [
|
||||
{
|
||||
title: t('metrics.pageViews.title'),
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import { Lock, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { rateLimiter } from '../../utils/rateLimiting';
|
||||
|
||||
export default function LoginForm() {
|
||||
const t = useTranslations('login');
|
||||
@@ -14,20 +15,48 @@ export default function LoginForm() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number | null>(null);
|
||||
const [isLockedOut, setIsLockedOut] = useState(false);
|
||||
const [lockoutMinutes, setLockoutMinutes] = useState(0);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setRemainingAttempts(null);
|
||||
setIsLockedOut(false);
|
||||
|
||||
try {
|
||||
// Rate limiting per browser session (client-side UX protection)
|
||||
// Real brute-force protection relies on Supabase's server-side rate limiting
|
||||
const clientIp = '127.0.0.1';
|
||||
|
||||
// Check if already locked out BEFORE attempting auth
|
||||
if (rateLimiter.isRateLimited(clientIp)) {
|
||||
const timeToReset = Math.ceil(rateLimiter.getTimeToReset(clientIp) / 1000 / 60);
|
||||
setIsLockedOut(true);
|
||||
setLockoutMinutes(timeToReset);
|
||||
const unit = timeToReset === 1 ? t('rateLimit.locked.minute') : t('rateLimit.locked.minutes');
|
||||
throw new Error(t('rateLimit.locked.message', { minutes: timeToReset, unit }));
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
if (error) {
|
||||
// Only track FAILED attempts
|
||||
rateLimiter.trackFailedAttempt(clientIp);
|
||||
|
||||
// Get remaining attempts after tracking the failure
|
||||
const remaining = rateLimiter.getRemainingAttempts(clientIp);
|
||||
setRemainingAttempts(remaining);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Success - DON'T track, just redirect
|
||||
router.push(`/${locale}/dashboard`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('form.errors.default'));
|
||||
@@ -45,9 +74,35 @@ export default function LoginForm() {
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg text-sm">
|
||||
{error}
|
||||
{isLockedOut && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('rateLimit.locked.title')}</p>
|
||||
<p className="text-sm mt-1">
|
||||
{t('rateLimit.locked.message', {
|
||||
minutes: lockoutMinutes,
|
||||
unit: lockoutMinutes === 1 ? t('rateLimit.locked.minute') : t('rateLimit.locked.minutes')
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLockedOut && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{remainingAttempts !== null && remainingAttempts > 0 && (
|
||||
<p className="text-sm mt-2 ml-7">
|
||||
{t('rateLimit.warning.message', {
|
||||
attempts: remainingAttempts,
|
||||
unit: remainingAttempts === 1 ? t('rateLimit.warning.attempt') : t('rateLimit.warning.attempts')
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// src/i18n/locales/de/pages/login.ts
|
||||
export const login = {
|
||||
// SEO
|
||||
seo: {
|
||||
title: 'Login',
|
||||
description: 'Anmelden für Zugriff auf das Dashboard'
|
||||
},
|
||||
// Hauptüberschrift
|
||||
header: {
|
||||
title: 'Im Dashboard anmelden'
|
||||
},
|
||||
// Formular
|
||||
form: {
|
||||
// Felder
|
||||
email: {
|
||||
label: 'E-Mail-Adresse',
|
||||
placeholder: 'ihre.email@beispiel.de'
|
||||
},
|
||||
password: {
|
||||
label: 'Passwort',
|
||||
placeholder: 'Ihr Passwort'
|
||||
},
|
||||
// Buttons
|
||||
submit: {
|
||||
default: 'Anmelden',
|
||||
loading: 'Anmeldung läuft...'
|
||||
},
|
||||
// Fehlermeldungen
|
||||
errors: {
|
||||
default: 'Ein Fehler ist aufgetreten',
|
||||
invalidCredentials: 'Ungültige Anmeldedaten',
|
||||
emailRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein',
|
||||
passwordRequired: 'Bitte geben Sie Ihr Passwort ein',
|
||||
emailInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein'
|
||||
}
|
||||
},
|
||||
// Links und Hilfe
|
||||
help: {
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
needHelp: 'Benötigen Sie Hilfe?',
|
||||
contactSupport: 'Support kontaktieren'
|
||||
},
|
||||
// Statusmeldungen
|
||||
status: {
|
||||
authenticating: 'Authentifizierung...',
|
||||
success: 'Erfolgreich angemeldet',
|
||||
redirecting: 'Weiterleitung zum Dashboard...'
|
||||
},
|
||||
// Sicherheitshinweise
|
||||
security: {
|
||||
secureConnection: 'Sichere Verbindung',
|
||||
privacyNote: 'Ihre Daten werden verschlüsselt übertragen'
|
||||
},
|
||||
// Ratenbegrenzung
|
||||
rateLimit: {
|
||||
locked: {
|
||||
title: 'Konto vorübergehend gesperrt',
|
||||
message: 'Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es in {minutes} {unit} erneut.',
|
||||
minute: 'Minute',
|
||||
minutes: 'Minuten'
|
||||
},
|
||||
warning: {
|
||||
message: '{attempts} {unit} verbleibend vor vorübergehender Sperrung.',
|
||||
attempt: 'Versuch',
|
||||
attempts: 'Versuche'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// src/i18n/locales/en/pages/login.ts
|
||||
export const login = {
|
||||
// SEO
|
||||
seo: {
|
||||
title: 'Login',
|
||||
description: 'Sign in to access the dashboard'
|
||||
},
|
||||
// Main heading
|
||||
header: {
|
||||
title: 'Sign in to Dashboard'
|
||||
},
|
||||
// Form
|
||||
form: {
|
||||
// Fields
|
||||
email: {
|
||||
label: 'Email Address',
|
||||
placeholder: 'your.email@example.com'
|
||||
},
|
||||
password: {
|
||||
label: 'Password',
|
||||
placeholder: 'Your password'
|
||||
},
|
||||
// Buttons
|
||||
submit: {
|
||||
default: 'Sign In',
|
||||
loading: 'Signing in...'
|
||||
},
|
||||
// Error messages
|
||||
errors: {
|
||||
default: 'An error has occurred',
|
||||
invalidCredentials: 'Invalid credentials',
|
||||
emailRequired: 'Please enter your email address',
|
||||
passwordRequired: 'Please enter your password',
|
||||
emailInvalid: 'Please enter a valid email address'
|
||||
}
|
||||
},
|
||||
// Links and help
|
||||
help: {
|
||||
forgotPassword: 'Forgot Password?',
|
||||
needHelp: 'Need Help?',
|
||||
contactSupport: 'Contact Support'
|
||||
},
|
||||
// Status messages
|
||||
status: {
|
||||
authenticating: 'Authenticating...',
|
||||
success: 'Successfully signed in',
|
||||
redirecting: 'Redirecting to dashboard...'
|
||||
},
|
||||
// Security notices
|
||||
security: {
|
||||
secureConnection: 'Secure Connection',
|
||||
privacyNote: 'Your data is transmitted encrypted'
|
||||
},
|
||||
// Rate limiting
|
||||
rateLimit: {
|
||||
locked: {
|
||||
title: 'Account temporarily locked',
|
||||
message: 'Too many failed login attempts. Please try again in {minutes} {unit}.',
|
||||
minute: 'minute',
|
||||
minutes: 'minutes'
|
||||
},
|
||||
warning: {
|
||||
message: '{attempts} {unit} remaining before temporary lockout.',
|
||||
attempt: 'attempt',
|
||||
attempts: 'attempts'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// src/i18n/locales/sr/pages/login.ts
|
||||
export const login = {
|
||||
// SEO
|
||||
seo: {
|
||||
title: 'Prijava',
|
||||
description: 'Prijavite se za pristup dashboard-u'
|
||||
},
|
||||
// Glavni naslov
|
||||
header: {
|
||||
title: 'Prijava na dashboard'
|
||||
},
|
||||
// Formular
|
||||
form: {
|
||||
// Polja
|
||||
email: {
|
||||
label: 'E-mail adresa',
|
||||
placeholder: 'vas.email@primer.com'
|
||||
},
|
||||
password: {
|
||||
label: 'Lozinka',
|
||||
placeholder: 'Vaša lozinka'
|
||||
},
|
||||
// Dugmad
|
||||
submit: {
|
||||
default: 'Prijavi se',
|
||||
loading: 'Prijava u toku...'
|
||||
},
|
||||
// Poruke o greškama
|
||||
errors: {
|
||||
default: 'Došlo je do greške',
|
||||
invalidCredentials: 'Neispravni podaci za prijavu',
|
||||
emailRequired: 'Molimo unesite vašu e-mail adresu',
|
||||
passwordRequired: 'Molimo unesite vašu lozinku',
|
||||
emailInvalid: 'Molimo unesite važeću e-mail adresu'
|
||||
}
|
||||
},
|
||||
// Linkovi i pomoć
|
||||
help: {
|
||||
forgotPassword: 'Zaboravili ste lozinku?',
|
||||
needHelp: 'Potrebna vam je pomoć?',
|
||||
contactSupport: 'Kontaktirajte podršku'
|
||||
},
|
||||
// Status poruke
|
||||
status: {
|
||||
authenticating: 'Autentifikacija...',
|
||||
success: 'Uspešna prijava',
|
||||
redirecting: 'Preusmeravanje na dashboard...'
|
||||
},
|
||||
// Bezbednosna obaveštenja
|
||||
security: {
|
||||
secureConnection: 'Bezbedna veza',
|
||||
privacyNote: 'Vaši podaci se prenose u šifrovanom obliku'
|
||||
},
|
||||
// Ograničenje broja pokušaja
|
||||
rateLimit: {
|
||||
locked: {
|
||||
title: 'Nalog privremeno zaključan',
|
||||
message: 'Previše neuspešnih pokušaja prijave. Molimo pokušajte ponovo za {minutes} {unit}.',
|
||||
minute: 'minut',
|
||||
minutes: 'minuta'
|
||||
},
|
||||
warning: {
|
||||
message: '{attempts} {unit} preostalo pre privremenog zaključavanja.',
|
||||
attempt: 'pokušaj',
|
||||
attempts: 'pokušaja'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
<<<<<<< HEAD
|
||||
export function createClient(request: NextRequest, response: NextResponse) {
|
||||
return createServerClient(
|
||||
=======
|
||||
export async function createClient(request: NextRequest) {
|
||||
let response = NextResponse.next({
|
||||
request: {
|
||||
@@ -9,6 +13,7 @@ export async function createClient(request: NextRequest) {
|
||||
});
|
||||
|
||||
const supabase = createServerClient(
|
||||
>>>>>>> origin/master
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
@@ -17,6 +22,15 @@ export async function createClient(request: NextRequest) {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
<<<<<<< HEAD
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
} catch {
|
||||
// Handle middleware context
|
||||
}
|
||||
=======
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
request.cookies.set(name, value)
|
||||
);
|
||||
@@ -28,10 +42,14 @@ export async function createClient(request: NextRequest) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options)
|
||||
);
|
||||
>>>>>>> origin/master
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
return { supabase, response };
|
||||
>>>>>>> origin/master
|
||||
}
|
||||
|
||||
+26
-10
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
<<<<<<< HEAD
|
||||
* Client-side rate limiter for UX protection.
|
||||
* This provides immediate user feedback but does NOT prevent
|
||||
* server-side brute force attacks. Relies on Supabase's
|
||||
* built-in server-side rate limiting for actual security.
|
||||
*/
|
||||
|
||||
=======
|
||||
* Rate limit entry tracking request count and timestamp
|
||||
*/
|
||||
>>>>>>> origin/master
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const WINDOW_SIZE_MS = 3600000; // 1 hour
|
||||
const MAX_REQUESTS = 5; // Maximum requests per hour
|
||||
const WINDOW_SIZE_MS = 900000; // 15 minutes
|
||||
const MAX_REQUESTS = 5; // Maximum requests per window
|
||||
|
||||
/**
|
||||
* Rate Limiter for API request throttling
|
||||
@@ -28,21 +37,28 @@ class RateLimiter {
|
||||
const entry = this.cache.get(ip);
|
||||
|
||||
if (!entry) {
|
||||
this.cache.set(ip, { count: 1, timestamp: now });
|
||||
return false;
|
||||
return false; // No entry = not limited
|
||||
}
|
||||
|
||||
if (now - entry.timestamp > WINDOW_SIZE_MS) {
|
||||
this.cache.set(ip, { count: 1, timestamp: now });
|
||||
// Window expired - clear entry and allow
|
||||
this.cache.delete(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_REQUESTS) {
|
||||
return true;
|
||||
}
|
||||
// Check if at or over limit (don't increment here)
|
||||
return entry.count >= MAX_REQUESTS;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return false;
|
||||
trackFailedAttempt(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = this.cache.get(ip);
|
||||
|
||||
if (!entry || now - entry.timestamp > WINDOW_SIZE_MS) {
|
||||
this.cache.set(ip, { count: 1, timestamp: now });
|
||||
} else {
|
||||
entry.count++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user