feat: complete MVP foundation auth and dashboard
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/.test-output
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
30
app/actions/auth.ts
Normal file
30
app/actions/auth.ts
Normal 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("/");
|
||||||
|
}
|
||||||
10
app/dashboard/analytics/page.tsx
Normal file
10
app/dashboard/analytics/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/dashboard/audits/page.tsx
Normal file
10
app/dashboard/audits/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/dashboard/blacklist/page.tsx
Normal file
10
app/dashboard/blacklist/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/dashboard/campaigns/page.tsx
Normal file
10
app/dashboard/campaigns/page.tsx
Normal 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
25
app/dashboard/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/dashboard/leads/page.tsx
Normal file
10
app/dashboard/leads/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/dashboard/outreach/page.tsx
Normal file
10
app/dashboard/outreach/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,63 +1,130 @@
|
|||||||
import { FileSearch, MailCheck, MapPinned, ShieldCheck } from "lucide-react";
|
import {
|
||||||
|
dashboardKpis,
|
||||||
const pipelineSteps = [
|
pipelineHealth,
|
||||||
{
|
pipelineStages,
|
||||||
title: "Kampagnen",
|
reviewQueue,
|
||||||
description: "Kategorie, PLZ, Radius und Lauf-Limits vorbereiten.",
|
} from "@/lib/dashboard-model";
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-dvh bg-background px-6 py-8">
|
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||||
<header className="flex flex-col gap-2 border-b pb-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="mt-2 text-3xl font-semibold tracking-normal">
|
||||||
|
Pipeline-Uebersicht
|
||||||
|
</h1>
|
||||||
|
<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">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
Interner Arbeitsbereich
|
Mock-Session aktiv
|
||||||
</p>
|
|
||||||
<h1 className="text-3xl font-semibold tracking-normal">
|
|
||||||
Dashboard-Platzhalter
|
|
||||||
</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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="grid gap-3 md:grid-cols-4">
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{pipelineSteps.map((step) => {
|
{dashboardKpis.map((kpi) => (
|
||||||
const Icon = step.icon;
|
<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 (
|
return (
|
||||||
<article
|
<article
|
||||||
className="rounded-lg border bg-card p-4 text-card-foreground"
|
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" />
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-sm font-medium">{step.title}</h2>
|
<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">
|
<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>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
10
app/dashboard/settings/page.tsx
Normal file
10
app/dashboard/settings/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import { LockKeyhole } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function LoginPage() {
|
import { AuthEntry } from "@/components/auth-entry";
|
||||||
return (
|
import { getCurrentMockSession } from "@/lib/mock-session";
|
||||||
<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">
|
export default async function LoginPage() {
|
||||||
<LockKeyhole className="mb-5 size-6 text-muted-foreground" />
|
const session = await getCurrentMockSession();
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">
|
|
||||||
Login-Platzhalter
|
if (session) {
|
||||||
</h1>
|
redirect("/dashboard");
|
||||||
<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
|
return <AuthEntry />;
|
||||||
bestehen.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
85
app/page.tsx
85
app/page.tsx
@@ -1,81 +1,14 @@
|
|||||||
import Link from "next/link";
|
import { redirect } from "next/navigation";
|
||||||
import { ArrowRight, FileText, LayoutDashboard, LockKeyhole } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { AuthEntry } from "@/components/auth-entry";
|
||||||
|
import { getCurrentMockSession } from "@/lib/mock-session";
|
||||||
|
|
||||||
export default function Home() {
|
export default async function Home() {
|
||||||
return (
|
const session = await getCurrentMockSession();
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
if (session) {
|
||||||
<Button asChild size="lg" className="justify-between">
|
redirect("/dashboard");
|
||||||
<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">
|
return <AuthEntry />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-1
|
id: TASK-1
|
||||||
title: Scaffold the Next.js MVP foundation
|
title: Scaffold the Next.js MVP foundation
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:12'
|
created_date: '2026-06-03 19:12'
|
||||||
updated_date: '2026-06-03 19:42'
|
updated_date: '2026-06-04 07:05'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- foundation
|
- 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.
|
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.
|
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.
|
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 -->
|
<!-- 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 -->
|
||||||
|
|||||||
@@ -21,5 +21,12 @@
|
|||||||
},
|
},
|
||||||
"menuColor": "default",
|
"menuColor": "default",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle",
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@shadcnblocks": {
|
||||||
|
"url": "https://shadcnblocks.com/r/{name}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
components/auth-entry.tsx
Normal file
82
components/auth-entry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/dashboard-placeholder-page.tsx
Normal file
21
components/dashboard-placeholder-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/dashboard-sidebar.tsx
Normal file
76
components/dashboard-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const eslintConfig = defineConfig([
|
|||||||
".next/**",
|
".next/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
|
".test-output/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
120
lib/dashboard-model.ts
Normal file
120
lib/dashboard-model.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
28
lib/dashboard-navigation.ts
Normal file
28
lib/dashboard-navigation.ts
Normal 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
47
lib/mock-auth.ts
Normal 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
7
lib/mock-session.ts
Normal 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
11
lib/proxy-auth.ts
Normal 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
5
lib/route-guards.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { MockSession } from "@/lib/mock-auth";
|
||||||
|
|
||||||
|
export function getDashboardRedirectPath(session: MockSession | null) {
|
||||||
|
return session ? null : "/";
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "tsc -p tsconfig.test.json && node --test .test-output/tests/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
21
proxy.ts
Normal file
21
proxy.ts
Normal 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*",
|
||||||
|
};
|
||||||
51
tests/dashboard-model.test.ts
Normal file
51
tests/dashboard-model.test.ts
Normal 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
71
tests/mock-auth.test.ts
Normal 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
26
tests/proxy-auth.test.ts
Normal 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);
|
||||||
|
});
|
||||||
18
tests/route-guards.test.ts
Normal file
18
tests/route-guards.test.ts
Normal 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
15
tsconfig.test.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user