Authentication updates
This commit is contained in:
parent
98b1daa7d3
commit
3015c98246
15 changed files with 657 additions and 165 deletions
19
app/api/auth/check/route.ts
Normal file
19
app/api/auth/check/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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
124
app/auth/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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's Servers
|
Log out
|
||||||
</span>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
222
app/components/serverMenu.tsx
Normal file
222
app/components/serverMenu.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
9
app/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/page.tsx
24
app/page.tsx
|
|
@ -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
20
hooks/useCheckAuth.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
@ -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
12
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
25
proxy.ts
Normal 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).*)"],
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue