296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { ArrowLeft, ExternalLink, ImageIcon, LockKeyhole, LogOut } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
|
|
import { AppHeader } from "@/components/layout/app-header";
|
|
import { AppShell } from "@/components/layout/app-shell";
|
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
|
import { Container } from "@/components/layout/container";
|
|
import { MotionFade } from "@/components/motion-fade";
|
|
import { ThemeToggle } from "@/components/theme-toggle";
|
|
import { getLocalizedPath } from "@/lib/locale";
|
|
import { AppCard } from "@/components/ui/app-card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
clearAdminSessionCookie,
|
|
getAdminLockState,
|
|
isAdminAuthConfigured,
|
|
isAdminAuthenticated,
|
|
isPasswordValid,
|
|
registerFailedAdminAttempt,
|
|
resetAdminFailedAttempts,
|
|
setAdminSessionCookie,
|
|
} from "@/lib/admin-auth";
|
|
import { getMaintenanceMode } from "@/lib/app-config";
|
|
import { getRootNavigation } from "@/lib/root-navigation";
|
|
|
|
type RootPageProps = {
|
|
searchParams?: {
|
|
error?: string;
|
|
};
|
|
};
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const copy = {
|
|
title: "Uebersicht",
|
|
subtitle: "Interner Bereich fuer Kennzahlen und zentrale Navigation.",
|
|
overview: "Uebersicht",
|
|
maintenance: "Wartungsmodus",
|
|
maintenanceTitle: "Wartungsmodus",
|
|
maintenanceVisitorsClosed: "Website fuer Besucher geschlossen",
|
|
maintenanceDescription:
|
|
"Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
|
|
maintenanceOpen: "Website fuer Besucher offen",
|
|
maintenanceAction: "Zur Wartungsseite",
|
|
uiKitTitle: "UI Kit",
|
|
uiKitDescription: "Globale Referenz fuer Cards, Buttons, Inputs und Surface Levels.",
|
|
uiKitAction: "Zur UI Kit",
|
|
media: "Media",
|
|
portfolio: "Portfolio",
|
|
portfolioTitle: "Portfolio",
|
|
portfolioDescription: "Kategorien, Projekte, Sections und Assets verwalten.",
|
|
portfolioAction: "Zum Portfolio",
|
|
mediaTitle: "Media Library",
|
|
mediaDescription: "Uploads, externe URLs und Verwendungsorte zentral verwalten.",
|
|
mediaAction: "Zur Media Library",
|
|
loginTitle: "Root Login",
|
|
loginText: "Nur autorisierte Nutzer duerfen diesen Bereich verwenden.",
|
|
passwordLabel: "Passwort",
|
|
loginButton: "Einloggen",
|
|
invalidLogin: "Falsches Passwort.",
|
|
lockedLogin: "Zu viele Fehlversuche. Bitte spaeter erneut versuchen.",
|
|
configMissing: "ADMIN_PASSWORD und ADMIN_AUTH_SECRET fehlen in env.",
|
|
basicAuthMissing: "ROOT_BASIC_AUTH_USER und ROOT_BASIC_AUTH_PASS fehlen in env.",
|
|
logout: "Ausloggen",
|
|
backToSite: "Zur Website",
|
|
uiKit: "UI Kit",
|
|
};
|
|
|
|
export default async function RootPage({ searchParams }: RootPageProps) {
|
|
const authConfigured = isAdminAuthConfigured();
|
|
const basicConfigured = Boolean(
|
|
process.env.ROOT_BASIC_AUTH_USER && process.env.ROOT_BASIC_AUTH_PASS,
|
|
);
|
|
const authenticated = isAdminAuthenticated();
|
|
const lockState = getAdminLockState();
|
|
const maintenanceEnabled = authenticated ? await getMaintenanceMode() : false;
|
|
|
|
async function loginAction(formData: FormData) {
|
|
"use server";
|
|
|
|
const password = String(formData.get("password") ?? "");
|
|
const currentLockState = getAdminLockState();
|
|
|
|
if (currentLockState.locked) {
|
|
redirect("/root?error=locked");
|
|
}
|
|
|
|
if (!isAdminAuthConfigured() || !isPasswordValid(password)) {
|
|
const failState = registerFailedAdminAttempt();
|
|
if (failState.locked) {
|
|
redirect("/root?error=locked");
|
|
}
|
|
|
|
redirect("/root?error=invalid");
|
|
}
|
|
|
|
resetAdminFailedAttempts();
|
|
setAdminSessionCookie();
|
|
redirect("/root");
|
|
}
|
|
|
|
async function logoutAction() {
|
|
"use server";
|
|
|
|
clearAdminSessionCookie();
|
|
redirect("/root");
|
|
}
|
|
|
|
if (!authenticated) {
|
|
return (
|
|
<Container size="narrow" className="py-12">
|
|
<MotionFade>
|
|
<AppCard level={3}>
|
|
<CardHeader>
|
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
|
<LockKeyhole className="h-4 w-4 text-brand-primary" />
|
|
{copy.loginTitle}
|
|
</p>
|
|
<CardDescription>{copy.loginText}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!authConfigured ? (
|
|
<p className="mb-4 rounded-nested border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
{copy.configMissing}
|
|
</p>
|
|
) : null}
|
|
|
|
{!basicConfigured ? (
|
|
<p className="mb-4 rounded-nested border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
{copy.basicAuthMissing}
|
|
</p>
|
|
) : null}
|
|
|
|
{searchParams?.error === "invalid" ? (
|
|
<p className="mb-4 rounded-nested border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
|
|
{copy.invalidLogin}
|
|
</p>
|
|
) : null}
|
|
|
|
{searchParams?.error === "locked" || lockState.locked ? (
|
|
<p className="mb-4 rounded-nested border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
|
|
{copy.lockedLogin}
|
|
</p>
|
|
) : null}
|
|
|
|
<form action={loginAction} className="space-y-3">
|
|
<Label htmlFor="password">{copy.passwordLabel}</Label>
|
|
<Input id="password" type="password" name="password" required />
|
|
<Button
|
|
type="submit"
|
|
disabled={!authConfigured || !basicConfigured || lockState.locked}
|
|
>
|
|
<LockKeyhole className="h-4 w-4" />
|
|
{copy.loginButton}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
const sidebarItems = getRootNavigation(copy, "overview");
|
|
|
|
return (
|
|
<AppShell
|
|
sidebar={
|
|
<AppSidebar
|
|
title={copy.title}
|
|
description={copy.subtitle}
|
|
items={sidebarItems}
|
|
footer={
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<ThemeToggle ariaLabel="Theme wechseln" />
|
|
</div>
|
|
<Button asChild variant="outline" className="w-full justify-start">
|
|
<Link href={getLocalizedPath("de")}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
{copy.backToSite}
|
|
</Link>
|
|
</Button>
|
|
<form action={logoutAction}>
|
|
<Button type="submit" variant="destructive" className="w-full justify-start">
|
|
<LogOut className="h-4 w-4" />
|
|
{copy.logout}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
}
|
|
/>
|
|
}
|
|
header={
|
|
<AppHeader
|
|
title={copy.title}
|
|
description={copy.subtitle}
|
|
actions={
|
|
maintenanceEnabled ? (
|
|
<Button asChild variant="destructive">
|
|
<Link href="/root/maintenance">
|
|
{copy.maintenanceVisitorsClosed}
|
|
</Link>
|
|
</Button>
|
|
) : null
|
|
}
|
|
/>
|
|
}
|
|
>
|
|
<div className="grid gap-6">
|
|
<section className="grid gap-4 lg:grid-cols-4">
|
|
<MotionFade delay={0.05}>
|
|
<AppCard level={2}>
|
|
<CardHeader>
|
|
<CardDescription>{copy.maintenanceTitle}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-lg font-semibold text-foreground">
|
|
{maintenanceEnabled ? copy.maintenanceVisitorsClosed : copy.maintenanceOpen}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{copy.maintenanceDescription}
|
|
</p>
|
|
<Button asChild variant={maintenanceEnabled ? "destructive" : "outline"}>
|
|
<Link href="/root/maintenance">
|
|
{copy.maintenanceAction}
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
|
|
<MotionFade delay={0.1}>
|
|
<AppCard level={2}>
|
|
<CardHeader>
|
|
<CardDescription>{copy.uiKitTitle}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-lg font-semibold text-foreground">{copy.uiKitTitle}</p>
|
|
<p className="text-sm text-muted-foreground">{copy.uiKitDescription}</p>
|
|
<Button asChild variant="outline">
|
|
<Link href="/root/ui-kit">
|
|
{copy.uiKitAction}
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
|
|
<MotionFade delay={0.15}>
|
|
<AppCard level={2}>
|
|
<CardHeader>
|
|
<CardDescription>{copy.portfolioTitle}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-lg font-semibold text-foreground">{copy.portfolioTitle}</p>
|
|
<p className="text-sm text-muted-foreground">{copy.portfolioDescription}</p>
|
|
<Button asChild variant="outline">
|
|
<Link href="/root/portfolio">
|
|
{copy.portfolioAction}
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
|
|
<MotionFade delay={0.2}>
|
|
<AppCard level={2}>
|
|
<CardHeader>
|
|
<CardDescription>{copy.mediaTitle}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-lg font-semibold text-foreground">{copy.mediaTitle}</p>
|
|
<p className="text-sm text-muted-foreground">{copy.mediaDescription}</p>
|
|
<Button asChild variant="outline">
|
|
<Link href="/root/media">
|
|
{copy.mediaAction}
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
</section>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|