feat: implement user authentication and admin management

- Add user management API endpoints for listing and creating users.
- Implement login and logout functionality with rate limiting.
- Create UI components for login form, logout button, and animated elements.
- Add session management with JWT and secure cookie handling.
- Seed initial admin user if no users exist.
- Introduce utility functions for password hashing and user authentication.
- Set up proxy middleware for route protection and security headers.
- Create a JSON file for user data storage.
- Add links configuration for external resources.
This commit is contained in:
Anders Böttcher
2026-05-12 14:58:11 +02:00
parent 50566f2a22
commit 0d58fafe4e
34 changed files with 4263 additions and 5012 deletions
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Variant =
| "fade-up"
| "fade-down"
| "fade-in"
| "fade-left"
| "fade-right"
| "scale-up";
interface AnimateInProps {
children: React.ReactNode;
variant?: Variant;
/** Delay (ms) after the element enters the viewport before animating */
delay?: number;
/** Animation duration in ms */
duration?: number;
/** Extra classes to put on the wrapper div */
className?: string;
/** If true (default) the animation only fires once */
once?: boolean;
}
const transforms: Record<Variant, { hidden: string; visible: string }> = {
"fade-up": { hidden: "opacity-0 translate-y-10", visible: "opacity-100 translate-y-0" },
"fade-down": { hidden: "opacity-0 -translate-y-10", visible: "opacity-100 translate-y-0" },
"fade-in": { hidden: "opacity-0", visible: "opacity-100" },
"fade-left": { hidden: "opacity-0 -translate-x-10", visible: "opacity-100 translate-x-0" },
"fade-right": { hidden: "opacity-0 translate-x-10", visible: "opacity-100 translate-x-0" },
"scale-up": { hidden: "opacity-0 scale-95", visible: "opacity-100 scale-100" },
};
export default function AnimateIn({
children,
variant = "fade-up",
delay = 0,
duration = 650,
className = "",
once = true,
}: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
// Respect the user's motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
if (once) observer.disconnect();
} else if (!once) {
setVisible(false);
}
},
{ threshold: 0.08, rootMargin: "0px 0px -60px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [once]);
const { hidden, visible: vis } = transforms[variant];
return (
<div
ref={ref}
className={`${visible ? vis : hidden} ${className}`}
style={{
transition: `opacity ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms, transform ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms`,
willChange: "opacity, transform",
}}
>
{children}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface CountUpProps {
/** Target number to count up to */
to: number;
/** String appended after the number (e.g. "%", " MB", "+") */
suffix?: string;
/** String prepended before the number */
prefix?: string;
/** Animation duration in ms */
duration?: number;
/** Decimal places to show */
decimals?: number;
}
export default function CountUp({
to,
suffix = "",
prefix = "",
duration = 1800,
decimals = 0,
}: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const [current, setCurrent] = useState(0);
const [started, setStarted] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !started) {
setStarted(true);
observer.disconnect();
}
},
{ threshold: 0.5 },
);
observer.observe(el);
return () => observer.disconnect();
}, [started]);
useEffect(() => {
if (!started) return;
// Skip animation if user prefers reduced motion
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setCurrent(to);
return;
}
const startTime = performance.now();
let raf: number;
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out quart — fast start, smooth finish
const eased = 1 - Math.pow(1 - progress, 4);
setCurrent(eased * to);
if (progress < 1) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [started, to, duration]);
const display =
decimals > 0 ? current.toFixed(decimals) : Math.floor(current).toString();
return (
<span ref={ref}>
{prefix}
{display}
{suffix}
</span>
);
}
+40
View File
@@ -0,0 +1,40 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LogoutButton() {
const router = useRouter()
const [loading, setLoading] = useState(false)
async function handleLogout() {
setLoading(true)
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login')
router.refresh()
}
return (
<button
onClick={handleLogout}
disabled={loading}
className="px-3 py-2 rounded-lg text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all disabled:opacity-50"
title="Sign out"
>
{loading ? (
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4" aria-hidden="true">
<path fillRule="evenodd" d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z" clipRule="evenodd" />
<path fillRule="evenodd" d="M6 10a.75.75 0 01.75-.75h9.546l-1.048-.943a.75.75 0 111.004-1.114l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 11-1.004-1.114l1.048-.943H6.75A.75.75 0 016 10z" clipRule="evenodd" />
</svg>
<span className="hidden sm:inline">Sign out</span>
</span>
)}
</button>
)
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useRef } from "react";
/**
* SpotlightCard — renders a card with a cursor-following radial glow
* that lights up where the user's mouse is hovering. Zero dependencies.
*/
export default function SpotlightCard({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
function onMouseMove(e: React.MouseEvent<HTMLDivElement>) {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
el.style.setProperty("--mx", `${e.clientX - rect.left}px`);
el.style.setProperty("--my", `${e.clientY - rect.top}px`);
}
function onMouseLeave() {
// Reset so the glow fades away gracefully
const el = ref.current;
if (!el) return;
el.style.setProperty("--mx", "-9999px");
el.style.setProperty("--my", "-9999px");
}
return (
<div
ref={ref}
className={`spotlight ${className}`}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
{children}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useState } from "react";
import { LINKS } from "@/lib/links";
interface Line {
type: "comment" | "cmd" | "cmd-cont" | "output" | "success" | "error";
text: string;
}
function buildLines(): Line[] {
return [
{ type: "comment", text: "# Pull the latest Backerup image" },
{ type: "cmd", text: `docker pull ${LINKS.dockerImage}` },
{ type: "output", text: `latest: Pulling from ${LINKS.dockerImage.split(":")[0]}` },
{ type: "output", text: "Digest: sha256:a3f8b1c2d9e4f7g6h5..." },
{ type: "success", text: "✓ Status: Image is up to date" },
{ type: "comment", text: "# Start Backerup with Docker socket access" },
{ type: "cmd", text: "docker run -d --name backerup \\" },
{ type: "cmd-cont", text: ` -p ${LINKS.uiPort}:${LINKS.uiPort} \\` },
{ type: "cmd-cont", text: " -v /var/run/docker.sock:/var/run/docker.sock \\" },
{ type: "cmd-cont", text: ` ${LINKS.dockerImage}` },
{ type: "success", text: "✓ Container started · a7f82c3d1b09" },
];
}
function lineSpeed(line: Line) {
if (line.type === "output" || line.type === "success" || line.type === "error") return 12;
if (line.type === "comment") return 22;
return 38;
}
function linePause(line: Line) {
if (line.type === "success" || line.type === "error") return 700;
if (line.type === "output") return 80;
if (line.type === "comment") return 300;
return 350;
}
function lineColor(line: Line) {
switch (line.type) {
case "comment": return "text-slate-600";
case "success": return "text-emerald-400";
case "error": return "text-red-400";
case "output": return "text-slate-400";
default: return "text-slate-200";
}
}
function linePrompt(line: Line) {
if (line.type === "cmd") return "$ ";
if (line.type === "cmd-cont") return " ";
return "";
}
export default function TerminalTyper() {
const LINES = buildLines();
const [doneLines, setDoneLines] = useState<number[]>([]);
const [current, setCurrent] = useState<{ idx: number; chars: number } | null>({ idx: 0, chars: 0 });
useEffect(() => {
if (!current) return;
const { idx, chars } = current;
const line = LINES[idx];
const fullLen = line.text.length;
if (chars < fullLen) {
const t = setTimeout(() => {
setCurrent({ idx, chars: chars + 1 });
}, lineSpeed(line));
return () => clearTimeout(t);
}
// Line finished → pause, then advance
const t = setTimeout(() => {
setDoneLines((prev) => [...prev, idx]);
const next = idx + 1;
if (next < LINES.length) {
setCurrent({ idx: next, chars: 0 });
} else {
setCurrent(null);
}
}, linePause(line));
return () => clearTimeout(t);
}, [current]);
return (
<div className="font-mono text-xs sm:text-sm leading-[1.85] select-none">
{doneLines.map((i) => {
const line = LINES[i];
const prompt = linePrompt(line);
return (
<div key={i} className={`flex ${lineColor(line)}`}>
{prompt && <span className="text-cyan-500 shrink-0 select-none">{prompt}</span>}
<span>{line.text}</span>
</div>
);
})}
{current && current.idx < LINES.length && (() => {
const line = LINES[current.idx];
const prompt = linePrompt(line);
const partial = line.text.slice(0, current.chars);
return (
<div className={`flex ${lineColor(line)}`}>
{prompt && <span className="text-cyan-500 shrink-0 select-none">{prompt}</span>}
<span>{partial}</span>
<span className="inline-block w-[7px] h-[0.9em] bg-cyan-400 ml-px animate-blink self-center" />
</div>
);
})()}
</div>
);
}