Authentication updates

This commit is contained in:
Jack Mechem 2026-03-28 16:01:45 -07:00
parent 98b1daa7d3
commit 3015c98246
15 changed files with 657 additions and 165 deletions

View file

@ -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 });
}

View file

@ -1,7 +1,18 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
export async function GET() { export async function GET(req: NextRequest) {
const res = await fetch('http://localhost:3001/stats'); const token = req.cookies.get("token")?.value;
const data = await res.json(); if (!token) {
return NextResponse.json(data); 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());
} }

124
app/auth/page.tsx Normal file
View file

@ -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 (
<main className="h-full bg-gray-100 flex items-center justify-center">
<div className="bg-white border border-gray-300 rounded-2xl md:p-12 p-8 w-full m-[10px] md:max-w-md shadow-sm">
{/* Header */}
<div className="mb-8">
<p className="text-[11px] tracking-widest text-blue-500 uppercase mb-2">
dell-xps-nixos-serv
</p>
<h1 className="text-[28px] font-normal text-gray-900 tracking-tight mb-1.5">
Login
</h1>
<p className="text-sm text-gray-400">Enter system credentials</p>
</div>
<form onSubmit={handleLogin}>
{/* Username */}
<div className="mb-4">
<label className="block text-[11px] tracking-wider text-gray-400 uppercase mb-1.5">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
{/* Password */}
<div className="mb-6">
<label className="block text-[11px] tracking-wider text-gray-400 uppercase mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
{/* Error */}
{error && <p className="text-[13px] text-red-400 mb-4">{error}</p>}
{/* Submit */}
<button
type="submit"
disabled={loading}
className={`w-full py-2.5 rounded-xl text-md border border-blue-600 shadow-sm text-white font-[600] tracking-wide transition-colors ${
loading
? "bg-blue-200 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-400 cursor-pointer"
}`}
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
</main>
);
}

View file

@ -1,16 +1,43 @@
"use client"; "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 { interface HeroProps {
lastUpdated: string | null; lastUpdated: string | null;
} }
export default function Hero({ lastUpdated }: HeroProps) { export default function Hero({ lastUpdated }: HeroProps) {
const router = useRouter();
const [authed, setAuthed] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
useEffect(() => {
async function checkAuth() {
try {
const res = await fetch("/api/auth/check");
setAuthed(res.ok);
} finally {
}
}
checkAuth();
}, [router]);
return ( return (
<div className="mb-11 animate-fade-up"> <div className="mb-11 animate-fade-up">
{menuOpen &&
typeof window !== "undefined" &&
createPortal(
<ControlPanel onClose={() => setMenuOpen(false)} />,
document.body,
)}
<p className="text-xs font-medium tracking-widest uppercase text-blue-500 mb-3"> <p className="text-xs font-medium tracking-widest uppercase text-blue-500 mb-3">
dell-xps-nixos-serv dell-xps-nixos-serv
</p> </p>
<div className="flex gap-[10px] items-center"> <div className="flex gap-[10px] items-center w-full">
<svg <svg
className="w-[50px] h-[50px]" className="w-[50px] h-[50px]"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -35,6 +62,21 @@ export default function Hero({ lastUpdated }: HeroProps) {
> >
Home server Home server
</h1> </h1>
{authed ? (
<div
onClick={() => 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]"
>
<LuSettings2 />
</div>
) : (
<div
onClick={() => 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
</div>
)}
</div> </div>
<p className="text-sm text-gray-400 font-light"> <p className="text-sm text-gray-400 font-light">
{lastUpdated {lastUpdated

View file

@ -1,17 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface NavBarProps { interface NavBarProps {
online: boolean; online: boolean;
} }
export default function NavBar({ online }: NavBarProps) { export default function NavBar({ online }: NavBarProps) {
const router = useRouter();
const [auth, setAuth] = useState(false);
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 ( return (
<nav className="flex items-center justify-between pt-7 pb-6 mb-13 border-b border-gray-200"> <nav className="flex items-center justify-between pt-7 pb-6 mb-13 border-b border-gray-200">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<span {auth && (
className="text-lg font-medium tracking-tight text-gray-900" <button
style={{ fontFamily: "'Playfair Display', serif" }} onClick={handleLogout}
className="flex items-center gap-2 text-xs text-red-700 bg-red-50 border border-red-200 px-3 py-1.5 rounded-full cursor-pointer"
> >
Jack&apos;s Servers Log out
</span> </button>
)}
</div> </div>
<div <div

View file

@ -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<string, string>;
export default function ControlPanel({ onClose }: { onClose: () => void }) {
const [loading, setLoading] = useState<Record<string, string>>({});
const [toast, setToast] = useState<Toast>(null);
const [statuses, setStatuses] = useState<ServiceStatuses>({});
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 <span className={`w-2 h-2 rounded-full shrink-0 ${color}`} />;
}
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 */}
<div onClick={onClose} className="fixed inset-0 bg-black/15 z-40" />
{/* Panel */}
<div className="fixed top-0 right-0 bottom-0 md:w-[480px] w-full bg-white border-l border-gray-100 z-50 overflow-y-auto p-8">
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<p className="text-[13px] text-blue-500 mb-1.5">Control panel</p>
<h2 className="text-[22px] font-normal text-gray-900 tracking-tight m-0">
Manage services
</h2>
</div>
<button
onClick={onClose}
className="border border-gray-200 rounded-lg px-3.5 py-1.5 text-[13px] text-gray-400 cursor-pointer hover:bg-gray-50 transition-colors"
>
Close
</button>
</div>
{/* Services */}
<div className="border-t border-gray-100 pt-6 mb-6">
<p className="text-[13px] text-gray-300 mb-3">Services</p>
<div className="flex flex-col gap-2">
{SERVICES.map((svc) => (
<div
key={svc}
className="bg-gray-50 border border-gray-100 rounded-xl px-4 py-3 flex md:flex-row flex-col items-center justify-between gap-3"
>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
{statusDot(svc)}
<p className="text-[14px] text-gray-900 m-0 truncate">
{svc}
</p>
</div>
<div className="flex gap-1.5 shrink-0">
{["start", "stop", "restart"].map((action) => (
<button
key={action}
onClick={() =>
!isLoading(svc, action) && handleService(action, svc)
}
disabled={isLoading(svc, action)}
className={btnClass(svc, action)}
>
{isLoading(svc, action)
? "..."
: action.charAt(0).toUpperCase() + action.slice(1)}
</button>
))}
<button
onClick={() => handleLogs(svc)}
className="rounded-md px-2.5 py-1 text-[13px] whitespace-nowrap border border-blue-100 text-blue-400 cursor-pointer hover:bg-blue-50 transition-colors"
>
Logs
</button>
</div>
</div>
))}
</div>
</div>
{/* System */}
<div className="border-t border-gray-100 pt-6">
<p className="text-[13px] text-gray-300 mb-3">System</p>
<div className="bg-gray-50 border border-gray-100 rounded-xl px-4 py-3.5 flex items-center justify-between">
<div>
<p className="text-[14px] text-gray-900 m-0 mb-0.5">
Reboot server
</p>
<p className="text-[12px] text-gray-400 m-0">
Immediately restarts the machine
</p>
</div>
<button
onClick={handleReboot}
className="border border-red-200 rounded-lg px-3.5 py-1.5 text-[13px] text-red-400 cursor-pointer hover:bg-red-50 transition-colors whitespace-nowrap"
>
Reboot
</button>
</div>
</div>
{/* Toast */}
{toast && (
<div
className={`mt-6 px-4 py-3 rounded-lg text-[13px] border ${
toast.ok
? "bg-green-50 text-green-700 border-green-200"
: "bg-red-50 text-red-500 border-red-200"
}`}
>
{toast.message}
</div>
)}
</div>
</>
);
}

View file

@ -30,7 +30,7 @@ export default function RootLayout({
// Add the new font variables here // Add the new font variables here
className={`${dmSans.variable} ${playfair.variable} h-full antialiased`} className={`${dmSans.variable} ${playfair.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full h-full flex flex-col">{children}</body>
</html> </html>
); );
} }

View file

@ -50,6 +50,11 @@ export interface Stats {
export async function getStats(): Promise<Stats> { export async function getStats(): Promise<Stats> {
const res = await fetch("/api/stats"); 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}`); if (!res.ok) throw new Error(`Failed to fetch stats: ${res.status}`);
return res.json() as Promise<Stats>; return res.json() as Promise<Stats>;
} }

9
app/loading.tsx Normal file
View file

@ -0,0 +1,9 @@
"use client";
export default function Loading() {
return (
<main className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
</main>
);
}

View file

@ -10,10 +10,17 @@ import ServicesCard from "./components/ServicesCard";
import UptimeCard from "./components/UptimeCard"; import UptimeCard from "./components/UptimeCard";
import NetworkCard from "./components/NetworkCard"; import NetworkCard from "./components/NetworkCard";
import LinksGrid from "./components/LinksGrid"; import LinksGrid from "./components/LinksGrid";
import { useCheckAuth } from "@/hooks/useCheckAuth";
import { useRouter } from "next/navigation";
export default function Home() { export default function Home() {
const router = useRouter();
useCheckAuth();
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [netSpeed, setNetSpeed] = useState<Record<string, { rx: number; tx: number }>>({}); const [netSpeed, setNetSpeed] = useState<
Record<string, { rx: number; tx: number }>
>({});
// We use Refs for these because changing them shouldn't trigger a "refresh" // We use Refs for these because changing them shouldn't trigger a "refresh"
// but we need them to calculate the delta (speed) between fetches. // but we need them to calculate the delta (speed) between fetches.
@ -51,6 +58,12 @@ export default function Home() {
// React's Virtual DOM will only update the changed text/numbers. // React's Virtual DOM will only update the changed text/numbers.
setStats(data); setStats(data);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message === "UNAUTHORIZED") {
router.push(
"/auth?callbackUrl=" + encodeURIComponent(window.location.pathname),
);
return;
}
console.error("Dashboard fetch failed:", e); console.error("Dashboard fetch failed:", e);
} }
}; };
@ -68,14 +81,17 @@ export default function Home() {
// Derived values for the UI // Derived values for the UI
const primaryIface = stats const primaryIface = stats
? Object.keys(stats.network).find( ? Object.keys(stats.network).find(
(k) => !k.startsWith("docker") && !k.startsWith("br-") && stats.network[k].rx > 0 (k) =>
!k.startsWith("docker") &&
!k.startsWith("br-") &&
stats.network[k].rx > 0,
) )
: null; : null;
const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null; const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null;
return ( return (
<main className="max-w-5xl mx-auto px-6 pb-20"> <div className="max-w-5xl mx-auto px-6 pb-20">
<NavBar online={!!stats} /> <NavBar online={!!stats} />
<Hero lastUpdated={stats?.timestamp ?? null} /> <Hero lastUpdated={stats?.timestamp ?? null} />
@ -100,6 +116,6 @@ export default function Home() {
</div> </div>
<LinksGrid /> <LinksGrid />
</main> </div>
); );
} }

20
hooks/useCheckAuth.ts Normal file
View file

@ -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]);
}

View file

@ -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).*)'],
};

12
package-lock.json generated
View file

@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@ -5463,6 +5464,15 @@
"react": "^19.2.4" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View file

@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

25
proxy.ts Normal file
View file

@ -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).*)"],
};