From ed1399151521c71a6a84f08a7c2fc1e9c890c4ac Mon Sep 17 00:00:00 2001
From: Sicherhaven
Date: Mon, 23 Mar 2026 16:35:30 +0530
Subject: [PATCH] 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
---
client/package.json | 5 +-
client/src/App.tsx | 81 ++-
client/src/components/layout/Sidebar.tsx | 15 +-
.../ui/animated-characters-login-page.tsx | 560 ++++++++++++++++++
client/src/components/ui/button.tsx | 56 ++
client/src/components/ui/checkbox.tsx | 30 +
client/src/components/ui/input.tsx | 25 +
client/src/components/ui/label.tsx | 26 +
client/src/hooks/useServerHealth.ts | 5 +-
package-lock.json | 141 +++++
server/package.json | 16 +-
server/src/config.ts | 5 +
server/src/index.ts | 5 +-
server/src/middleware/auth.ts | 19 +
server/src/routes/auth.ts | 42 ++
15 files changed, 1015 insertions(+), 16 deletions(-)
create mode 100644 client/src/components/ui/animated-characters-login-page.tsx
create mode 100644 client/src/components/ui/button.tsx
create mode 100644 client/src/components/ui/checkbox.tsx
create mode 100644 client/src/components/ui/input.tsx
create mode 100644 client/src/components/ui/label.tsx
create mode 100644 server/src/middleware/auth.ts
create mode 100644 server/src/routes/auth.ts
diff --git a/client/package.json b/client/package.json
index 8969650..5347a86 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,6 +15,9 @@
"vite": "^8.0.1"
},
"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",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -25,7 +28,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
-"tailwind-merge": "^3.5.0",
+ "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"
}
}
diff --git a/client/src/App.tsx b/client/src/App.tsx
index a84e2f8..b9d8122 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react"
+import { useEffect, useState, useCallback } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Cpu, MemoryStick, HardDrive, Container } from "lucide-react"
import { Sidebar } from "@/components/layout/Sidebar"
@@ -14,10 +14,25 @@ import { ContainersPage } from "@/components/pages/ContainersPage"
import { MemoryPage } from "@/components/pages/MemoryPage"
import { StoragePage } from "@/components/pages/StoragePage"
import { NetworkPage } from "@/components/pages/NetworkPage"
+import { LoginPage } from "@/components/ui/animated-characters-login-page"
import { useServerHealth } from "@/hooks/useServerHealth"
import { useCpuHistory } from "@/hooks/useCpuHistory"
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({
defaultOptions: {
queries: {
@@ -35,7 +50,7 @@ const PAGE_TITLES: Record = {
network: "Network",
}
-function DashboardContent({ activePage }: { activePage: string }) {
+function DashboardContent({ activePage, onLogout }: { activePage: string; onLogout: () => void }) {
const {
data,
isLoading,
@@ -55,6 +70,13 @@ function DashboardContent({ activePage }: { activePage: string }) {
}
}, [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
if (isError) {
@@ -76,7 +98,7 @@ function DashboardContent({ activePage }: { activePage: string }) {
@@ -229,13 +251,62 @@ function DashboardContent({ activePage }: { activePage: string }) {
}
export default function App() {
+ const [token, setToken] = useState(getStoredToken)
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 (
+
+ )
+ }
+
+ if (!token) {
+ return
+ }
return (
-
-
+
+
)
diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx
index a07689e..27f7f30 100644
--- a/client/src/components/layout/Sidebar.tsx
+++ b/client/src/components/layout/Sidebar.tsx
@@ -7,6 +7,7 @@ import {
Settings,
HelpCircle,
Server,
+ LogOut,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
@@ -33,6 +34,7 @@ const bottomNav: NavItem[] = [
interface SidebarProps {
activePage: string
onNavigate: (page: string) => void
+ onLogout?: () => void
}
function NavButton({
@@ -61,7 +63,7 @@ function NavButton({
)
}
-export function Sidebar({ activePage, onNavigate }: SidebarProps) {
+export function Sidebar({ activePage, onNavigate, onLogout }: SidebarProps) {
return (
)
diff --git a/client/src/components/ui/animated-characters-login-page.tsx b/client/src/components/ui/animated-characters-login-page.tsx
new file mode 100644
index 0000000..264f5e1
--- /dev/null
+++ b/client/src/components/ui/animated-characters-login-page.tsx
@@ -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(0);
+ const [mouseY, setMouseY] = useState(0);
+ const pupilRef = useRef(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 (
+
+ );
+};
+
+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(0);
+ const [mouseY, setMouseY] = useState(0);
+ const eyeRef = useRef(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 (
+
+ {!isBlinking && (
+
+ )}
+
+ );
+};
+
+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(0);
+ const [mouseY, setMouseY] = useState(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(null);
+ const blackRef = useRef(null);
+ const yellowRef = useRef(null);
+ const orangeRef = useRef(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) => {
+ 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 (
+
+ {/* Left Content Section */}
+
+
+
+
+ {/* Cartoon Characters */}
+
+ {/* Purple tall rectangle character */}
+
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',
+ }}
+ >
+
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`,
+ }}
+ >
+ 0 && showPassword) ? (isPurplePeeking ? 4 : -4) : isLookingAtEachOther ? 3 : undefined}
+ forceLookY={(password.length > 0 && showPassword) ? (isPurplePeeking ? 5 : -4) : isLookingAtEachOther ? 4 : undefined}
+ />
+ 0 && showPassword) ? (isPurplePeeking ? 4 : -4) : isLookingAtEachOther ? 3 : undefined}
+ forceLookY={(password.length > 0 && showPassword) ? (isPurplePeeking ? 5 : -4) : isLookingAtEachOther ? 4 : undefined}
+ />
+
+
+
+ {/* Black tall rectangle character */}
+
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',
+ }}
+ >
+
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`,
+ }}
+ >
+ 0 && showPassword) ? -4 : isLookingAtEachOther ? 0 : undefined}
+ forceLookY={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? -4 : undefined}
+ />
+ 0 && showPassword) ? -4 : isLookingAtEachOther ? 0 : undefined}
+ forceLookY={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? -4 : undefined}
+ />
+
+
+
+ {/* Orange semi-circle character */}
+
0 && showPassword) ? `skewX(0deg)` : `skewX(${orangePos.bodySkew || 0}deg)`,
+ transformOrigin: 'bottom center',
+ }}
+ >
+
0 && showPassword) ? `${50}px` : `${82 + (orangePos.faceX || 0)}px`,
+ top: (password.length > 0 && showPassword) ? `${85}px` : `${90 + (orangePos.faceY || 0)}px`,
+ }}
+ >
+
0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
+ 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
+
+
+
+ {/* Yellow tall rectangle character */}
+
0 && showPassword) ? `skewX(0deg)` : `skewX(${yellowPos.bodySkew || 0}deg)`,
+ transformOrigin: 'bottom center',
+ }}
+ >
+
0 && showPassword) ? `${20}px` : `${52 + (yellowPos.faceX || 0)}px`,
+ top: (password.length > 0 && showPassword) ? `${35}px` : `${40 + (yellowPos.faceY || 0)}px`,
+ }}
+ >
+
0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
+ 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
+
+
0 && showPassword) ? `${10}px` : `${40 + (yellowPos.faceX || 0)}px`,
+ top: (password.length > 0 && showPassword) ? `${88}px` : `${88 + (yellowPos.faceY || 0)}px`,
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right Login Section */}
+
+
+
+
+
+
Server Monitor
+
Sign in to access the dashboard
+
+
+
+
+
+ Eventify Server Health Monitor
+
+
+
+
+ );
+}
+
+export { LoginPage };
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
new file mode 100644
index 0000000..ac472bb
--- /dev/null
+++ b/client/src/components/ui/button.tsx
@@ -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
,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ },
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/client/src/components/ui/checkbox.tsx b/client/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..b8e7c62
--- /dev/null
+++ b/client/src/components/ui/checkbox.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx
new file mode 100644
index 0000000..a921025
--- /dev/null
+++ b/client/src/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/client/src/components/ui/label.tsx b/client/src/components/ui/label.tsx
new file mode 100644
index 0000000..afde563
--- /dev/null
+++ b/client/src/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/client/src/hooks/useServerHealth.ts b/client/src/hooks/useServerHealth.ts
index 8003c60..ef31753 100644
--- a/client/src/hooks/useServerHealth.ts
+++ b/client/src/hooks/useServerHealth.ts
@@ -23,7 +23,10 @@ export function useServerHealth() {
const query = useQuery({
queryKey: ["server-overview"],
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}`)
return res.json()
},
diff --git a/package-lock.json b/package-lock.json
index 8525186..bac7f27 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,9 @@
"client": {
"version": "0.0.0",
"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",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -2984,6 +2987,24 @@
"dev": true,
"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": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
@@ -3198,6 +3219,12 @@
"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": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
@@ -3502,6 +3529,15 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3947,6 +3983,55 @@
"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": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -4196,6 +4281,48 @@
"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": {
"version": "0.577.0",
"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==",
"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": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -5316,11 +5455,13 @@
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
+ "jsonwebtoken": "^9.0.3",
"ssh2": "^1.16.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
+ "@types/jsonwebtoken": "^9.0.10",
"@types/ssh2": "^1.15.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
diff --git a/server/package.json b/server/package.json
index 4f6d321..6788754 100644
--- a/server/package.json
+++ b/server/package.json
@@ -8,16 +8,18 @@
"start": "tsx src/index.ts"
},
"dependencies": {
- "express": "^4.21.0",
- "ssh2": "^1.16.0",
"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": {
- "@types/express": "^5.0.0",
- "@types/ssh2": "^1.15.0",
"@types/cors": "^2.8.17",
- "typescript": "^5.8.0",
- "tsx": "^4.19.0"
+ "@types/express": "^5.0.0",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/ssh2": "^1.15.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.8.0"
}
}
diff --git a/server/src/config.ts b/server/src/config.ts
index f4fd130..481b11e 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -18,4 +18,9 @@ export const config = {
),
},
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;
diff --git a/server/src/index.ts b/server/src/index.ts
index 1e63754..43aaccf 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -5,6 +5,8 @@ import { dirname, resolve } from 'path';
import { existsSync } from 'fs';
import { config } from './config.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';
const __filename = fileURLToPath(import.meta.url);
@@ -22,7 +24,8 @@ app.use(
app.use(express.json());
// ── API Routes ──
-app.use('/api', healthRouter);
+app.use('/api/auth', authRouter);
+app.use('/api', requireAuth, healthRouter);
// ── Static file serving for production SPA ──
const clientDistPath = resolve(__dirname, '../../client/dist');
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
new file mode 100644
index 0000000..b8d4065
--- /dev/null
+++ b/server/src/middleware/auth.ts
@@ -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' });
+ }
+}
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
new file mode 100644
index 0000000..7dc3ac9
--- /dev/null
+++ b/server/src/routes/auth.ts
@@ -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 });
+ }
+});