Authentication updates
This commit is contained in:
parent
98b1daa7d3
commit
3015c98246
15 changed files with 657 additions and 165 deletions
|
|
@ -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<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 (
|
||||
<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">
|
||||
dell-xps-nixos-serv
|
||||
</p>
|
||||
<div className="flex gap-[10px] items-center">
|
||||
<div className="flex gap-[10px] items-center w-full">
|
||||
<svg
|
||||
className="w-[50px] h-[50px]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -35,6 +62,21 @@ export default function Hero({ lastUpdated }: HeroProps) {
|
|||
>
|
||||
Home server
|
||||
</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>
|
||||
<p className="text-sm text-gray-400 font-light">
|
||||
{lastUpdated
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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">
|
||||
<span
|
||||
className="text-lg font-medium tracking-tight text-gray-900"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
Jack's Servers
|
||||
</span>
|
||||
</div>
|
||||
const router = useRouter();
|
||||
const [auth, setAuth] = useState(false);
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs font-medium px-3 py-1.5 rounded-full border ${
|
||||
online
|
||||
? "text-green-700 bg-green-50 border-green-200"
|
||||
: "text-gray-500 bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: online ? "#22c55e" : "#d1d5db",
|
||||
animation: online ? "pulse-dot 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
{online ? "Online" : "Connecting..."}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
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 (
|
||||
<nav className="flex items-center justify-between pt-7 pb-6 mb-13 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
{auth && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs font-medium px-3 py-1.5 rounded-full border ${
|
||||
online
|
||||
? "text-green-700 bg-green-50 border-green-200"
|
||||
: "text-gray-500 bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: online ? "#22c55e" : "#d1d5db",
|
||||
animation: online ? "pulse-dot 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
{online ? "Online" : "Connecting..."}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue