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 (
+ + {/* Logout */} + {onLogout && ( + + )} ) 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 */} +
+
+
+
+ +
+ Eventify +
+
+ +
+ {/* 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 */} +
+
+
+
+ +
+ Eventify +
+ +
+

Server Monitor

+

Sign in to access the dashboard

+
+ +
+
+ + setEmail(e.target.value)} + onFocus={() => setIsTyping(true)} + onBlur={() => setIsTyping(false)} + required + className="h-12 bg-background border-border/60 focus:border-primary" + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + className="h-12 pr-10 bg-background border-border/60 focus:border-primary" + /> + +
+
+ +
+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ +
+ 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 }); + } +});