245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
import { FolderKanban, ImageIcon, LockKeyhole, ShieldAlert } from "lucide-react";
|
|
import { redirect } from "next/navigation";
|
|
|
|
import { Container } from "@/components/layout/container";
|
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
|
import { MotionFade } from "@/components/motion-fade";
|
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, 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 { getAdminAppPath } from "@/lib/admin-routing";
|
|
import { getMaintenanceMode } from "@/lib/app-config";
|
|
import { getAdminMediaAssets } from "@/lib/media";
|
|
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
|
|
|
|
type AdminPageProps = {
|
|
searchParams?: Promise<{
|
|
error?: string;
|
|
}>;
|
|
};
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const copy = {
|
|
title: "Uebersicht",
|
|
subtitle: "Kompakter Status zu Portfolio, Media und Wartung.",
|
|
overview: "Uebersicht",
|
|
maintenance: "Wartungsmodus",
|
|
media: "Media",
|
|
siteSettings: "Settings",
|
|
portfolio: "Portfolio",
|
|
uiKit: "UI Kit",
|
|
logout: "Ausloggen",
|
|
backToSite: "Zur Website",
|
|
loginTitle: "Admin 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: "ADMIN_BASIC_AUTH_USER und ADMIN_BASIC_AUTH_PASS fehlen in env.",
|
|
maintenanceOn: "Aktiv",
|
|
maintenanceOff: "Inaktiv",
|
|
portfolioOnline: "Portfolio online",
|
|
mediaImages: "Media Images",
|
|
maintenanceStatus: "Maintenance",
|
|
drafts: "Drafts",
|
|
activeCategories: "Active Categories",
|
|
};
|
|
|
|
export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|
const resolvedSearchParams = await searchParams;
|
|
const authConfigured = isAdminAuthConfigured();
|
|
const basicConfigured = Boolean(
|
|
process.env.ADMIN_BASIC_AUTH_USER && process.env.ADMIN_BASIC_AUTH_PASS,
|
|
);
|
|
const authenticated = await isAdminAuthenticated();
|
|
const lockState = await getAdminLockState();
|
|
|
|
async function loginAction(formData: FormData) {
|
|
"use server";
|
|
|
|
const password = String(formData.get("password") ?? "");
|
|
const currentLockState = await getAdminLockState();
|
|
|
|
if (currentLockState.locked) {
|
|
redirect(`${getAdminAppPath("/")}?error=locked`);
|
|
}
|
|
|
|
if (!isAdminAuthConfigured() || !isPasswordValid(password)) {
|
|
const failState = await registerFailedAdminAttempt();
|
|
if (failState.locked) {
|
|
redirect(`${getAdminAppPath("/")}?error=locked`);
|
|
}
|
|
|
|
redirect(`${getAdminAppPath("/")}?error=invalid`);
|
|
}
|
|
|
|
await resetAdminFailedAttempts();
|
|
await setAdminSessionCookie();
|
|
redirect(getAdminAppPath("/"));
|
|
}
|
|
|
|
async function logoutAction() {
|
|
"use server";
|
|
|
|
await clearAdminSessionCookie();
|
|
redirect(getAdminAppPath("/"));
|
|
}
|
|
|
|
if (!authenticated) {
|
|
return (
|
|
<Container size="narrow" className="py-12">
|
|
<MotionFade>
|
|
<Card className="border-border/70 bg-card/95 shadow-sm">
|
|
<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}
|
|
|
|
{resolvedSearchParams?.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}
|
|
|
|
{resolvedSearchParams?.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">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password" className="sr-only">
|
|
{copy.passwordLabel}
|
|
</Label>
|
|
<div className="relative">
|
|
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
name="password"
|
|
required
|
|
placeholder={copy.passwordLabel}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
disabled={!authConfigured || !basicConfigured || lockState.locked}
|
|
>
|
|
<LockKeyhole className="h-4 w-4" />
|
|
{copy.loginButton}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</MotionFade>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
const [maintenanceEnabled, categories, projects, mediaAssets] = await Promise.all([
|
|
getMaintenanceMode(),
|
|
getAdminPortfolioCategories(),
|
|
getAdminPortfolioProjects(),
|
|
getAdminMediaAssets(),
|
|
]);
|
|
|
|
const publishedProjects = projects.filter((project) => project.isPublished).length;
|
|
const draftProjects = projects.length - publishedProjects;
|
|
const activeCategories = categories.filter((category) => category.isActive).length;
|
|
const imageCount = mediaAssets.filter((asset) => asset.kind === "IMAGE").length;
|
|
|
|
return (
|
|
<AdminDashboardShell
|
|
copy={copy}
|
|
active="overview"
|
|
logoutAction={logoutAction}
|
|
headerTitle={copy.title}
|
|
headerDescription={copy.subtitle}
|
|
>
|
|
<div className="space-y-6">
|
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<MotionFade delay={0.05}>
|
|
<StatsCard
|
|
title={copy.portfolioOnline}
|
|
value={String(publishedProjects)}
|
|
icon={FolderKanban}
|
|
className="h-full"
|
|
footer={(
|
|
<div className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
|
<span>{copy.drafts}: {draftProjects}</span>
|
|
<span>{copy.activeCategories}: {activeCategories}</span>
|
|
</div>
|
|
)}
|
|
/>
|
|
</MotionFade>
|
|
|
|
<MotionFade delay={0.1}>
|
|
<StatsCard
|
|
title={copy.mediaImages}
|
|
value={String(imageCount)}
|
|
icon={ImageIcon}
|
|
description="Anzahl der Bilder innerhalb der Media Library."
|
|
className="h-full"
|
|
/>
|
|
</MotionFade>
|
|
|
|
<MotionFade delay={0.15}>
|
|
<StatsCard
|
|
title={copy.maintenanceStatus}
|
|
value={maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
|
icon={ShieldAlert}
|
|
className="h-full"
|
|
footer={(
|
|
<div className="flex items-center justify-between gap-3 text-sm">
|
|
<span className="text-muted-foreground">
|
|
{maintenanceEnabled
|
|
? "Website ist aktuell fuer Besucher gesperrt."
|
|
: "Website ist aktuell offen."}
|
|
</span>
|
|
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
|
|
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
/>
|
|
</MotionFade>
|
|
</section>
|
|
</div>
|
|
</AdminDashboardShell>
|
|
);
|
|
}
|