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:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
560
client/src/components/ui/animated-characters-login-page.tsx
Normal file
560
client/src/components/ui/animated-characters-login-page.tsx
Normal 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 };
|
||||||
56
client/src/components/ui/button.tsx
Normal file
56
client/src/components/ui/button.tsx
Normal 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 }
|
||||||
30
client/src/components/ui/checkbox.tsx
Normal file
30
client/src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
25
client/src/components/ui/input.tsx
Normal file
25
client/src/components/ui/input.tsx
Normal 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 }
|
||||||
26
client/src/components/ui/label.tsx
Normal file
26
client/src/components/ui/label.tsx
Normal 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 }
|
||||||
@@ -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
141
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
19
server/src/middleware/auth.ts
Normal file
19
server/src/middleware/auth.ts
Normal 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
42
server/src/routes/auth.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user