feat: complete MVP foundation auth and dashboard

This commit is contained in:
2026-06-04 09:05:40 +02:00
parent 20615e12a1
commit df7a955736
32 changed files with 880 additions and 139 deletions

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@
# testing
/coverage
/.test-output
# next.js
/.next/

30
app/actions/auth.ts Normal file
View File

@@ -0,0 +1,30 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {
createClearedMockSessionCookie,
createMockSessionCookie,
} from "@/lib/mock-auth";
export async function signInMock() {
const cookieStore = await cookies();
cookieStore.set(createMockSessionCookie());
redirect("/dashboard");
}
export async function signUpMock() {
const cookieStore = await cookies();
cookieStore.set(createMockSessionCookie());
redirect("/dashboard");
}
export async function signOutMock() {
const cookieStore = await cookies();
cookieStore.set(createClearedMockSessionCookie());
redirect("/");
}

View File

@@ -0,0 +1,10 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
export default function AnalyticsPage() {
return (
<DashboardPlaceholderPage
description="Kampagnenmetriken und Rybbit-Daten folgen in TASK-17 und TASK-19."
title="Analytics"
/>
);
}

View File

@@ -0,0 +1,10 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
export default function AuditsPage() {
return (
<DashboardPlaceholderPage
description="Audit-Review, Screenshots und oeffentliche Freigaben folgen in TASK-12 und TASK-13."
title="Audits"
/>
);
}

View File

@@ -0,0 +1,10 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
export default function BlacklistPage() {
return (
<DashboardPlaceholderPage
description="Sperrlisten fuer Domains, E-Mails, Telefonnummern, Firmennamen und Place IDs folgen nach den Datenmodellen."
title="Blacklist"
/>
);
}

View File

@@ -0,0 +1,10 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
export default function CampaignsPage() {
return (
<DashboardPlaceholderPage
description="Kampagnen-Konfiguration, PLZ, Radius, Limits und Laufplanung folgen in TASK-5."
title="Campaigns"
/>
);
}

25
app/dashboard/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { DashboardSidebar } from "@/components/dashboard-sidebar";
import { getCurrentMockSession } from "@/lib/mock-session";
import { getDashboardRedirectPath } from "@/lib/route-guards";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getCurrentMockSession();
const redirectPath = getDashboardRedirectPath(session);
if (redirectPath || !session) {
redirect(redirectPath ?? "/");
}
return (
<div className="min-h-dvh bg-background md:flex">
<DashboardSidebar session={session} />
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
export default function LeadsPage() {
return (
<DashboardPlaceholderPage
description="Lead-Qualifikation, Dubletten und fehlende Kontaktdaten folgen in TASK-7."
title="Leads"
/>
);
}

View File

@@ -0,0 +1,10 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
export default function OutreachPage() {
return (
<DashboardPlaceholderPage
description="E-Mail-Entwuerfe, Telefon-Skripte und manuelle Versandfreigaben folgen in TASK-13 und TASK-14."
title="Outreach"
/>
);
}

View File

@@ -1,63 +1,130 @@
import { FileSearch, MailCheck, MapPinned, ShieldCheck } from "lucide-react";
const pipelineSteps = [
{
title: "Kampagnen",
description: "Kategorie, PLZ, Radius und Lauf-Limits vorbereiten.",
icon: MapPinned,
},
{
title: "Lead-Audit",
description: "Website-Potenzial, Screenshots und Quellen buendeln.",
icon: FileSearch,
},
{
title: "Freigabe",
description: "Audit-Seite, E-Mail und Telefon-Skript manuell pruefen.",
icon: ShieldCheck,
},
{
title: "Outreach",
description: "Freigegebene Kontakte senden und Antworten nachhalten.",
icon: MailCheck,
},
];
import {
dashboardKpis,
pipelineHealth,
pipelineStages,
reviewQueue,
} from "@/lib/dashboard-model";
export default function DashboardPage() {
return (
<main className="min-h-dvh bg-background px-6 py-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<header className="flex flex-col gap-2 border-b pb-6">
<main className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<header className="flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Interner Arbeitsbereich
</p>
<h1 className="text-3xl font-semibold tracking-normal">
Dashboard-Platzhalter
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
Pipeline-Uebersicht
</h1>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Hier entsteht der scanbare Funnel fuer Recherche, Audit, Review,
Versand und Follow-up.
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Recherche, Audit-Freigabe und Outreach bleiben eng gekoppelt:
wenige gute Leads, manuelle Pruefung, kein automatischer Versand.
</p>
</div>
<p className="text-sm font-medium text-muted-foreground">
Mock-Session aktiv
</p>
</header>
<section className="grid gap-3 md:grid-cols-4">
{pipelineSteps.map((step) => {
const Icon = step.icon;
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{dashboardKpis.map((kpi) => (
<article
className="rounded-lg border bg-card p-4 text-card-foreground"
key={kpi.label}
>
<p className="text-sm text-muted-foreground">{kpi.label}</p>
<p className="mt-3 text-3xl font-semibold tracking-normal">
{kpi.value}
</p>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
{kpi.detail}
</p>
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-4">
{pipelineStages.map((stage) => {
const Icon = stage.icon;
return (
<article
className="rounded-lg border bg-card p-4 text-card-foreground"
key={step.title}
key={stage.title}
>
<Icon className="mb-4 size-5 text-muted-foreground" />
<h2 className="text-sm font-medium">{step.title}</h2>
<div className="flex items-center justify-between gap-4">
<Icon className="size-5 text-muted-foreground" />
<span className="text-2xl font-semibold">{stage.count}</span>
</div>
<h2 className="mt-4 text-sm font-medium">{stage.title}</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{step.description}
{stage.description}
</p>
<p className="mt-4 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
{stage.meta}
</p>
</article>
);
})}
</section>
<section className="grid gap-3 lg:grid-cols-[1.45fr_0.55fr]">
<div className="rounded-lg border bg-card text-card-foreground">
<div className="border-b p-4">
<h2 className="text-base font-semibold tracking-normal">
Naechste Review-Schritte
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Alles bleibt an manuelle Freigabe gekoppelt.
</p>
</div>
<div className="divide-y">
{reviewQueue.map((item) => (
<article
className="grid gap-2 p-4 sm:grid-cols-[1fr_auto]"
key={`${item.title}-${item.company}`}
>
<div>
<h3 className="text-sm font-medium">{item.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{item.company}
</p>
</div>
<p className="max-w-sm text-sm leading-6 text-muted-foreground sm:text-right">
{item.detail}
</p>
</article>
))}
</div>
</div>
<div className="rounded-lg border bg-card p-4 text-card-foreground">
<h2 className="text-base font-semibold tracking-normal">
Betriebsmodus
</h2>
<div className="mt-4 grid gap-3">
{pipelineHealth.map((item) => {
const Icon = item.icon;
return (
<div
className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3"
key={item.label}
>
<span className="inline-flex items-center gap-2 text-sm font-medium">
<Icon className="size-4 text-muted-foreground" />
{item.label}
</span>
<span className="text-right text-sm text-muted-foreground">
{item.value}
</span>
</div>
);
})}
</div>
</div>
</section>
</div>
</main>
);

View File

@@ -0,0 +1,10 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
export default function SettingsPage() {
return (
<DashboardPlaceholderPage
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
title="Settings"
/>
);
}

View File

@@ -1,19 +1,14 @@
import { LockKeyhole } from "lucide-react";
import { redirect } from "next/navigation";
export default function LoginPage() {
return (
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-12">
<section className="w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground">
<LockKeyhole className="mb-5 size-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold tracking-normal">
Login-Platzhalter
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Better Auth wird in einem spaeteren Task angebunden. Bis dahin bleibt
diese Route als definierter Einstiegspunkt fuer den Admin-Login
bestehen.
</p>
</section>
</main>
);
import { AuthEntry } from "@/components/auth-entry";
import { getCurrentMockSession } from "@/lib/mock-session";
export default async function LoginPage() {
const session = await getCurrentMockSession();
if (session) {
redirect("/dashboard");
}
return <AuthEntry />;
}

View File

@@ -1,81 +1,14 @@
import Link from "next/link";
import { ArrowRight, FileText, LayoutDashboard, LockKeyhole } from "lucide-react";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { AuthEntry } from "@/components/auth-entry";
import { getCurrentMockSession } from "@/lib/mock-session";
export default function Home() {
return (
<main className="flex flex-1 bg-background">
<section className="mx-auto flex min-h-dvh w-full max-w-5xl flex-col justify-center gap-10 px-6 py-12">
<div className="max-w-3xl space-y-5">
<p className="text-sm font-medium text-muted-foreground">
WebDev Pipeline MVP
</p>
<h1 className="text-4xl font-semibold tracking-normal text-foreground sm:text-5xl">
Lokale Webdesign-Leads recherchieren, auditieren und respektvoll
kontaktieren.
</h1>
<p className="max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg">
Diese Foundation setzt die ersten App-Routen fuer Dashboard,
Anmeldung und oeffentliche Audit-Seiten auf. Die Integrationen
folgen in den naechsten Backlog-Tasks.
</p>
</div>
export default async function Home() {
const session = await getCurrentMockSession();
<div className="grid gap-3 sm:grid-cols-3">
<Button asChild size="lg" className="justify-between">
<Link href="/dashboard">
<span className="inline-flex items-center gap-2">
<LayoutDashboard />
Dashboard
</span>
<ArrowRight />
</Link>
</Button>
<Button asChild size="lg" variant="outline" className="justify-between">
<Link href="/login">
<span className="inline-flex items-center gap-2">
<LockKeyhole />
Login
</span>
<ArrowRight />
</Link>
</Button>
<Button asChild size="lg" variant="outline" className="justify-between">
<Link href="/audit/example">
<span className="inline-flex items-center gap-2">
<FileText />
Audit
</span>
<ArrowRight />
</Link>
</Button>
</div>
<dl className="grid gap-4 border-t pt-8 sm:grid-cols-3">
<div>
<dt className="text-sm font-medium text-foreground">Recherche</dt>
<dd className="mt-2 text-sm leading-6 text-muted-foreground">
Kampagnen, Places-Quellen und Lead-Qualitaet werden spaeter im
Dashboard gebuendelt.
</dd>
</div>
<div>
<dt className="text-sm font-medium text-foreground">Audit</dt>
<dd className="mt-2 text-sm leading-6 text-muted-foreground">
Oeffentliche Audit-Seiten starten als sichere Platzhalter ohne
freigegebene Inhalte.
</dd>
</div>
<div>
<dt className="text-sm font-medium text-foreground">Outreach</dt>
<dd className="mt-2 text-sm leading-6 text-muted-foreground">
Versand bleibt im MVP an manuelle Pruefung und Freigabe
gekoppelt.
</dd>
</div>
</dl>
</section>
</main>
);
if (session) {
redirect("/dashboard");
}
return <AuthEntry />;
}

View File

@@ -1,10 +1,10 @@
---
id: TASK-1
title: Scaffold the Next.js MVP foundation
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:12'
updated_date: '2026-06-03 19:42'
updated_date: '2026-06-04 07:05'
labels:
- mvp
- foundation
@@ -48,4 +48,16 @@ Set up the application foundation for the WebDev Pipeline MVP: Next.js App Route
Implemented pnpm-based Next.js MVP foundation on branch task-1-scaffold-foundation.
Verified pnpm install, pnpm lint, pnpm build, and local route smoke checks for /, /dashboard, /login, and /audit/example.
Note: pnpm requires approved build scripts for msw, sharp, and unrs-resolver, recorded in pnpm-workspace.yaml. Build needs network access for next/font/google unless fonts are later self-hosted.
Extending TASK-1 foundation with mock-cookie auth gate, sign in/sign up entry, protected dashboard sidebar shell, and pipeline overview per user request. TASK-1 remains In Progress until explicit manual confirmation.
Implemented mock-cookie auth extension: / and /login render sign in/sign up for guests, sign in/sign up set a secure httpOnly mock session cookie and redirect to /dashboard, sign out clears the cookie, dashboard routes are guarded by Next proxy plus dashboard layout guard, /audit/[slug] remains public. Added sidebar dashboard shell, placeholder dashboard child routes, pipeline overview, route guard/dashboard model/proxy behavior tests, and ESLint ignore for test build output. Verified pnpm test, pnpm lint, pnpm build with network access for Google Fonts, and browser smoke for guest auth, dashboard redirect, logout, public audit route, desktop layout, and mobile layout.
Final polish: replaced the hard-coded personal email in the mock session with matthias@webdev-pipeline.local. Re-ran pnpm test, pnpm lint, and pnpm build successfully; build still reports only the existing Next workspace-root warning and Node module.register deprecation warning.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped the Next.js MVP foundation extension: mock-cookie sign in/sign up, secure httpOnly mock session, protected dashboard via proxy and layout guard, public audit route preserved, sidebar dashboard shell with pipeline overview and placeholder child routes, plus behavior tests and verification scripts. Verified pnpm test, pnpm lint, pnpm build, and browser smoke for auth, dashboard, logout, audit, desktop, and mobile.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -21,5 +21,12 @@
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@shadcnblocks": {
"url": "https://shadcnblocks.com/r/{name}",
"headers": {
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
}
}
}
}

82
components/auth-entry.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { ArrowRight, LockKeyhole, UserPlus } from "lucide-react";
import { signInMock, signUpMock } from "@/app/actions/auth";
import { Button } from "@/components/ui/button";
export function AuthEntry() {
return (
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-10">
<section className="grid w-full max-w-5xl overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm md:grid-cols-[1.05fr_0.95fr]">
<div className="flex min-h-[520px] flex-col justify-between border-b p-6 md:border-b-0 md:border-r lg:p-8">
<div>
<div className="mb-8 inline-flex size-10 items-center justify-center rounded-lg border bg-background">
<LockKeyhole className="size-5" />
</div>
<p className="text-sm font-medium text-muted-foreground">
WebDev Pipeline MVP
</p>
<h1 className="mt-4 max-w-xl text-3xl font-semibold tracking-normal sm:text-4xl">
Lokale Webdesign-Leads recherchieren, auditieren und freigeben.
</h1>
<p className="mt-4 max-w-lg text-sm leading-6 text-muted-foreground sm:text-base">
Melde dich an, um Kampagnen, Lead-Qualitaet, Audit-Freigaben und
Outreach-Schritte in einem Arbeitsbereich zu steuern.
</p>
</div>
<dl className="grid gap-4 pt-8 sm:grid-cols-3">
{[
["Recherche", "Google Places Quellen und Kontaktluecken."],
["Audit", "Website-Potenzial und Review-Status."],
["Outreach", "Manuelle Freigabe vor Versand."],
].map(([label, value]) => (
<div key={label}>
<dt className="text-sm font-medium">{label}</dt>
<dd className="mt-1 text-sm leading-5 text-muted-foreground">
{value}
</dd>
</div>
))}
</dl>
</div>
<div className="flex flex-col justify-center p-6 lg:p-8">
<div className="mx-auto w-full max-w-sm">
<h2 className="text-2xl font-semibold tracking-normal">
Sign in oder sign up
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Die Authentifizierung ist in TASK-1 noch simuliert. Beide
Aktionen setzen eine lokale Mock-Session und leiten ins Dashboard.
</p>
<div className="mt-8 grid gap-3">
<form action={signInMock}>
<Button className="w-full justify-between" size="lg">
<span className="inline-flex items-center gap-2">
<LockKeyhole />
Sign in
</span>
<ArrowRight />
</Button>
</form>
<form action={signUpMock}>
<Button
className="w-full justify-between"
size="lg"
variant="outline"
>
<span className="inline-flex items-center gap-2">
<UserPlus />
Sign up
</span>
<ArrowRight />
</Button>
</form>
</div>
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,21 @@
export function DashboardPlaceholderPage({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<main className="px-4 py-5 sm:px-6 lg:px-8">
<section className="mx-auto max-w-7xl rounded-lg border bg-card p-5 text-card-foreground">
<p className="text-sm font-medium text-muted-foreground">
WebDev Pipeline
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-normal">{title}</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
{description}
</p>
</section>
</main>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LogOut } from "lucide-react";
import { signOutMock } from "@/app/actions/auth";
import { Button } from "@/components/ui/button";
import { dashboardNavigation } from "@/lib/dashboard-navigation";
import type { MockSession } from "@/lib/mock-auth";
import { cn } from "@/lib/utils";
export function DashboardSidebar({ session }: { session: MockSession }) {
const pathname = usePathname();
return (
<aside className="flex w-full shrink-0 flex-col border-b bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-72 md:border-b-0 md:border-r">
<div className="flex h-16 items-center gap-3 border-b px-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
W
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">WebDev Pipeline</p>
<p className="truncate text-xs text-muted-foreground">
Akquise Workspace
</p>
</div>
</div>
<nav
className="flex gap-1 overflow-x-auto p-3 md:grid md:overflow-visible"
aria-label="Dashboard navigation"
>
{dashboardNavigation.map((item) => {
const Icon = item.icon;
const isActive =
item.href === "/dashboard"
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
aria-current={isActive ? "page" : undefined}
className={cn(
"flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
isActive
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
href={item.href}
key={item.href}
>
<Icon className="size-4" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="border-t p-3 md:mt-auto">
<div className="mb-3 rounded-lg border bg-background p-3 md:block">
<p className="truncate text-sm font-medium">{session.name}</p>
<p className="truncate text-xs text-muted-foreground">
{session.email}
</p>
</div>
<form action={signOutMock}>
<Button className="w-full justify-start" variant="outline">
<LogOut />
Sign out
</Button>
</form>
</div>
</aside>
);
}

View File

@@ -11,6 +11,7 @@ const eslintConfig = defineConfig([
".next/**",
"out/**",
"build/**",
".test-output/**",
"next-env.d.ts",
]),
]);

120
lib/dashboard-model.ts Normal file
View File

@@ -0,0 +1,120 @@
import {
Gauge,
MailCheck,
MapPinned,
ShieldCheck,
UsersRound,
type LucideIcon,
} from "lucide-react";
export { dashboardNavigation } from "./dashboard-navigation";
export type PipelineStage = {
title: string;
description: string;
count: number;
meta: string;
icon: LucideIcon;
};
export type DashboardKpi = {
label: string;
value: string;
detail: string;
};
export type ReviewQueueItem = {
title: string;
company: string;
detail: string;
};
export const pipelineStages: PipelineStage[] = [
{
title: "Kampagnen",
description: "Aktive Suchlaeufe nach Kategorie, PLZ und Radius.",
count: 3,
meta: "1 Lauf heute geplant",
icon: MapPinned,
},
{
title: "Lead-Recherche",
description: "Neue Places-Quellen, Kontaktluecken und Dubletten.",
count: 18,
meta: "5 Leads brauchen E-Mail-Quelle",
icon: UsersRound,
},
{
title: "Audit-Freigabe",
description: "Interne Audits warten auf manuelle Pruefung.",
count: 6,
meta: "2 Seiten bereit zur Veroeffentlichung",
icon: ShieldCheck,
},
{
title: "Outreach",
description: "Freigegebene E-Mails und Telefon-Skripte.",
count: 4,
meta: "Keine automatische Kontaktaufnahme",
icon: MailCheck,
},
];
export const dashboardKpis: DashboardKpi[] = [
{
label: "Neue Leads",
value: "18",
detail: "aus 3 aktiven Kampagnen",
},
{
label: "Audit-Entwuerfe",
value: "6",
detail: "manuelle Freigabe offen",
},
{
label: "Outreach bereit",
value: "4",
detail: "E-Mail und Telefon-Skript",
},
{
label: "Antworten",
value: "2",
detail: "manuell nachzutragen",
},
];
export const reviewQueue: ReviewQueueItem[] = [
{
title: "Audit-Freigabe pruefen",
company: "Malerbetrieb Klein",
detail: "Mobile Kontaktfuehrung und lokaler CTA fehlen.",
},
{
title: "Kontaktstrategie bestaetigen",
company: "Physio am Park",
detail: "Telefon zuerst, E-Mail nach persoenlicher Einordnung.",
},
{
title: "Follow-up vormerken",
company: "Tischlerei Weber",
detail: "Respektvolles Follow-up in 5 Tagen, kein Autoversand.",
},
];
export const pipelineHealth = [
{
label: "Recherche",
value: "hoch",
icon: Gauge,
},
{
label: "Freigabe",
value: "manuell",
icon: ShieldCheck,
},
{
label: "Versand",
value: "gesperrt bis Review",
icon: MailCheck,
},
];

View File

@@ -0,0 +1,28 @@
import {
BarChart3,
FileSearch,
LayoutDashboard,
MailCheck,
MapPinned,
OctagonMinus,
Settings,
UsersRound,
type LucideIcon,
} from "lucide-react";
export type DashboardNavigationItem = {
label: string;
href: string;
icon: LucideIcon;
};
export const dashboardNavigation: DashboardNavigationItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Campaigns", href: "/dashboard/campaigns", icon: MapPinned },
{ label: "Leads", href: "/dashboard/leads", icon: UsersRound },
{ label: "Audits", href: "/dashboard/audits", icon: FileSearch },
{ label: "Outreach", href: "/dashboard/outreach", icon: MailCheck },
{ label: "Analytics", href: "/dashboard/analytics", icon: BarChart3 },
{ label: "Blacklist", href: "/dashboard/blacklist", icon: OctagonMinus },
{ label: "Settings", href: "/dashboard/settings", icon: Settings },
];

47
lib/mock-auth.ts Normal file
View File

@@ -0,0 +1,47 @@
export const MOCK_SESSION_COOKIE_NAME = "webdev_pipeline_mock_session";
export const MOCK_SESSION_COOKIE_VALUE = "mock-admin";
export type MockCookieStore = {
get: (name: string) => { name: string; value: string } | undefined;
};
export type MockSession = {
name: string;
email: string;
};
export const MOCK_ADMIN_SESSION: MockSession = {
name: "Matthias Meister",
email: "matthias@webdev-pipeline.local",
};
export function hasMockSession(cookieStore: MockCookieStore) {
return (
cookieStore.get(MOCK_SESSION_COOKIE_NAME)?.value === MOCK_SESSION_COOKIE_VALUE
);
}
export function getMockSession(cookieStore: MockCookieStore) {
return hasMockSession(cookieStore) ? MOCK_ADMIN_SESSION : null;
}
export function createMockSessionCookie() {
return {
name: MOCK_SESSION_COOKIE_NAME,
value: MOCK_SESSION_COOKIE_VALUE,
httpOnly: true,
sameSite: "lax" as const,
secure: true,
path: "/",
maxAge: 60 * 60 * 24 * 7,
};
}
export function createClearedMockSessionCookie() {
return {
name: MOCK_SESSION_COOKIE_NAME,
value: "",
path: "/",
maxAge: 0,
};
}

7
lib/mock-session.ts Normal file
View File

@@ -0,0 +1,7 @@
import { cookies } from "next/headers";
import { getMockSession } from "@/lib/mock-auth";
export async function getCurrentMockSession() {
return getMockSession(await cookies());
}

11
lib/proxy-auth.ts Normal file
View File

@@ -0,0 +1,11 @@
import { MOCK_SESSION_COOKIE_VALUE } from "./mock-auth";
export function shouldRedirectDashboardRequest(
pathname: string,
sessionCookieValue: string | undefined,
) {
return (
pathname.startsWith("/dashboard") &&
sessionCookieValue !== MOCK_SESSION_COOKIE_VALUE
);
}

5
lib/route-guards.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { MockSession } from "@/lib/mock-auth";
export function getDashboardRedirectPath(session: MockSession | null) {
return session ? null : "/";
}

View File

@@ -7,7 +7,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "tsc -p tsconfig.test.json && node --test .test-output/tests/*.test.js"
},
"dependencies": {
"class-variance-authority": "^0.7.1",

21
proxy.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextResponse, type NextRequest } from "next/server";
import { MOCK_SESSION_COOKIE_NAME } from "@/lib/mock-auth";
import { shouldRedirectDashboardRequest } from "@/lib/proxy-auth";
export function proxy(request: NextRequest) {
if (
shouldRedirectDashboardRequest(
request.nextUrl.pathname,
request.cookies.get(MOCK_SESSION_COOKIE_NAME)?.value,
)
) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: "/dashboard/:path*",
};

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
dashboardKpis,
dashboardNavigation,
pipelineStages,
reviewQueue,
} from "../lib/dashboard-model";
type NavigationItem = { label: string; href: string };
type PipelineStage = { title: string; count: number };
type ReviewQueueItem = { title: string };
test("dashboardNavigation contains the expected sidebar routes in order", () => {
assert.deepEqual(
dashboardNavigation.map((item: NavigationItem) => [item.label, item.href]),
[
["Dashboard", "/dashboard"],
["Campaigns", "/dashboard/campaigns"],
["Leads", "/dashboard/leads"],
["Audits", "/dashboard/audits"],
["Outreach", "/dashboard/outreach"],
["Analytics", "/dashboard/analytics"],
["Blacklist", "/dashboard/blacklist"],
["Settings", "/dashboard/settings"],
],
);
});
test("pipelineStages keep the first-screen workflow focused on pipeline overview", () => {
assert.deepEqual(
pipelineStages.map((stage: PipelineStage) => stage.title),
["Kampagnen", "Lead-Recherche", "Audit-Freigabe", "Outreach"],
);
assert.equal(
pipelineStages.every((stage: PipelineStage) => stage.count >= 0),
true,
);
});
test("dashboardKpis and reviewQueue expose the above-the-fold dashboard summary", () => {
assert.equal(dashboardKpis.length, 4);
assert.equal(reviewQueue.length, 3);
assert.equal(
reviewQueue.some((item: ReviewQueueItem) =>
item.title.includes("Audit-Freigabe"),
),
true,
);
});

71
tests/mock-auth.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MOCK_SESSION_COOKIE_NAME,
MOCK_SESSION_COOKIE_VALUE,
createClearedMockSessionCookie,
createMockSessionCookie,
getMockSession,
hasMockSession,
} from "../lib/mock-auth";
type CookieLookupName = string;
test("hasMockSession returns true only for the expected mock session cookie", () => {
assert.equal(
hasMockSession({
get: (name: CookieLookupName) =>
name === MOCK_SESSION_COOKIE_NAME
? { name, value: MOCK_SESSION_COOKIE_VALUE }
: undefined,
}),
true,
);
assert.equal(
hasMockSession({
get: () => ({ name: MOCK_SESSION_COOKIE_NAME, value: "wrong" }),
}),
false,
);
assert.equal(hasMockSession({ get: () => undefined }), false);
});
test("createMockSessionCookie creates an http-only lax root cookie", () => {
assert.deepEqual(createMockSessionCookie(), {
name: MOCK_SESSION_COOKIE_NAME,
value: MOCK_SESSION_COOKIE_VALUE,
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
});
test("getMockSession returns the simulated admin user only when the cookie is valid", () => {
const validStore = {
get: (name: CookieLookupName) =>
name === MOCK_SESSION_COOKIE_NAME
? { name, value: MOCK_SESSION_COOKIE_VALUE }
: undefined,
};
assert.deepEqual(getMockSession(validStore), {
name: "Matthias Meister",
email: "matthias@webdev-pipeline.local",
});
assert.equal(getMockSession({ get: () => undefined }), null);
});
test("createClearedMockSessionCookie expires the mock session at the root path", () => {
assert.deepEqual(createClearedMockSessionCookie(), {
name: MOCK_SESSION_COOKIE_NAME,
value: "",
path: "/",
maxAge: 0,
});
});

26
tests/proxy-auth.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldRedirectDashboardRequest } from "../lib/proxy-auth";
test("shouldRedirectDashboardRequest protects dashboard paths without a valid mock cookie", () => {
assert.equal(
shouldRedirectDashboardRequest("/dashboard", undefined),
true,
);
assert.equal(
shouldRedirectDashboardRequest("/dashboard/leads", "wrong"),
true,
);
});
test("shouldRedirectDashboardRequest allows valid mock sessions and non-dashboard paths", () => {
assert.equal(
shouldRedirectDashboardRequest(
"/dashboard",
"mock-admin",
),
false,
);
assert.equal(shouldRedirectDashboardRequest("/audit/example", undefined), false);
});

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getDashboardRedirectPath } from "../lib/route-guards";
test("getDashboardRedirectPath sends guests back to the auth entry", () => {
assert.equal(getDashboardRedirectPath(null), "/");
});
test("getDashboardRedirectPath lets authenticated mock users stay on dashboard", () => {
assert.equal(
getDashboardRedirectPath({
name: "Matthias Meister",
email: "matthias@webdev-pipeline.local",
}),
null,
);
});

15
tsconfig.test.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": false,
"incremental": false,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"outDir": ".test-output",
"rootDir": ".",
"types": ["node"],
"verbatimModuleSyntax": false
},
"include": ["lib/**/*.ts", "tests/**/*.test.ts"]
}