From df7a955736d44426679f8f621c9295d2f1304798 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Thu, 4 Jun 2026 09:05:40 +0200 Subject: [PATCH] feat: complete MVP foundation auth and dashboard --- .gitignore | 1 + app/actions/auth.ts | 30 ++++ app/dashboard/analytics/page.tsx | 10 ++ app/dashboard/audits/page.tsx | 10 ++ app/dashboard/blacklist/page.tsx | 10 ++ app/dashboard/campaigns/page.tsx | 10 ++ app/dashboard/layout.tsx | 25 +++ app/dashboard/leads/page.tsx | 10 ++ app/dashboard/outreach/page.tsx | 10 ++ app/dashboard/page.tsx | 151 +++++++++++++----- app/dashboard/settings/page.tsx | 10 ++ app/login/page.tsx | 29 ++-- app/page.tsx | 85 ++-------- ...1 - Scaffold-the-Next.js-MVP-foundation.md | 16 +- components.json | 9 +- components/auth-entry.tsx | 82 ++++++++++ components/dashboard-placeholder-page.tsx | 21 +++ components/dashboard-sidebar.tsx | 76 +++++++++ eslint.config.mjs | 1 + lib/dashboard-model.ts | 120 ++++++++++++++ lib/dashboard-navigation.ts | 28 ++++ lib/mock-auth.ts | 47 ++++++ lib/mock-session.ts | 7 + lib/proxy-auth.ts | 11 ++ lib/route-guards.ts | 5 + package.json | 3 +- proxy.ts | 21 +++ tests/dashboard-model.test.ts | 51 ++++++ tests/mock-auth.test.ts | 71 ++++++++ tests/proxy-auth.test.ts | 26 +++ tests/route-guards.test.ts | 18 +++ tsconfig.test.json | 15 ++ 32 files changed, 880 insertions(+), 139 deletions(-) create mode 100644 app/actions/auth.ts create mode 100644 app/dashboard/analytics/page.tsx create mode 100644 app/dashboard/audits/page.tsx create mode 100644 app/dashboard/blacklist/page.tsx create mode 100644 app/dashboard/campaigns/page.tsx create mode 100644 app/dashboard/layout.tsx create mode 100644 app/dashboard/leads/page.tsx create mode 100644 app/dashboard/outreach/page.tsx create mode 100644 app/dashboard/settings/page.tsx create mode 100644 components/auth-entry.tsx create mode 100644 components/dashboard-placeholder-page.tsx create mode 100644 components/dashboard-sidebar.tsx create mode 100644 lib/dashboard-model.ts create mode 100644 lib/dashboard-navigation.ts create mode 100644 lib/mock-auth.ts create mode 100644 lib/mock-session.ts create mode 100644 lib/proxy-auth.ts create mode 100644 lib/route-guards.ts create mode 100644 proxy.ts create mode 100644 tests/dashboard-model.test.ts create mode 100644 tests/mock-auth.test.ts create mode 100644 tests/proxy-auth.test.ts create mode 100644 tests/route-guards.test.ts create mode 100644 tsconfig.test.json diff --git a/.gitignore b/.gitignore index 9c7b043..dfea3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # testing /coverage +/.test-output # next.js /.next/ diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000..ea39d1c --- /dev/null +++ b/app/actions/auth.ts @@ -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("/"); +} diff --git a/app/dashboard/analytics/page.tsx b/app/dashboard/analytics/page.tsx new file mode 100644 index 0000000..f50c375 --- /dev/null +++ b/app/dashboard/analytics/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; + +export default function AnalyticsPage() { + return ( + + ); +} diff --git a/app/dashboard/audits/page.tsx b/app/dashboard/audits/page.tsx new file mode 100644 index 0000000..05097e7 --- /dev/null +++ b/app/dashboard/audits/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; + +export default function AuditsPage() { + return ( + + ); +} diff --git a/app/dashboard/blacklist/page.tsx b/app/dashboard/blacklist/page.tsx new file mode 100644 index 0000000..aa96528 --- /dev/null +++ b/app/dashboard/blacklist/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; + +export default function BlacklistPage() { + return ( + + ); +} diff --git a/app/dashboard/campaigns/page.tsx b/app/dashboard/campaigns/page.tsx new file mode 100644 index 0000000..0ccafdb --- /dev/null +++ b/app/dashboard/campaigns/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; + +export default function CampaignsPage() { + return ( + + ); +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..50ed7f7 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} diff --git a/app/dashboard/leads/page.tsx b/app/dashboard/leads/page.tsx new file mode 100644 index 0000000..434a016 --- /dev/null +++ b/app/dashboard/leads/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; + +export default function LeadsPage() { + return ( + + ); +} diff --git a/app/dashboard/outreach/page.tsx b/app/dashboard/outreach/page.tsx new file mode 100644 index 0000000..703f64c --- /dev/null +++ b/app/dashboard/outreach/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; + +export default function OutreachPage() { + return ( + + ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 148df11..6d524e6 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -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 ( -
-
-
+
+
+
+
+

+ Interner Arbeitsbereich +

+

+ Pipeline-Uebersicht +

+

+ Recherche, Audit-Freigabe und Outreach bleiben eng gekoppelt: + wenige gute Leads, manuelle Pruefung, kein automatischer Versand. +

+

- Interner Arbeitsbereich -

-

- Dashboard-Platzhalter -

-

- Hier entsteht der scanbare Funnel fuer Recherche, Audit, Review, - Versand und Follow-up. + Mock-Session aktiv

-
- {pipelineSteps.map((step) => { - const Icon = step.icon; +
+ {dashboardKpis.map((kpi) => ( +
+

{kpi.label}

+

+ {kpi.value} +

+

+ {kpi.detail} +

+
+ ))} +
+ +
+ {pipelineStages.map((stage) => { + const Icon = stage.icon; return (
- -

{step.title}

+
+ + {stage.count} +
+

{stage.title}

- {step.description} + {stage.description} +

+

+ {stage.meta}

); })}
+ +
+
+
+

+ Naechste Review-Schritte +

+

+ Alles bleibt an manuelle Freigabe gekoppelt. +

+
+
+ {reviewQueue.map((item) => ( +
+
+

{item.title}

+

+ {item.company} +

+
+

+ {item.detail} +

+
+ ))} +
+
+ +
+

+ Betriebsmodus +

+
+ {pipelineHealth.map((item) => { + const Icon = item.icon; + + return ( +
+ + + {item.label} + + + {item.value} + +
+ ); + })} +
+
+
); diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..4309962 --- /dev/null +++ b/app/dashboard/settings/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; + +export default function SettingsPage() { + return ( + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 05cb3c7..26af363 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,19 +1,14 @@ -import { LockKeyhole } from "lucide-react"; +import { redirect } from "next/navigation"; -export default function LoginPage() { - return ( -
-
- -

- Login-Platzhalter -

-

- Better Auth wird in einem spaeteren Task angebunden. Bis dahin bleibt - diese Route als definierter Einstiegspunkt fuer den Admin-Login - bestehen. -

-
-
- ); +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 ; } diff --git a/app/page.tsx b/app/page.tsx index 98dd6ad..bd6daa4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ( -
-
-
-

- WebDev Pipeline MVP -

-

- Lokale Webdesign-Leads recherchieren, auditieren und respektvoll - kontaktieren. -

-

- Diese Foundation setzt die ersten App-Routen fuer Dashboard, - Anmeldung und oeffentliche Audit-Seiten auf. Die Integrationen - folgen in den naechsten Backlog-Tasks. -

-
+export default async function Home() { + const session = await getCurrentMockSession(); -
- - - -
+ if (session) { + redirect("/dashboard"); + } -
-
-
Recherche
-
- Kampagnen, Places-Quellen und Lead-Qualitaet werden spaeter im - Dashboard gebuendelt. -
-
-
-
Audit
-
- Oeffentliche Audit-Seiten starten als sichere Platzhalter ohne - freigegebene Inhalte. -
-
-
-
Outreach
-
- Versand bleibt im MVP an manuelle Pruefung und Freigabe - gekoppelt. -
-
-
-
-
- ); + return ; } diff --git a/backlog/tasks/task-1 - Scaffold-the-Next.js-MVP-foundation.md b/backlog/tasks/task-1 - Scaffold-the-Next.js-MVP-foundation.md index f057c2a..3b266e2 100644 --- a/backlog/tasks/task-1 - Scaffold-the-Next.js-MVP-foundation.md +++ b/backlog/tasks/task-1 - Scaffold-the-Next.js-MVP-foundation.md @@ -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. + +## Final Summary + + +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. + diff --git a/components.json b/components.json index 02e61e0..020e29a 100644 --- a/components.json +++ b/components.json @@ -21,5 +21,12 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": { + "@shadcnblocks": { + "url": "https://shadcnblocks.com/r/{name}", + "headers": { + "Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}" + } + } + } } diff --git a/components/auth-entry.tsx b/components/auth-entry.tsx new file mode 100644 index 0000000..e3a5cd4 --- /dev/null +++ b/components/auth-entry.tsx @@ -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 ( +
+
+
+
+
+ +
+

+ WebDev Pipeline MVP +

+

+ Lokale Webdesign-Leads recherchieren, auditieren und freigeben. +

+

+ Melde dich an, um Kampagnen, Lead-Qualitaet, Audit-Freigaben und + Outreach-Schritte in einem Arbeitsbereich zu steuern. +

+
+ +
+ {[ + ["Recherche", "Google Places Quellen und Kontaktluecken."], + ["Audit", "Website-Potenzial und Review-Status."], + ["Outreach", "Manuelle Freigabe vor Versand."], + ].map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+
+ +
+
+

+ Sign in oder sign up +

+

+ Die Authentifizierung ist in TASK-1 noch simuliert. Beide + Aktionen setzen eine lokale Mock-Session und leiten ins Dashboard. +

+ +
+
+ +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/components/dashboard-placeholder-page.tsx b/components/dashboard-placeholder-page.tsx new file mode 100644 index 0000000..494071f --- /dev/null +++ b/components/dashboard-placeholder-page.tsx @@ -0,0 +1,21 @@ +export function DashboardPlaceholderPage({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+
+

+ WebDev Pipeline +

+

{title}

+

+ {description} +

+
+
+ ); +} diff --git a/components/dashboard-sidebar.tsx b/components/dashboard-sidebar.tsx new file mode 100644 index 0000000..ce8579d --- /dev/null +++ b/components/dashboard-sidebar.tsx @@ -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 ( + + ); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..f3429ea 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + ".test-output/**", "next-env.d.ts", ]), ]); diff --git a/lib/dashboard-model.ts b/lib/dashboard-model.ts new file mode 100644 index 0000000..a08417f --- /dev/null +++ b/lib/dashboard-model.ts @@ -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, + }, +]; diff --git a/lib/dashboard-navigation.ts b/lib/dashboard-navigation.ts new file mode 100644 index 0000000..c3eb783 --- /dev/null +++ b/lib/dashboard-navigation.ts @@ -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 }, +]; diff --git a/lib/mock-auth.ts b/lib/mock-auth.ts new file mode 100644 index 0000000..efc83cf --- /dev/null +++ b/lib/mock-auth.ts @@ -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, + }; +} diff --git a/lib/mock-session.ts b/lib/mock-session.ts new file mode 100644 index 0000000..fc89021 --- /dev/null +++ b/lib/mock-session.ts @@ -0,0 +1,7 @@ +import { cookies } from "next/headers"; + +import { getMockSession } from "@/lib/mock-auth"; + +export async function getCurrentMockSession() { + return getMockSession(await cookies()); +} diff --git a/lib/proxy-auth.ts b/lib/proxy-auth.ts new file mode 100644 index 0000000..43a0230 --- /dev/null +++ b/lib/proxy-auth.ts @@ -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 + ); +} diff --git a/lib/route-guards.ts b/lib/route-guards.ts new file mode 100644 index 0000000..1951fc6 --- /dev/null +++ b/lib/route-guards.ts @@ -0,0 +1,5 @@ +import type { MockSession } from "@/lib/mock-auth"; + +export function getDashboardRedirectPath(session: MockSession | null) { + return session ? null : "/"; +} diff --git a/package.json b/package.json index e7b4742..b42fa04 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..5733a28 --- /dev/null +++ b/proxy.ts @@ -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*", +}; diff --git a/tests/dashboard-model.test.ts b/tests/dashboard-model.test.ts new file mode 100644 index 0000000..2325eca --- /dev/null +++ b/tests/dashboard-model.test.ts @@ -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, + ); +}); diff --git a/tests/mock-auth.test.ts b/tests/mock-auth.test.ts new file mode 100644 index 0000000..f669fb9 --- /dev/null +++ b/tests/mock-auth.test.ts @@ -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, + }); +}); diff --git a/tests/proxy-auth.test.ts b/tests/proxy-auth.test.ts new file mode 100644 index 0000000..a1f212f --- /dev/null +++ b/tests/proxy-auth.test.ts @@ -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); +}); diff --git a/tests/route-guards.test.ts b/tests/route-guards.test.ts new file mode 100644 index 0000000..ece6ae7 --- /dev/null +++ b/tests/route-guards.test.ts @@ -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, + ); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..54655c4 --- /dev/null +++ b/tsconfig.test.json @@ -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"] +}