Add JWT authentication with animated login page

- Backend: JWT auth with /api/auth/login and /api/auth/verify endpoints
- Middleware: requireAuth protects all /api routes except /api/auth
- Frontend: Animated characters login page with eye-tracking effects
- Auth state persisted in localStorage with token verification on mount
- Sidebar logout button, 401 auto-logout, 30-day token expiry
- shadcn button, input, checkbox, label components added

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 16:35:30 +05:30
parent eb6c097664
commit ed13991515
15 changed files with 1015 additions and 16 deletions

View File

@@ -15,6 +15,9 @@
"vite": "^8.0.1" "vite": "^8.0.1"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.95.0", "@tanstack/react-query": "^5.95.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -25,7 +28,7 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
} }
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react" import { useEffect, useState, useCallback } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Cpu, MemoryStick, HardDrive, Container } from "lucide-react" import { Cpu, MemoryStick, HardDrive, Container } from "lucide-react"
import { Sidebar } from "@/components/layout/Sidebar" import { Sidebar } from "@/components/layout/Sidebar"
@@ -14,10 +14,25 @@ import { ContainersPage } from "@/components/pages/ContainersPage"
import { MemoryPage } from "@/components/pages/MemoryPage" import { MemoryPage } from "@/components/pages/MemoryPage"
import { StoragePage } from "@/components/pages/StoragePage" import { StoragePage } from "@/components/pages/StoragePage"
import { NetworkPage } from "@/components/pages/NetworkPage" import { NetworkPage } from "@/components/pages/NetworkPage"
import { LoginPage } from "@/components/ui/animated-characters-login-page"
import { useServerHealth } from "@/hooks/useServerHealth" import { useServerHealth } from "@/hooks/useServerHealth"
import { useCpuHistory } from "@/hooks/useCpuHistory" import { useCpuHistory } from "@/hooks/useCpuHistory"
import { formatBytes } from "@/lib/utils" import { formatBytes } from "@/lib/utils"
const TOKEN_KEY = "eventify-auth-token"
function getStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
function setStoredToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
function clearStoredToken() {
localStorage.removeItem(TOKEN_KEY)
}
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -35,7 +50,7 @@ const PAGE_TITLES: Record<string, string> = {
network: "Network", network: "Network",
} }
function DashboardContent({ activePage }: { activePage: string }) { function DashboardContent({ activePage, onLogout }: { activePage: string; onLogout: () => void }) {
const { const {
data, data,
isLoading, isLoading,
@@ -55,6 +70,13 @@ function DashboardContent({ activePage }: { activePage: string }) {
} }
}, [data, addDataPoint]) }, [data, addDataPoint])
// If we get a 401, the token is invalid — log out
useEffect(() => {
if (isError && error instanceof Error && error.message.includes("401")) {
onLogout()
}
}, [isError, error, onLogout])
const lastUpdated = dataUpdatedAt ? new Date(dataUpdatedAt).toISOString() : null const lastUpdated = dataUpdatedAt ? new Date(dataUpdatedAt).toISOString() : null
if (isError) { if (isError) {
@@ -76,7 +98,7 @@ function DashboardContent({ activePage }: { activePage: string }) {
</p> </p>
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700" className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 cursor-pointer"
> >
Retry Retry
</button> </button>
@@ -229,13 +251,62 @@ function DashboardContent({ activePage }: { activePage: string }) {
} }
export default function App() { export default function App() {
const [token, setToken] = useState<string | null>(getStoredToken)
const [activePage, setActivePage] = useState("dashboard") const [activePage, setActivePage] = useState("dashboard")
const [verifying, setVerifying] = useState(true)
// Verify stored token on mount
useEffect(() => {
const stored = getStoredToken()
if (!stored) {
setVerifying(false)
return
}
fetch("/api/auth/verify", {
headers: { Authorization: `Bearer ${stored}` },
})
.then((res) => {
if (!res.ok) {
clearStoredToken()
setToken(null)
}
})
.catch(() => {
clearStoredToken()
setToken(null)
})
.finally(() => setVerifying(false))
}, [])
const handleLogin = useCallback((newToken: string) => {
setStoredToken(newToken)
setToken(newToken)
}, [])
const handleLogout = useCallback(() => {
clearStoredToken()
setToken(null)
queryClient.clear()
}, [])
if (verifying) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!token) {
return <LoginPage onLogin={handleLogin} />
}
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
<Sidebar activePage={activePage} onNavigate={setActivePage} /> <Sidebar activePage={activePage} onNavigate={setActivePage} onLogout={handleLogout} />
<DashboardContent activePage={activePage} /> <DashboardContent activePage={activePage} onLogout={handleLogout} />
</div> </div>
</QueryClientProvider> </QueryClientProvider>
) )

View File

@@ -7,6 +7,7 @@ import {
Settings, Settings,
HelpCircle, HelpCircle,
Server, Server,
LogOut,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
@@ -33,6 +34,7 @@ const bottomNav: NavItem[] = [
interface SidebarProps { interface SidebarProps {
activePage: string activePage: string
onNavigate: (page: string) => void onNavigate: (page: string) => void
onLogout?: () => void
} }
function NavButton({ function NavButton({
@@ -61,7 +63,7 @@ function NavButton({
) )
} }
export function Sidebar({ activePage, onNavigate }: SidebarProps) { export function Sidebar({ activePage, onNavigate, onLogout }: SidebarProps) {
return ( return (
<aside className="flex h-screen w-60 shrink-0 flex-col border-r bg-sidebar-background"> <aside className="flex h-screen w-60 shrink-0 flex-col border-r bg-sidebar-background">
{/* Brand */} {/* Brand */}
@@ -114,6 +116,17 @@ export function Sidebar({ activePage, onNavigate }: SidebarProps) {
All Systems Operational All Systems Operational
</p> </p>
</div> </div>
{/* Logout */}
{onLogout && (
<button
onClick={onLogout}
className="mb-4 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-600 cursor-pointer"
>
<LogOut className="size-5 shrink-0" />
<span>Sign Out</span>
</button>
)}
</nav> </nav>
</aside> </aside>
) )

View File

@@ -0,0 +1,560 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Eye, EyeOff, Sparkles } from "lucide-react";
interface PupilProps {
size?: number;
maxDistance?: number;
pupilColor?: string;
forceLookX?: number;
forceLookY?: number;
}
const Pupil = ({
size = 12,
maxDistance = 5,
pupilColor = "black",
forceLookX,
forceLookY
}: PupilProps) => {
const [mouseX, setMouseX] = useState<number>(0);
const [mouseY, setMouseY] = useState<number>(0);
const pupilRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMouseX(e.clientX);
setMouseY(e.clientY);
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
const calculatePupilPosition = () => {
if (!pupilRef.current) return { x: 0, y: 0 };
if (forceLookX !== undefined && forceLookY !== undefined) {
return { x: forceLookX, y: forceLookY };
}
const pupil = pupilRef.current.getBoundingClientRect();
const pupilCenterX = pupil.left + pupil.width / 2;
const pupilCenterY = pupil.top + pupil.height / 2;
const deltaX = mouseX - pupilCenterX;
const deltaY = mouseY - pupilCenterY;
const distance = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2), maxDistance);
const angle = Math.atan2(deltaY, deltaX);
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
return { x, y };
};
const pupilPosition = calculatePupilPosition();
return (
<div
ref={pupilRef}
className="rounded-full"
style={{
width: `${size}px`,
height: `${size}px`,
backgroundColor: pupilColor,
transform: `translate(${pupilPosition.x}px, ${pupilPosition.y}px)`,
transition: 'transform 0.1s ease-out',
}}
/>
);
};
interface EyeBallProps {
size?: number;
pupilSize?: number;
maxDistance?: number;
eyeColor?: string;
pupilColor?: string;
isBlinking?: boolean;
forceLookX?: number;
forceLookY?: number;
}
const EyeBall = ({
size = 48,
pupilSize = 16,
maxDistance = 10,
eyeColor = "white",
pupilColor = "black",
isBlinking = false,
forceLookX,
forceLookY
}: EyeBallProps) => {
const [mouseX, setMouseX] = useState<number>(0);
const [mouseY, setMouseY] = useState<number>(0);
const eyeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMouseX(e.clientX);
setMouseY(e.clientY);
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
const calculatePupilPosition = () => {
if (!eyeRef.current) return { x: 0, y: 0 };
if (forceLookX !== undefined && forceLookY !== undefined) {
return { x: forceLookX, y: forceLookY };
}
const eye = eyeRef.current.getBoundingClientRect();
const eyeCenterX = eye.left + eye.width / 2;
const eyeCenterY = eye.top + eye.height / 2;
const deltaX = mouseX - eyeCenterX;
const deltaY = mouseY - eyeCenterY;
const distance = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2), maxDistance);
const angle = Math.atan2(deltaY, deltaX);
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
return { x, y };
};
const pupilPosition = calculatePupilPosition();
return (
<div
ref={eyeRef}
className="rounded-full flex items-center justify-center transition-all duration-150"
style={{
width: `${size}px`,
height: isBlinking ? '2px' : `${size}px`,
backgroundColor: eyeColor,
overflow: 'hidden',
}}
>
{!isBlinking && (
<div
className="rounded-full"
style={{
width: `${pupilSize}px`,
height: `${pupilSize}px`,
backgroundColor: pupilColor,
transform: `translate(${pupilPosition.x}px, ${pupilPosition.y}px)`,
transition: 'transform 0.1s ease-out',
}}
/>
)}
</div>
);
};
interface LoginPageProps {
onLogin: (token: string) => void;
}
function LoginPage({ onLogin }: LoginPageProps) {
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [mouseX, setMouseX] = useState<number>(0);
const [mouseY, setMouseY] = useState<number>(0);
const [isPurpleBlinking, setIsPurpleBlinking] = useState(false);
const [isBlackBlinking, setIsBlackBlinking] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const [isLookingAtEachOther, setIsLookingAtEachOther] = useState(false);
const [isPurplePeeking, setIsPurplePeeking] = useState(false);
const purpleRef = useRef<HTMLDivElement>(null);
const blackRef = useRef<HTMLDivElement>(null);
const yellowRef = useRef<HTMLDivElement>(null);
const orangeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMouseX(e.clientX);
setMouseY(e.clientY);
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
useEffect(() => {
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000;
const scheduleBlink = () => {
const blinkTimeout = setTimeout(() => {
setIsPurpleBlinking(true);
setTimeout(() => {
setIsPurpleBlinking(false);
scheduleBlink();
}, 150);
}, getRandomBlinkInterval());
return blinkTimeout;
};
const timeout = scheduleBlink();
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000;
const scheduleBlink = () => {
const blinkTimeout = setTimeout(() => {
setIsBlackBlinking(true);
setTimeout(() => {
setIsBlackBlinking(false);
scheduleBlink();
}, 150);
}, getRandomBlinkInterval());
return blinkTimeout;
};
const timeout = scheduleBlink();
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
if (isTyping) {
setIsLookingAtEachOther(true);
const timer = setTimeout(() => {
setIsLookingAtEachOther(false);
}, 800);
return () => clearTimeout(timer);
} else {
setIsLookingAtEachOther(false);
}
}, [isTyping]);
useEffect(() => {
if (password.length > 0 && showPassword) {
const schedulePeek = () => {
const peekInterval = setTimeout(() => {
setIsPurplePeeking(true);
setTimeout(() => {
setIsPurplePeeking(false);
}, 800);
}, Math.random() * 3000 + 2000);
return peekInterval;
};
const firstPeek = schedulePeek();
return () => clearTimeout(firstPeek);
} else {
setIsPurplePeeking(false);
}
}, [password, showPassword, isPurplePeeking]);
const calculatePosition = (ref: React.RefObject<HTMLDivElement | null>) => {
if (!ref.current) return { faceX: 0, faceY: 0, bodySkew: 0 };
const rect = ref.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 3;
const deltaX = mouseX - centerX;
const deltaY = mouseY - centerY;
const faceX = Math.max(-15, Math.min(15, deltaX / 20));
const faceY = Math.max(-10, Math.min(10, deltaY / 30));
const bodySkew = Math.max(-6, Math.min(6, -deltaX / 120));
return { faceX, faceY, bodySkew };
};
const purplePos = calculatePosition(purpleRef);
const blackPos = calculatePosition(blackRef);
const yellowPos = calculatePosition(yellowRef);
const orangePos = calculatePosition(orangeRef);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
const res = await fetch("/api/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 || "Invalid email or password.");
} else {
onLogin(data.token);
}
} catch {
setError("Network error. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen grid lg:grid-cols-2">
{/* Left Content Section */}
<div className="relative hidden lg:flex flex-col justify-between bg-gradient-to-br from-primary/90 via-primary to-primary/80 p-12 text-primary-foreground">
<div className="relative z-20">
<div className="flex items-center gap-2 text-lg font-semibold">
<div className="size-8 rounded-lg bg-primary-foreground/10 backdrop-blur-sm flex items-center justify-center">
<Sparkles className="size-4" />
</div>
<span>Eventify</span>
</div>
</div>
<div className="relative z-20 flex items-end justify-center h-[500px]">
{/* Cartoon Characters */}
<div className="relative" style={{ width: '550px', height: '400px' }}>
{/* Purple tall rectangle character */}
<div
ref={purpleRef}
className="absolute bottom-0 transition-all duration-700 ease-in-out"
style={{
left: '70px',
width: '180px',
height: (isTyping || (password.length > 0 && !showPassword)) ? '440px' : '400px',
backgroundColor: '#6C3FF5',
borderRadius: '10px 10px 0 0',
zIndex: 1,
transform: (password.length > 0 && showPassword)
? `skewX(0deg)`
: (isTyping || (password.length > 0 && !showPassword))
? `skewX(${(purplePos.bodySkew || 0) - 12}deg) translateX(40px)`
: `skewX(${purplePos.bodySkew || 0}deg)`,
transformOrigin: 'bottom center',
}}
>
<div
className="absolute flex gap-8 transition-all duration-700 ease-in-out"
style={{
left: (password.length > 0 && showPassword) ? `${20}px` : isLookingAtEachOther ? `${55}px` : `${45 + purplePos.faceX}px`,
top: (password.length > 0 && showPassword) ? `${35}px` : isLookingAtEachOther ? `${65}px` : `${40 + purplePos.faceY}px`,
}}
>
<EyeBall
size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#2D2D2D"
isBlinking={isPurpleBlinking}
forceLookX={(password.length > 0 && showPassword) ? (isPurplePeeking ? 4 : -4) : isLookingAtEachOther ? 3 : undefined}
forceLookY={(password.length > 0 && showPassword) ? (isPurplePeeking ? 5 : -4) : isLookingAtEachOther ? 4 : undefined}
/>
<EyeBall
size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#2D2D2D"
isBlinking={isPurpleBlinking}
forceLookX={(password.length > 0 && showPassword) ? (isPurplePeeking ? 4 : -4) : isLookingAtEachOther ? 3 : undefined}
forceLookY={(password.length > 0 && showPassword) ? (isPurplePeeking ? 5 : -4) : isLookingAtEachOther ? 4 : undefined}
/>
</div>
</div>
{/* Black tall rectangle character */}
<div
ref={blackRef}
className="absolute bottom-0 transition-all duration-700 ease-in-out"
style={{
left: '240px',
width: '120px',
height: '310px',
backgroundColor: '#2D2D2D',
borderRadius: '8px 8px 0 0',
zIndex: 2,
transform: (password.length > 0 && showPassword)
? `skewX(0deg)`
: isLookingAtEachOther
? `skewX(${(blackPos.bodySkew || 0) * 1.5 + 10}deg) translateX(20px)`
: (isTyping || (password.length > 0 && !showPassword))
? `skewX(${(blackPos.bodySkew || 0) * 1.5}deg)`
: `skewX(${blackPos.bodySkew || 0}deg)`,
transformOrigin: 'bottom center',
}}
>
<div
className="absolute flex gap-6 transition-all duration-700 ease-in-out"
style={{
left: (password.length > 0 && showPassword) ? `${10}px` : isLookingAtEachOther ? `${32}px` : `${26 + blackPos.faceX}px`,
top: (password.length > 0 && showPassword) ? `${28}px` : isLookingAtEachOther ? `${12}px` : `${32 + blackPos.faceY}px`,
}}
>
<EyeBall
size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#2D2D2D"
isBlinking={isBlackBlinking}
forceLookX={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? 0 : undefined}
forceLookY={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? -4 : undefined}
/>
<EyeBall
size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#2D2D2D"
isBlinking={isBlackBlinking}
forceLookX={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? 0 : undefined}
forceLookY={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? -4 : undefined}
/>
</div>
</div>
{/* Orange semi-circle character */}
<div
ref={orangeRef}
className="absolute bottom-0 transition-all duration-700 ease-in-out"
style={{
left: '0px',
width: '240px',
height: '200px',
zIndex: 3,
backgroundColor: '#FF9B6B',
borderRadius: '120px 120px 0 0',
transform: (password.length > 0 && showPassword) ? `skewX(0deg)` : `skewX(${orangePos.bodySkew || 0}deg)`,
transformOrigin: 'bottom center',
}}
>
<div
className="absolute flex gap-8 transition-all duration-200 ease-out"
style={{
left: (password.length > 0 && showPassword) ? `${50}px` : `${82 + (orangePos.faceX || 0)}px`,
top: (password.length > 0 && showPassword) ? `${85}px` : `${90 + (orangePos.faceY || 0)}px`,
}}
>
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
</div>
</div>
{/* Yellow tall rectangle character */}
<div
ref={yellowRef}
className="absolute bottom-0 transition-all duration-700 ease-in-out"
style={{
left: '310px',
width: '140px',
height: '230px',
backgroundColor: '#E8D754',
borderRadius: '70px 70px 0 0',
zIndex: 4,
transform: (password.length > 0 && showPassword) ? `skewX(0deg)` : `skewX(${yellowPos.bodySkew || 0}deg)`,
transformOrigin: 'bottom center',
}}
>
<div
className="absolute flex gap-6 transition-all duration-200 ease-out"
style={{
left: (password.length > 0 && showPassword) ? `${20}px` : `${52 + (yellowPos.faceX || 0)}px`,
top: (password.length > 0 && showPassword) ? `${35}px` : `${40 + (yellowPos.faceY || 0)}px`,
}}
>
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
</div>
<div
className="absolute w-20 h-[4px] bg-[#2D2D2D] rounded-full transition-all duration-200 ease-out"
style={{
left: (password.length > 0 && showPassword) ? `${10}px` : `${40 + (yellowPos.faceX || 0)}px`,
top: (password.length > 0 && showPassword) ? `${88}px` : `${88 + (yellowPos.faceY || 0)}px`,
}}
/>
</div>
</div>
</div>
<div className="relative z-20 flex items-center gap-8 text-sm text-primary-foreground/60">
<a href="#" className="hover:text-primary-foreground transition-colors">Privacy Policy</a>
<a href="#" className="hover:text-primary-foreground transition-colors">Terms of Service</a>
<a href="#" className="hover:text-primary-foreground transition-colors">Contact</a>
</div>
<div className="absolute inset-0 bg-grid-white/[0.05] bg-[size:20px_20px]" />
<div className="absolute top-1/4 right-1/4 size-64 bg-primary-foreground/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 left-1/4 size-96 bg-primary-foreground/5 rounded-full blur-3xl" />
</div>
{/* Right Login Section */}
<div className="flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-[420px]">
<div className="lg:hidden flex items-center justify-center gap-2 text-lg font-semibold mb-12">
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<span>Eventify</span>
</div>
<div className="text-center mb-10">
<h1 className="text-3xl font-bold tracking-tight mb-2">Server Monitor</h1>
<p className="text-muted-foreground text-sm">Sign in to access the dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@eventifyplus.com"
value={email}
autoComplete="off"
onChange={(e) => setEmail(e.target.value)}
onFocus={() => setIsTyping(true)}
onBlur={() => setIsTyping(false)}
required
className="h-12 bg-background border-border/60 focus:border-primary"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-12 pr-10 bg-background border-border/60 focus:border-primary"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox id="remember" />
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
Remember for 30 days
</Label>
</div>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg">
{error}
</div>
)}
<Button
type="submit"
className="w-full h-12 text-base font-medium cursor-pointer"
size="lg"
disabled={isLoading}
>
{isLoading ? "Signing in..." : "Log in"}
</Button>
</form>
<div className="text-center text-sm text-muted-foreground mt-8">
Eventify Server Health Monitor
</div>
</div>
</div>
</div>
);
}
export { LoginPage };

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -23,7 +23,10 @@ export function useServerHealth() {
const query = useQuery<ServerOverview>({ const query = useQuery<ServerOverview>({
queryKey: ["server-overview"], queryKey: ["server-overview"],
queryFn: async () => { queryFn: async () => {
const res = await fetch("/api/overview") const token = localStorage.getItem("eventify-auth-token")
const res = await fetch("/api/overview", {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Server responded with ${res.status}`) if (!res.ok) throw new Error(`Server responded with ${res.status}`)
return res.json() return res.json()
}, },

141
package-lock.json generated
View File

@@ -16,6 +16,9 @@
"client": { "client": {
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.95.0", "@tanstack/react-query": "^5.95.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -2984,6 +2987,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
@@ -3198,6 +3219,12 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buildcheck": { "node_modules/buildcheck": {
"version": "0.0.7", "version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
@@ -3502,6 +3529,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3947,6 +3983,55 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -4196,6 +4281,48 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.577.0", "version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
@@ -4735,6 +4862,18 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -5316,11 +5455,13 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"express": "^4.21.0", "express": "^4.21.0",
"jsonwebtoken": "^9.0.3",
"ssh2": "^1.16.0" "ssh2": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/ssh2": "^1.15.0", "@types/ssh2": "^1.15.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.8.0" "typescript": "^5.8.0"

View File

@@ -8,16 +8,18 @@
"start": "tsx src/index.ts" "start": "tsx src/index.ts"
}, },
"dependencies": { "dependencies": {
"express": "^4.21.0",
"ssh2": "^1.16.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.0" "dotenv": "^16.4.0",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.3",
"ssh2": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0",
"@types/ssh2": "^1.15.0",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"typescript": "^5.8.0", "@types/express": "^5.0.0",
"tsx": "^4.19.0" "@types/jsonwebtoken": "^9.0.10",
"@types/ssh2": "^1.15.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
} }
} }

View File

@@ -18,4 +18,9 @@ export const config = {
), ),
}, },
port: parseInt(process.env.PORT ?? '3002', 10), port: parseInt(process.env.PORT ?? '3002', 10),
auth: {
email: process.env.AUTH_EMAIL ?? 'admin@eventifyplus.com',
password: process.env.AUTH_PASSWORD ?? 'eventify2026',
jwtSecret: process.env.JWT_SECRET ?? 'eventify-server-monitor-secret-key-change-in-production',
},
} as const; } as const;

View File

@@ -5,6 +5,8 @@ import { dirname, resolve } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { config } from './config.js'; import { config } from './config.js';
import { healthRouter } from './routes/health.js'; import { healthRouter } from './routes/health.js';
import { authRouter } from './routes/auth.js';
import { requireAuth } from './middleware/auth.js';
import { sshManager } from './ssh/client.js'; import { sshManager } from './ssh/client.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -22,7 +24,8 @@ app.use(
app.use(express.json()); app.use(express.json());
// ── API Routes ── // ── API Routes ──
app.use('/api', healthRouter); app.use('/api/auth', authRouter);
app.use('/api', requireAuth, healthRouter);
// ── Static file serving for production SPA ── // ── Static file serving for production SPA ──
const clientDistPath = resolve(__dirname, '../../client/dist'); const clientDistPath = resolve(__dirname, '../../client/dist');

View File

@@ -0,0 +1,19 @@
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Authentication required' });
return;
}
try {
jwt.verify(authHeader.slice(7), config.auth.jwtSecret);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}

42
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export const authRouter = Router();
// POST /api/auth/login
authRouter.post('/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Email and password are required' });
return;
}
if (email === config.auth.email && password === config.auth.password) {
const token = jwt.sign(
{ email, role: 'admin' },
config.auth.jwtSecret,
{ expiresIn: '30d' }
);
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid email or password' });
}
});
// GET /api/auth/verify - check if token is still valid
authRouter.get('/verify', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ valid: false });
return;
}
try {
const payload = jwt.verify(authHeader.slice(7), config.auth.jwtSecret);
res.json({ valid: true, user: payload });
} catch {
res.status(401).json({ valid: false });
}
});