mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user