feat: add login page and auth guard
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
|
||||||
import { Providers } from '@/components/Providers';
|
import { Providers } from '@/components/Providers';
|
||||||
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
@@ -20,12 +20,7 @@ export default function RootLayout({
|
|||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<Providers>
|
<Providers>
|
||||||
<div className="flex h-screen bg-gray-950">
|
<MainLayout>{children}</MainLayout>
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 overflow-auto">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
91
dashboard/src/app/login/page.tsx
Normal file
91
dashboard/src/app/login/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
router.push('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Connection error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||||
|
<div className="bg-gray-900 p-8 rounded-lg border border-gray-800 w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white">🐍 OPHION</h1>
|
||||||
|
<p className="text-gray-400 mt-2">Observability Platform</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500 text-red-400 px-4 py-2 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="admin@ophion.local"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
dashboard/src/components/AuthGuard.tsx
Normal file
34
dashboard/src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { isAuthenticated } from '@/lib/auth';
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname === '/login') {
|
||||||
|
setChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
router.push('/login');
|
||||||
|
} else {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
|
if (checking && pathname !== '/login') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||||
|
<div className="text-white">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
23
dashboard/src/components/layout/MainLayout.tsx
Normal file
23
dashboard/src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { AuthGuard } from '../AuthGuard';
|
||||||
|
|
||||||
|
export function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isLoginPage = pathname === '/login';
|
||||||
|
|
||||||
|
if (isLoginPage) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<div className="flex h-screen bg-gray-950">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
dashboard/src/lib/auth.ts
Normal file
35
dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = {
|
||||||
|
...options.headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
removeToken();
|
||||||
|
window.location.href = '/login';
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user