From 3015c98246fee95fa5853a1ad7ada0d9a2b11438 Mon Sep 17 00:00:00 2001 From: Jack Mechem Date: Sat, 28 Mar 2026 16:01:45 -0700 Subject: [PATCH] Authentication updates --- app/api/auth/check/route.ts | 19 +++ app/api/stats/route.ts | 21 +++- app/auth/page.tsx | 124 +++++++++++++++++++ app/components/Hero.tsx | 44 ++++++- app/components/NavBar.tsx | 78 +++++++----- app/components/serverMenu.tsx | 222 ++++++++++++++++++++++++++++++++++ app/layout.tsx | 38 +++--- app/lib/getStats.ts | 5 + app/loading.tsx | 9 ++ app/page.tsx | 170 ++++++++++++++------------ hooks/useCheckAuth.ts | 20 +++ middleware.ts | 32 ----- package-lock.json | 12 +- package.json | 3 +- proxy.ts | 25 ++++ 15 files changed, 657 insertions(+), 165 deletions(-) create mode 100644 app/api/auth/check/route.ts create mode 100644 app/auth/page.tsx create mode 100644 app/components/serverMenu.tsx create mode 100644 app/loading.tsx create mode 100644 hooks/useCheckAuth.ts delete mode 100644 middleware.ts create mode 100644 proxy.ts diff --git a/app/api/auth/check/route.ts b/app/api/auth/check/route.ts new file mode 100644 index 0000000..b70dd8e --- /dev/null +++ b/app/api/auth/check/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const token = req.cookies.get("token")?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // hit an endpoint that actually requires auth + const res = await fetch("http://localhost:3001/services/sysapi/logs", { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index fbe1a20..bf0e244 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -1,7 +1,18 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from "next/server"; -export async function GET() { - const res = await fetch('http://localhost:3001/stats'); - const data = await res.json(); - return NextResponse.json(data); +export async function GET(req: NextRequest) { + const token = req.cookies.get("token")?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const res = await fetch("http://localhost:3001/stats", { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + return NextResponse.json({ error: "Unauthorized" }, { status: res.status }); + } + + return NextResponse.json(await res.json()); } diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000..5ec4565 --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function AuthPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [checking, setChecking] = useState(true); + + useEffect(() => { + async function checkAuth() { + try { + const res = await fetch("/api/auth/check"); + if (res.ok) { + const callbackUrl = + new URLSearchParams(window.location.search).get("callbackUrl") ?? + "/"; + router.push(callbackUrl); + } + } finally { + setChecking(false); + } + } + checkAuth(); + }, [router]); + + async function handleLogin(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + setError("Invalid username or password."); + setLoading(false); + return; + } + + const callbackUrl = + new URLSearchParams(window.location.search).get("callbackUrl") ?? "/"; + router.push(callbackUrl); + } catch { + setError("Something went wrong. Try again."); + setLoading(false); + } + } + + if (checking) return null; + + return ( +
+
+ {/* Header */} +
+

+ dell-xps-nixos-serv +

+

+ Login +

+

Enter system credentials

+
+ +
+ {/* Username */} +
+ + setUsername(e.target.value)} + required + autoComplete="username" + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" + /> +
+ + {/* Password */} +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" + /> +
+ + {/* Error */} + {error &&

{error}

} + + {/* Submit */} + +
+
+
+ ); +} diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index a0c0e82..02a8252 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,16 +1,43 @@ "use client"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { LuSettings2 } from "react-icons/lu"; +import ControlPanel from "./serverMenu"; interface HeroProps { lastUpdated: string | null; } export default function Hero({ lastUpdated }: HeroProps) { + const router = useRouter(); + const [authed, setAuthed] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + async function checkAuth() { + try { + const res = await fetch("/api/auth/check"); + setAuthed(res.ok); + } finally { + } + } + checkAuth(); + }, [router]); + return (
+ {menuOpen && + typeof window !== "undefined" && + createPortal( + setMenuOpen(false)} />, + document.body, + )}

dell-xps-nixos-serv

-
+
Home server + {authed ? ( +
setMenuOpen(true)} + className="align-self-end ml-auto px-[20px] py-[20px] rounded-2xl hover:bg-gray-200 cursor-pointer duration-[200ms] text-gray-600 hover:shadow-sm font-[600]" + > + +
+ ) : ( +
router.push("/auth")} + className="align-self-end ml-auto px-[18px] py-[5px] rounded-xl hover:bg-blue-500 shadow-sm bg-white border-blue-300 hover:border-blue-400 border cursor-pointer duration-[200ms] text-blue-400 hover:shadow-sm hover:text-blue-100 text-[11pt] font-[600]" + > + Authenticate +
+ )}

{lastUpdated diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index 8031c29..ff46e57 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -1,35 +1,55 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + interface NavBarProps { - online: boolean; + online: boolean; } export default function NavBar({ online }: NavBarProps) { - return ( -

- ); + useEffect(() => { + fetch("/api/auth/check") + .then((r) => setAuth(r.ok)) + .catch(() => setAuth(false)); + }, []); + + async function handleLogout() { + await fetch("/api/auth/logout", { method: "POST" }); + router.push("/auth"); + } + + return ( + + ); } diff --git a/app/components/serverMenu.tsx b/app/components/serverMenu.tsx new file mode 100644 index 0000000..a9e909f --- /dev/null +++ b/app/components/serverMenu.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useState, useEffect } from "react"; + +const SERVICES = [ + "syncthing", + "dashboard", + "caddy", + "sshd", + "cloudflare-dyndns.timer", + "cloudflare-dyndns", + "docker", + "sysapi", +]; + +type Toast = { message: string; ok: boolean } | null; +type ServiceStatuses = Record; + +export default function ControlPanel({ onClose }: { onClose: () => void }) { + const [loading, setLoading] = useState>({}); + const [toast, setToast] = useState(null); + const [statuses, setStatuses] = useState({}); + + useEffect(() => { + async function fetchStatuses() { + try { + const res = await fetch("/api/stats"); + if (!res.ok) return; + const data = await res.json(); + setStatuses(data.services ?? {}); + } catch {} + } + fetchStatuses(); + }, []); + + function showToast(message: string, ok: boolean) { + setToast({ message, ok }); + setTimeout(() => setToast(null), 3000); + } + + async function handleService(action: string, service: string) { + setLoading((l) => ({ ...l, [`${service}-${action}`]: action })); + const res = await fetch(`/api/services/${service}/${action}`, { + method: "POST", + }); + showToast( + res.ok ? `${service} ${action}ed` : `Failed to ${action} ${service}`, + res.ok, + ); + if (res.ok) { + setTimeout(async () => { + const r = await fetch("/api/stats"); + if (r.ok) { + const data = await r.json(); + setStatuses(data.services ?? {}); + } + }, 1500); + } + setLoading((l) => { + const n = { ...l }; + delete n[`${service}-${action}`]; + return n; + }); + } + + async function handleLogs(service: string) { + const res = await fetch(`/api/services/${service}/logs`); + if (!res.ok) { + showToast("Failed to fetch logs", false); + return; + } + const data = await res.json(); + console.log(`Logs for ${service}:`, data.stdout); + showToast(`Logs fetched for ${service} — check console`, true); + } + + async function handleReboot() { + if (!confirm("Reboot the server? This will disconnect all sessions.")) + return; + const res = await fetch("/api/system/reboot", { method: "POST" }); + showToast(res.ok ? "Rebooting..." : "Reboot failed", res.ok); + } + + const isLoading = (service: string, action: string) => + loading[`${service}-${action}`] !== undefined; + + const isActive = (svc: string) => statuses[svc] === "active"; + const isInactive = (svc: string) => + statuses[svc] === "inactive" || + statuses[svc] === "failed" || + statuses[svc] === "dead"; + + function statusDot(svc: string) { + const s = statuses[svc]; + const color = + s === "active" + ? "bg-green-400" + : s === "failed" + ? "bg-red-400" + : "bg-gray-300"; + return ; + } + + function btnClass(svc: string, action: string): string { + const active = isActive(svc); + const inactive = isInactive(svc); + const busy = isLoading(svc, action); + const base = + "rounded-md px-2.5 py-1 text-[13px] whitespace-nowrap border transition-colors"; + + if (busy) return `${base} border-gray-200 text-gray-300 cursor-not-allowed`; + if (action === "start" && inactive) + return `${base} bg-green-50 border-green-200 text-green-700 cursor-pointer`; + if (action === "stop" && active) + return `${base} bg-red-50 border-red-200 text-red-500 cursor-pointer`; + if (action === "restart" && active) + return `${base} bg-blue-50 border-blue-200 text-blue-500 cursor-pointer`; + return `${base} border-gray-200 text-gray-300 cursor-default`; + } + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

Control panel

+

+ Manage services +

+
+ +
+ + {/* Services */} +
+

Services

+
+ {SERVICES.map((svc) => ( +
+
+ {statusDot(svc)} +

+ {svc} +

+
+
+ {["start", "stop", "restart"].map((action) => ( + + ))} + +
+
+ ))} +
+
+ + {/* System */} +
+

System

+
+
+

+ Reboot server +

+

+ Immediately restarts the machine +

+
+ +
+
+ + {/* Toast */} + {toast && ( +
+ {toast.message} +
+ )} +
+ + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 4ed168b..d09470c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,34 +3,34 @@ import { DM_Sans, Playfair_Display } from "next/font/google"; // Import your spe import "./globals.css"; const dmSans = DM_Sans({ - variable: "--font-dm-sans", - subsets: ["latin"], - display: "swap", + variable: "--font-dm-sans", + subsets: ["latin"], + display: "swap", }); const playfair = Playfair_Display({ - variable: "--font-playfair", - subsets: ["latin"], - display: "swap", + variable: "--font-playfair", + subsets: ["latin"], + display: "swap", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Create Next App", + description: "Generated by create next app", }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - {children} - - ); + return ( + + {children} + + ); } diff --git a/app/lib/getStats.ts b/app/lib/getStats.ts index 934b941..dee3af7 100644 --- a/app/lib/getStats.ts +++ b/app/lib/getStats.ts @@ -50,6 +50,11 @@ export interface Stats { export async function getStats(): Promise { const res = await fetch("/api/stats"); + + if (res.status === 401) { + throw new Error("UNAUTHORIZED"); + } + if (!res.ok) throw new Error(`Failed to fetch stats: ${res.status}`); return res.json() as Promise; } diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..3064987 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function Loading() { + return ( +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 4aae247..bfb9bb6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,96 +10,112 @@ import ServicesCard from "./components/ServicesCard"; import UptimeCard from "./components/UptimeCard"; import NetworkCard from "./components/NetworkCard"; import LinksGrid from "./components/LinksGrid"; +import { useCheckAuth } from "@/hooks/useCheckAuth"; +import { useRouter } from "next/navigation"; export default function Home() { - const [stats, setStats] = useState(null); - const [netSpeed, setNetSpeed] = useState>({}); - - // We use Refs for these because changing them shouldn't trigger a "refresh" - // but we need them to calculate the delta (speed) between fetches. - const prevNetRef = useRef | null>(null); - const lastFetchRef = useRef(0); + const router = useRouter(); + useCheckAuth(); - useEffect(() => { - const fetchData = async () => { - try { - const now = Date.now(); - const data = await getStats(); + const [stats, setStats] = useState(null); + const [netSpeed, setNetSpeed] = useState< + Record + >({}); - // 1. Calculate speeds if we have previous data - if (prevNetRef.current && lastFetchRef.current > 0) { - const elapsed = (now - lastFetchRef.current) / 1000; - const speeds: Record = {}; - - for (const iface of Object.keys(data.network)) { - const prev = prevNetRef.current[iface]; - if (prev) { - speeds[iface] = { - rx: Math.max(0, (data.network[iface].rx - prev.rx) / elapsed), - tx: Math.max(0, (data.network[iface].tx - prev.tx) / elapsed), - }; - } - } - setNetSpeed(speeds); - } + // We use Refs for these because changing them shouldn't trigger a "refresh" + // but we need them to calculate the delta (speed) between fetches. + const prevNetRef = useRef | null>(null); + const lastFetchRef = useRef(0); - // 2. Update our "silent" trackers - prevNetRef.current = data.network; - lastFetchRef.current = now; + useEffect(() => { + const fetchData = async () => { + try { + const now = Date.now(); + const data = await getStats(); - // 3. Update the UI state with new data - // React's Virtual DOM will only update the changed text/numbers. - setStats(data); - } catch (e) { - console.error("Dashboard fetch failed:", e); - } - }; + // 1. Calculate speeds if we have previous data + if (prevNetRef.current && lastFetchRef.current > 0) { + const elapsed = (now - lastFetchRef.current) / 1000; + const speeds: Record = {}; - // Initial fetch - fetchData(); + for (const iface of Object.keys(data.network)) { + const prev = prevNetRef.current[iface]; + if (prev) { + speeds[iface] = { + rx: Math.max(0, (data.network[iface].rx - prev.rx) / elapsed), + tx: Math.max(0, (data.network[iface].tx - prev.tx) / elapsed), + }; + } + } + setNetSpeed(speeds); + } - // Start the 4s loop - const id = setInterval(fetchData, 4000); + // 2. Update our "silent" trackers + prevNetRef.current = data.network; + lastFetchRef.current = now; - // Clean up on unmount so we don't have multiple intervals running - return () => clearInterval(id); - }, []); // Empty array means this setup only happens ONCE. + // 3. Update the UI state with new data + // React's Virtual DOM will only update the changed text/numbers. + setStats(data); + } catch (e) { + if (e instanceof Error && e.message === "UNAUTHORIZED") { + router.push( + "/auth?callbackUrl=" + encodeURIComponent(window.location.pathname), + ); + return; + } + console.error("Dashboard fetch failed:", e); + } + }; - // Derived values for the UI - const primaryIface = stats - ? Object.keys(stats.network).find( - (k) => !k.startsWith("docker") && !k.startsWith("br-") && stats.network[k].rx > 0 - ) - : null; + // Initial fetch + fetchData(); - const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null; + // Start the 4s loop + const id = setInterval(fetchData, 4000); - return ( -
- - + // Clean up on unmount so we don't have multiple intervals running + return () => clearInterval(id); + }, []); // Empty array means this setup only happens ONCE. -
-

- System Stats -

-
- - + // Derived values for the UI + const primaryIface = stats + ? Object.keys(stats.network).find( + (k) => + !k.startsWith("docker") && + !k.startsWith("br-") && + stats.network[k].rx > 0, + ) + : null; -
- -
- - -
-
+ const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null; - -
- ); + return ( +
+ + + +
+

+ System Stats +

+
+ + + +
+ +
+ + +
+
+ + +
+ ); } diff --git a/hooks/useCheckAuth.ts b/hooks/useCheckAuth.ts new file mode 100644 index 0000000..366b90d --- /dev/null +++ b/hooks/useCheckAuth.ts @@ -0,0 +1,20 @@ +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export function useCheckAuth() { + const router = useRouter(); + + useEffect(() => { + async function check() { + const res = await fetch("/api/auth/check"); + console.log(res); + if (!res.ok) { + console.log("Invalid tokin"); + router.push( + "/auth?callbackUrl=" + encodeURIComponent(window.location.pathname), + ); + } + } + check(); + }, [router]); +} diff --git a/middleware.ts b/middleware.ts deleted file mode 100644 index 655da3a..0000000 --- a/middleware.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -export function middleware(req: NextRequest) { - const authHeader = req.headers.get('authorization'); - - // Change these to your desired username and password - const USERNAME = process.env.DASHBOARD_USER; - const PASSWORD = process.env.DASHBOARD_PASS; - - if (authHeader) { - const auth = authHeader.split(' ')[1]; - const [user, pwd] = Buffer.from(auth, 'base64').toString().split(':'); - - if (user === USERNAME && pwd === PASSWORD) { - return NextResponse.next(); - } - } - - // If not authenticated, trigger the browser's native login popup - return new NextResponse('Authentication required', { - status: 401, - headers: { - 'WWW-Authenticate': 'Basic realm="Secure Area"', - }, - }); -} - -// Only protect the dashboard, not the static assets -export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], -}; diff --git a/package-lock.json b/package-lock.json index 51b2e49..44a6f39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "16.2.1", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-icons": "^5.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -5463,6 +5464,15 @@ "react": "^19.2.4" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index b3af769..a878289 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dependencies": { "next": "16.2.1", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-icons": "^5.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..292f8c0 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function proxy(req: NextRequest) { + const token = req.cookies.get("token")?.value; + const { pathname } = req.nextUrl; + + // always allow login page and auth api routes + if (pathname.startsWith("/auth") || pathname.startsWith("/api/auth")) { + return NextResponse.next(); + } + + // no token — redirect to login + if (!token) { + const loginUrl = new URL("/auth", req.url); + loginUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +};