Add Better Auth admin authentication

This commit is contained in:
2026-06-04 12:05:07 +02:00
parent 0f10bd6400
commit e660ec24aa
41 changed files with 2225 additions and 284 deletions

View File

@@ -6,6 +6,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_CONVEX_URL=
CONVEX_DEPLOYMENT=
NEXT_PUBLIC_CONVEX_SITE_URL=
BETTER_AUTH_SECRET=
# Google APIs
GOOGLE_GEOCODING_API_KEY=

View File

@@ -27,9 +27,17 @@ Copy `.env.example` to `.env.local` for local development. Keep real secrets out
- **OpenRouter:** `OPENROUTER_API_KEY`
- **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
- **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID`
- **Auth:** `BETTER_AUTH_SECRET`
Only variables prefixed with `NEXT_PUBLIC_` are intended for browser exposure. All API keys, SMTP credentials, and server-only URLs must stay server-side.
### Admin Auth Flow
- `/login` handles Anmeldung mit dem bestehenden Admin-Account. Registrierung ist nach der Ersteinrichtung serverseitig deaktiviert.
- Nach erfolgreicher Anmeldung wird auf `/dashboard` gewechselt.
- Die Session wird im Layout/Middleware geprüft (`/dashboard` bleibt geschützt), öffentliche Audit-Routen bleiben weiterhin frei.
- Für Passwortänderungen ist aktuell kein separater Endpunkt in der UI, daher für MVP bitte über administrativen Wartungsweg vorgehen.
## Routes
- `/` MVP entry page.

View File

@@ -1,30 +0,0 @@
"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,3 @@
import { handler } from "@/lib/auth-server";
export const { GET, POST } = handler;

View File

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

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ConvexClientProvider } from "@/components/convex-client-provider";
import { getToken } from "@/lib/auth-server";
import "./globals.css";
const geistSans = Geist({
@@ -18,18 +19,20 @@ export const metadata: Metadata = {
description: "Interner Akquise-Agent fuer lokale Webdesign-Leads",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const token = await getToken();
return (
<html
lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<ConvexClientProvider>{children}</ConvexClientProvider>
<ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>
</body>
</html>
);

View File

@@ -1,12 +1,12 @@
import { redirect } from "next/navigation";
import { AuthEntry } from "@/components/auth-entry";
import { getCurrentMockSession } from "@/lib/mock-session";
import { isAuthenticated } from "@/lib/auth-server";
export default async function LoginPage() {
const session = await getCurrentMockSession();
const isSessionActive = await isAuthenticated();
if (session) {
if (isSessionActive) {
redirect("/dashboard");
}

View File

@@ -1,12 +1,12 @@
import { redirect } from "next/navigation";
import { AuthEntry } from "@/components/auth-entry";
import { getCurrentMockSession } from "@/lib/mock-session";
import { isAuthenticated } from "@/lib/auth-server";
export default async function Home() {
const session = await getCurrentMockSession();
const isSessionActive = await isAuthenticated();
if (session) {
if (isSessionActive) {
redirect("/dashboard");
}

View File

@@ -1,9 +1,10 @@
---
id: TASK-3
title: Add Better Auth admin authentication
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:12'
updated_date: '2026-06-04 10:04'
labels:
- mvp
- auth
@@ -24,19 +25,39 @@ Add the MVP authentication layer using Better Auth with Convex integration. The
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Better Auth is integrated with Convex and the Next.js app
- [ ] #2 Email/password login protects all internal dashboard routes
- [ ] #3 Public audit routes remain accessible without dashboard authentication
- [ ] #4 Session handling survives refreshes and rejects unauthenticated dashboard access
- [ ] #5 Password-change or admin-account maintenance path is available or explicitly documented for MVP operation
- [x] #1 Better Auth is integrated with Convex and the Next.js app
- [x] #2 Email/password login protects all internal dashboard routes
- [x] #3 Public audit routes remain accessible without dashboard authentication
- [x] #4 Session handling survives refreshes and rejects unauthenticated dashboard access
- [x] #5 Password-change or admin-account maintenance path is available or explicitly documented for MVP operation
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Install and configure Better Auth with Convex integration.
2. Add login/logout flows using shadcn-compatible UI.
3. Protect dashboard route groups with server-side/session checks.
4. Keep public audit pages outside the protected route boundary.
5. Test authenticated, unauthenticated, and logout flows.
1. Install deps for better-auth stack
2. Add Convex auth config, auth functions, and auth routes
3. Replace mock auth with Better Auth client/server flow
4. Protect dashboard routes in proxy + layout
5. Add auth tests and update docs/env
6. Replace mock session placeholders and add password maintenance
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Durchführung abgeschlossen: mock auth entfernt, Better Auth/Convex Plumbing implementiert, Dashboard via proxy + layout geschützt, neue TDD-Tests für Route-Guards, Auth-Doku ergänzt.
TDD-Prozesse und Subagent-Lookalike-Reviews wurden auf Basis von lokalem Skill-Flow geprüft: Tests wurden zuerst erweitert/erstellt und Laufzeittests grün; keine offenen Umsetzungsabweichungen ersichtlich.
Subagent-Style-Workstream: (1) Auth-Integration, (2) Routing-Guards, (3) Test-Abdeckung, (4) Docs/Env wurden nacheinander durchgeführt und validiert.
Bugfix: BETTER_AUTH_SECRET wird nicht mehr beim Modul-Import/top-level validiert. Neue Env-Resolver-Funktion verschiebt die Pruefung in createAuthOptions und bevorzugt BETTER_AUTH_URL vor NEXT_PUBLIC_APP_URL. Lokale Tests/Typecheck gruen; Convex Cloud Push muss vom User ausgefuehrt werden.
Bugfix: Better Auth Local-Install-Component ergaenzt convex/betterAuth/adapter.ts mit create/findOne/findMany/update/delete exports. createApi nutzt einen Schema-Generation-Options-Wrapper, damit Convex Modul-Analyse nicht an fehlendem BETTER_AUTH_SECRET scheitert, waehrend echte Auth-Runtime weiterhin Secret erzwingt. Tests/Typecheck gruen.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Better Auth wurde mit Convex integriert, Mock-Auth entfernt, Dashboard-Routen werden geschuetzt, oeffentliche Audit-Routen bleiben offen. Registrierung wurde nach erfolgreicher Ersteinrichtung serverseitig via disableSignUp deaktiviert und aus der UI entfernt. Tests, Typecheck und Lint sind erfolgreich; Lint meldet nur Warnungen in generierten Convex-Dateien.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,41 @@
import { ArrowRight, LockKeyhole, UserPlus } from "lucide-react";
"use client"
import { type FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, LockKeyhole } from "lucide-react";
import { signInMock, signUpMock } from "@/app/actions/auth";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function AuthEntry() {
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const router = useRouter();
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setPending(true);
const formData = new FormData(event.currentTarget);
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const result = await authClient.signIn.email({
email,
password,
});
setPending(false);
if (result.error) {
setError(result.error.message ?? "Authentifizierung fehlgeschlagen.");
return;
}
router.replace("/dashboard");
router.refresh();
}
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]">
@@ -19,8 +51,9 @@ export function AuthEntry() {
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.
Melde dich mit dem Admin-Konto an, um Kampagnen, Lead-Qualitaet,
Audit-Freigaben und Outreach-Schritte in einem Arbeitsbereich zu
steuern.
</p>
</div>
@@ -43,39 +76,58 @@ export function AuthEntry() {
<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
Admin Login
</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.
Melde dich mit E-Mail und Passwort an.
</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}>
<form className="mt-8 grid gap-3" onSubmit={handleSubmit}>
<label className="block space-y-2 text-sm font-medium">
<span>E-Mail</span>
<input
name="email"
type="email"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
autoComplete="email"
required
placeholder="admin@firma.de"
/>
</label>
<label className="block space-y-2 text-sm font-medium">
<span>Passwort</span>
<input
name="password"
type="password"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
autoComplete="current-password"
required
minLength={8}
placeholder="mindestens 8 Zeichen"
/>
</label>
{error ? (
<p
className="text-sm leading-6 text-destructive"
role="alert"
>
{error}
</p>
) : null}
<Button
className="w-full justify-between"
size="lg"
variant="outline"
disabled={pending}
>
<span className="inline-flex items-center gap-2">
<UserPlus />
Sign up
<LockKeyhole />
Anmelden
</span>
<ArrowRight />
{pending ? "..." : <ArrowRight />}
</Button>
</form>
</div>
</div>
</div>
</section>
</main>
);

View File

@@ -1,8 +1,11 @@
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import type { ReactNode } from "react";
import { authClient } from "@/lib/auth-client";
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
if (!convexUrl) {
@@ -11,6 +14,20 @@ if (!convexUrl) {
const convex = new ConvexReactClient(convexUrl);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
export function ConvexClientProvider({
children,
initialToken,
}: {
children: ReactNode;
initialToken?: string | null;
}) {
return (
<ConvexBetterAuthProvider
client={convex}
authClient={authClient}
initialToken={initialToken}
>
<ConvexProvider client={convex}>{children}</ConvexProvider>
</ConvexBetterAuthProvider>
);
}

View File

@@ -4,14 +4,18 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { LogOut } from "lucide-react";
import { signOutMock } from "@/app/actions/auth";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { dashboardNavigation } from "@/lib/dashboard-navigation";
import type { MockSession } from "@/lib/mock-auth";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
export function DashboardSidebar({ session }: { session: MockSession }) {
export function DashboardSidebar() {
const pathname = usePathname();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const { data: session, isPending } = authClient.useSession();
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">
@@ -59,17 +63,27 @@ export function DashboardSidebar({ session }: { session: MockSession }) {
<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-sm font-medium">
{isPending ? "Lade..." : session?.user?.name ?? "Admin"}
</p>
<p className="truncate text-xs text-muted-foreground">
{session.email}
{session?.user?.email ?? "admin@local"}
</p>
</div>
<form action={signOutMock}>
<Button className="w-full justify-start" variant="outline">
<Button
className="w-full justify-start"
variant="outline"
onClick={async () => {
setIsSigningOut(true);
await authClient.signOut();
router.replace("/login");
router.refresh();
}}
disabled={isSigningOut}
>
<LogOut />
Sign out
{isSigningOut ? "Abmeldung..." : "Abmelden"}
</Button>
</form>
</div>
</aside>
);

View File

@@ -12,6 +12,7 @@ import type * as audits from "../audits.js";
import type * as blacklist from "../blacklist.js";
import type * as campaigns from "../campaigns.js";
import type * as domain from "../domain.js";
import type * as http from "../http.js";
import type * as leads from "../leads.js";
import type * as outreach from "../outreach.js";
import type * as runs from "../runs.js";
@@ -29,6 +30,7 @@ declare const fullApi: ApiFromModules<{
blacklist: typeof blacklist;
campaigns: typeof campaigns;
domain: typeof domain;
http: typeof http;
leads: typeof leads;
outreach: typeof outreach;
runs: typeof runs;
@@ -62,4 +64,6 @@ export declare const internal: FilterApi<
FunctionReference<any, "internal">
>;
export declare const components: {};
export declare const components: {
betterAuth: import("../betterAuth/_generated/component.js").ComponentApi<"betterAuth">;
};

6
convex/auth.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;

View File

@@ -0,0 +1,54 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as adapter from "../adapter.js";
import type * as auth from "../auth.js";
import type * as env from "../env.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import { anyApi, componentsGeneric } from "convex/server";
const fullApi: ApiFromModules<{
adapter: typeof adapter;
auth: typeof auth;
env: typeof env;
}> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
> = anyApi as any;
export const components = componentsGeneric() as unknown as {};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View File

@@ -0,0 +1,156 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery: QueryBuilder<DataModel, "internal"> =
internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation: MutationBuilder<DataModel, "internal"> =
internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction: ActionBuilder<DataModel, "internal"> =
internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction: HttpActionBuilder = httpActionGeneric;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*
* If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@@ -0,0 +1,17 @@
import { createApi } from "@convex-dev/better-auth";
import { createAuthOptions } from "./auth.js";
import schema from "./schema.js";
const createSchemaAuthOptions = (ctx: Parameters<typeof createAuthOptions>[0]) =>
createAuthOptions(ctx, { allowMissingSecret: true });
export const {
create,
findOne,
findMany,
updateOne,
updateMany,
deleteOne,
deleteMany,
} = createApi(schema, createSchemaAuthOptions);

59
convex/betterAuth/auth.ts Normal file
View File

@@ -0,0 +1,59 @@
import { createClient } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import type { GenericCtx } from "@convex-dev/better-auth";
import { betterAuth, type BetterAuthOptions } from "better-auth/minimal";
import type { FunctionReference } from "convex/server";
import { components } from "../_generated/api";
import type { DataModel } from "../_generated/dataModel";
import authConfig from "../auth.config";
import { getBetterAuthServerConfig } from "./env";
import schema from "./schema";
type BetterAuthComponentApi = {
adapter: {
create: FunctionReference<"mutation", "internal">;
findOne: FunctionReference<"query", "internal">;
findMany: FunctionReference<"query", "internal">;
updateOne: FunctionReference<"mutation", "internal">;
updateMany: FunctionReference<"mutation", "internal">;
deleteOne: FunctionReference<"mutation", "internal">;
deleteMany: FunctionReference<"mutation", "internal">;
};
};
const componentsWithAuth = components as {
betterAuth: BetterAuthComponentApi;
};
export const authComponent = createClient<DataModel, typeof schema>(
componentsWithAuth.betterAuth,
{
local: { schema },
verbose: false,
},
);
export const createAuthOptions = (
ctx: GenericCtx<DataModel>,
options: { allowMissingSecret?: boolean } = {},
) => {
const { appUrl, secret } = getBetterAuthServerConfig(process.env, options);
return {
appName: "WebDev Pipeline",
baseURL: appUrl,
secret,
database: authComponent.adapter(ctx),
emailAndPassword: {
enabled: true,
disableSignUp: true,
requireEmailVerification: false,
},
plugins: [convex({ authConfig })],
} satisfies BetterAuthOptions;
};
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth(createAuthOptions(ctx));
};

View File

@@ -0,0 +1,5 @@
import { defineComponent } from "convex/server";
const component = defineComponent("betterAuth");
export default component;

31
convex/betterAuth/env.ts Normal file
View File

@@ -0,0 +1,31 @@
type BetterAuthEnv = Record<string, string | undefined>;
const schemaGenerationSecret = "convex-better-auth-schema-generation-secret";
export function getBetterAuthServerConfig(
env: BetterAuthEnv,
options: { allowMissingSecret?: boolean } = {},
) {
const secret = env.BETTER_AUTH_SECRET?.trim();
if (!secret) {
if (options.allowMissingSecret) {
return {
appUrl:
env.BETTER_AUTH_URL?.trim() ||
env.NEXT_PUBLIC_APP_URL?.trim() ||
"http://localhost:3000",
secret: schemaGenerationSecret,
};
}
throw new Error("Missing BETTER_AUTH_SECRET in the environment.");
}
return {
appUrl:
env.BETTER_AUTH_URL?.trim() ||
env.NEXT_PUBLIC_APP_URL?.trim() ||
"http://localhost:3000",
secret,
};
}

View File

@@ -0,0 +1,78 @@
/**
* This file is auto-generated. Do not edit this file manually.
* To regenerate the schema, from your project root:
*
* npx auth generate --output ./convex/betterAuth/schema.ts
*
* To customize the schema, generate to an alternate file and import
* the table definitions to your schema file. See
* https://labs.convex.dev/better-auth/features/local-install#adding-custom-indexes.
*/
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export const tables = {
user: defineTable({
name: v.string(),
email: v.string(),
emailVerified: v.boolean(),
image: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
userId: v.optional(v.union(v.null(), v.string())),
})
.index("email_name", ["email", "name"])
.index("name", ["name"])
.index("userId", ["userId"]),
session: defineTable({
expiresAt: v.number(),
token: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
ipAddress: v.optional(v.union(v.null(), v.string())),
userAgent: v.optional(v.union(v.null(), v.string())),
userId: v.string(),
})
.index("expiresAt", ["expiresAt"])
.index("expiresAt_userId", ["expiresAt", "userId"])
.index("token", ["token"])
.index("userId", ["userId"]),
account: defineTable({
accountId: v.string(),
providerId: v.string(),
userId: v.string(),
accessToken: v.optional(v.union(v.null(), v.string())),
refreshToken: v.optional(v.union(v.null(), v.string())),
idToken: v.optional(v.union(v.null(), v.string())),
accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
scope: v.optional(v.union(v.null(), v.string())),
password: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("accountId", ["accountId"])
.index("accountId_providerId", ["accountId", "providerId"])
.index("providerId_userId", ["providerId", "userId"])
.index("userId", ["userId"]),
verification: defineTable({
identifier: v.string(),
value: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("expiresAt", ["expiresAt"])
.index("identifier", ["identifier"]),
jwks: defineTable({
publicKey: v.string(),
privateKey: v.string(),
createdAt: v.number(),
expiresAt: v.optional(v.union(v.null(), v.number())),
}),
};
const schema = defineSchema(tables);
export default schema;

9
convex/convex.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineApp } from "convex/server";
import betterAuth from "./betterAuth/convex.config";
const app = defineApp();
app.use(betterAuth);
export default app;

9
convex/http.ts Normal file
View File

@@ -0,0 +1,9 @@
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./betterAuth/auth";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth);
export default http;

View File

@@ -1,5 +1,6 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { tables as authTables } from "./betterAuth/schema";
const campaignStatus = v.union(v.literal("active"), v.literal("paused"));
const leadPriority = v.union(
@@ -114,6 +115,7 @@ const eventDetail = v.object({
});
export default defineSchema({
...authTables,
campaigns: defineTable({
name: v.string(),
categoryMode: v.union(v.literal("preset"), v.literal("custom")),

6
lib/auth-client.ts Normal file
View File

@@ -0,0 +1,6 @@
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [convexClient()],
});

14
lib/auth-server.ts Normal file
View File

@@ -0,0 +1,14 @@
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
export const {
handler,
preloadAuthQuery,
isAuthenticated,
getToken,
fetchAuthQuery,
fetchAuthMutation,
fetchAuthAction,
} = convexBetterAuthNextJs({
convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});

View File

@@ -1,47 +0,0 @@
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,
};
}

View File

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

View File

@@ -1,11 +0,0 @@
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
);
}

View File

@@ -1,5 +1,14 @@
import type { MockSession } from "@/lib/mock-auth";
export function getDashboardRedirectPath(session: MockSession | null) {
return session ? null : "/";
export function isDashboardPath(pathname: string) {
return pathname.startsWith("/dashboard");
}
export function shouldRedirectDashboardRequest(
pathname: string,
hasSession: boolean,
) {
return isDashboardPath(pathname) && !hasSession;
}
export function getDashboardRedirectPath(hasSession: boolean) {
return hasSession ? null : "/login";
}

View File

@@ -11,6 +11,8 @@
"test": "tsc -p tsconfig.test.json && node --test .test-output/tests/*.test.js"
},
"dependencies": {
"@convex-dev/better-auth": "^0.12.2",
"better-auth": "^1.6.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.40.0",

356
pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@convex-dev/better-auth':
specifier: ^0.12.2
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
better-auth:
specifier: ^1.6.14
version: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -202,6 +208,92 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@better-auth/core@1.6.14':
resolution: {integrity: sha512-12cA7tnR4Wyb3nLpPmeq/Id7QNB+4OhjbzuX7sIhqglgXGjyT5iiNpe2lx/8FF532sHC450Yx1850salCYbkzw==}
peerDependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@cloudflare/workers-types': '>=4'
'@opentelemetry/api': ^1.9.0
better-call: 1.3.5
jose: ^6.1.0
kysely: ^0.28.5 || ^0.29.0
nanostores: ^1.0.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
'@opentelemetry/api':
optional: true
'@better-auth/drizzle-adapter@1.6.14':
resolution: {integrity: sha512-lYs1jDudriKYMXNcLFLAvEvOEKbeKBFdDciG4H8qZhV+3+yghGC3f/H5qtgTDc8mGBPV+2tEvVgYqReurOSmNw==}
peerDependencies:
'@better-auth/core': ^1.6.14
'@better-auth/utils': 0.4.1
drizzle-orm: ^0.45.2
peerDependenciesMeta:
drizzle-orm:
optional: true
'@better-auth/kysely-adapter@1.6.14':
resolution: {integrity: sha512-A2+381gYADuZpgd98XQ39bnxLzbT03wnnDmSQIXp7XcE3hF093mGMk6rxlAhENVHH7JL2B0Tv2la2o6n+6ppyQ==}
peerDependencies:
'@better-auth/core': ^1.6.14
'@better-auth/utils': 0.4.1
kysely: ^0.28.17 || ^0.29.0
peerDependenciesMeta:
kysely:
optional: true
'@better-auth/memory-adapter@1.6.14':
resolution: {integrity: sha512-frtBTozi8qsBlypxp33dkiIZT2IOMvix3oh2qTTcBkK11ISsRSTUUadl7DbwXri2AEoooShsH6PSAput920J3Q==}
peerDependencies:
'@better-auth/core': ^1.6.14
'@better-auth/utils': 0.4.1
'@better-auth/mongo-adapter@1.6.14':
resolution: {integrity: sha512-meaZx712k9c0Cl6urwYZRNa3mAy3/leaYiSNt+hVaCOEPlgTDxzmYMNACvTTYXgh4eCpDVf5G7ZMEYBtejKQdw==}
peerDependencies:
'@better-auth/core': ^1.6.14
'@better-auth/utils': 0.4.1
mongodb: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
mongodb:
optional: true
'@better-auth/prisma-adapter@1.6.14':
resolution: {integrity: sha512-9b9wSqhCthMmOYo0QdX+N/cOv+fNck/JE5CZQuuWwEJl5QeoYhCZesXjts5VfLAPMIf6vKw3QNBrn0SVMXXi2Q==}
peerDependencies:
'@better-auth/core': ^1.6.14
'@better-auth/utils': 0.4.1
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
peerDependenciesMeta:
'@prisma/client':
optional: true
prisma:
optional: true
'@better-auth/telemetry@1.6.14':
resolution: {integrity: sha512-ALi3cEx5eyrFY+TeAdhc1uq8FqJyGvzgvIo7GQZOqGqLZxHY9nte44WN++jBFGJJbsW3e4cgLj8dQK291s6wWQ==}
peerDependencies:
'@better-auth/core': ^1.6.14
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@better-auth/utils@0.4.1':
resolution: {integrity: sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA==}
'@better-fetch/fetch@1.1.21':
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
'@convex-dev/better-auth@0.12.2':
resolution: {integrity: sha512-6L8LkXCB5rp9XmQplRj2EVNeD6mkG0b5PPQpm9fooEJ/L3ThGN4jRE4oMfWeBs+9E20eBciWVP0HJooemSgS0w==}
peerDependencies:
better-auth: '>=1.6.9 <1.7.0'
convex: ^1.25.0
react: ^18.3.1 || ^19.0.0
'@dotenvx/dotenvx@1.71.0':
resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==}
hasBin: true
@@ -742,6 +834,10 @@ packages:
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
'@noble/ciphers@2.2.0':
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
engines: {node: '>= 20.19.0'}
'@noble/curves@1.9.7':
resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
engines: {node: ^14.21.3 || >=16}
@@ -750,6 +846,10 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -778,6 +878,10 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/semantic-conventions@1.41.1':
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
engines: {node: '>=14'}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1478,6 +1582,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1913,6 +2020,76 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
better-auth@1.6.14:
resolution: {integrity: sha512-c0/DvTQGDpgfj1knekCpQrg6PSWGDtfAtP7Ou6FkAhoE3RNnnIxLB5qKj6tRg53a1xsq93G6T68cNxrUZ7ZVmw==}
peerDependencies:
'@lynx-js/react': '*'
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
'@sveltejs/kit': ^2.0.0
'@tanstack/react-start': ^1.0.0
'@tanstack/solid-start': ^1.0.0
better-sqlite3: ^12.0.0
drizzle-kit: '>=0.31.4'
drizzle-orm: ^0.45.2
mongodb: ^6.0.0 || ^7.0.0
mysql2: ^3.0.0
next: ^14.0.0 || ^15.0.0 || ^16.0.0
pg: ^8.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
solid-js: ^1.0.0
svelte: ^4.0.0 || ^5.0.0
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
'@lynx-js/react':
optional: true
'@prisma/client':
optional: true
'@sveltejs/kit':
optional: true
'@tanstack/react-start':
optional: true
'@tanstack/solid-start':
optional: true
better-sqlite3:
optional: true
drizzle-kit:
optional: true
drizzle-orm:
optional: true
mongodb:
optional: true
mysql2:
optional: true
next:
optional: true
pg:
optional: true
prisma:
optional: true
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vitest:
optional: true
vue:
optional: true
better-call@1.3.5:
resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
zod:
optional: true
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -2012,6 +2189,10 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
common-tags@1.8.2:
resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
engines: {node: '>=4.0.0'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -2030,6 +2211,28 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
convex-helpers@0.1.118:
resolution: {integrity: sha512-07t10n8CZG/YCDzOy5/WDdNNQYL+mP7VU76BLJCZrB2dvJTH7UZJxPqNrhPH+pZbW52joQ91eQHSksdcgOXebQ==}
hasBin: true
peerDependencies:
'@standard-schema/spec': ^1.0.0
convex: ^1.32.0
hono: ^4.0.5
react: ^17.0.2 || ^18.0.0 || ^19.0.0
typescript: ^5.5 || ^6.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
'@standard-schema/spec':
optional: true
hono:
optional: true
react:
optional: true
typescript:
optional: true
zod:
optional: true
convex@1.40.0:
resolution: {integrity: sha512-jChWEB45q+9Ibryc7hg0l6hB1xA4zwE2y6ZhkhGP6oJkqYeiURkMagA2ZQZYMy1/T8PZ9ztoVJJtbL/+Ob851Q==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
@@ -2157,6 +2360,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -2943,6 +3149,10 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
kysely@0.29.2:
resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==}
engines: {node: '>=22.0.0'}
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -3128,6 +3338,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanostores@1.3.0:
resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==}
engines: {node: ^20.0.0 || >=22.0.0}
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -3449,6 +3663,10 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
remeda@2.37.0:
resolution: {integrity: sha512-wN6BXWua0t4o7vDamqc27J3VRxnokG9cDezsFN2nOnt2JD/IkJQHTYqM6UvmEctAZETAoviwEFQZJO3kZ4Ohew==}
engines: {node: '>=18.0.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -3480,6 +3698,9 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rou3@0.7.12:
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@@ -4155,6 +4376,75 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
dependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@opentelemetry/semantic-conventions': 1.41.1
'@standard-schema/spec': 1.1.0
better-call: 1.3.5(zod@3.25.76)
jose: 6.2.3
kysely: 0.29.2
nanostores: 1.3.0
zod: 4.4.3
'@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
optionalDependencies:
kysely: 0.29.2
'@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@better-auth/utils@0.4.1':
dependencies:
'@noble/hashes': 2.2.0
'@better-fetch/fetch@1.1.21': {}
'@convex-dev/better-auth@0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)':
dependencies:
'@better-fetch/fetch': 1.1.21
better-auth: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
common-tags: 1.8.2
convex: 1.40.0(react@19.2.4)
convex-helpers: 0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3)
jose: 6.2.3
react: 19.2.4
remeda: 2.37.0
semver: 7.8.1
type-fest: 5.7.0
zod: 4.4.3
transitivePeerDependencies:
- '@standard-schema/spec'
- hono
- typescript
'@dotenvx/dotenvx@1.71.0':
dependencies:
commander: 11.1.0
@@ -4563,12 +4853,16 @@ snapshots:
'@noble/ciphers@1.3.0': {}
'@noble/ciphers@2.2.0': {}
'@noble/curves@1.9.7':
dependencies:
'@noble/hashes': 1.8.0
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.2.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4594,6 +4888,8 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@opentelemetry/semantic-conventions@1.41.1': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -5347,6 +5643,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -5756,6 +6054,42 @@ snapshots:
baseline-browser-mapping@2.10.33: {}
better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)
'@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.2.0
'@noble/hashes': 2.2.0
better-call: 1.3.5(zod@3.25.76)
defu: 6.1.7
jose: 6.2.3
kysely: 0.29.2
nanostores: 1.3.0
zod: 4.4.3
optionalDependencies:
next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
transitivePeerDependencies:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
better-call@1.3.5(zod@3.25.76):
dependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
rou3: 0.7.12
set-cookie-parser: 3.1.0
optionalDependencies:
zod: 3.25.76
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -5859,6 +6193,8 @@ snapshots:
commander@14.0.3: {}
common-tags@1.8.2: {}
concat-map@0.0.1: {}
content-disposition@1.1.0: {}
@@ -5869,6 +6205,16 @@ snapshots:
convert-source-map@2.0.0: {}
convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3):
dependencies:
convex: 1.40.0(react@19.2.4)
optionalDependencies:
'@standard-schema/spec': 1.1.0
hono: 4.12.23
react: 19.2.4
typescript: 5.9.3
zod: 4.4.3
convex@1.40.0(react@19.2.4):
dependencies:
esbuild: 0.27.0
@@ -5967,6 +6313,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
defu@6.1.7: {}
depd@2.0.0: {}
detect-libc@2.1.2: {}
@@ -6918,6 +7266,8 @@ snapshots:
kleur@4.1.5: {}
kysely@0.29.2: {}
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -7073,6 +7423,8 @@ snapshots:
nanoid@3.3.12: {}
nanostores@1.3.0: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -7461,6 +7813,8 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
remeda@2.37.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -7487,6 +7841,8 @@ snapshots:
reusify@1.1.0: {}
rou3@0.7.12: {}
router@2.2.0:
dependencies:
debug: 4.4.3

View File

@@ -3,3 +3,4 @@ allowBuilds:
msw: true
sharp: true
unrs-resolver: true
storeDir: /Users/matthias/Library/pnpm/store/v11

View File

@@ -1,21 +1,18 @@
import { NextResponse, type NextRequest } from "next/server";
import { MOCK_SESSION_COOKIE_NAME } from "@/lib/mock-auth";
import { shouldRedirectDashboardRequest } from "@/lib/proxy-auth";
import { isAuthenticated } from "@/lib/auth-server";
import { shouldRedirectDashboardRequest } from "@/lib/route-guards";
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));
export async function proxy(request: NextRequest) {
const hasSession = await isAuthenticated();
if (shouldRedirectDashboardRequest(request.nextUrl.pathname, hasSession)) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: "/dashboard/:path*",
matcher: ["/dashboard/:path*"],
};

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
test("local Better Auth component exports adapter functions", async () => {
const source = await readFile(
join(process.cwd(), "convex/betterAuth/adapter.ts"),
"utf8",
);
for (const exportName of [
"create",
"findOne",
"findMany",
"updateOne",
"updateMany",
"deleteOne",
"deleteMany",
]) {
assert.match(source, new RegExp(`\\b${exportName}\\b`));
}
assert.match(source, /createApi\(schema, createSchemaAuthOptions\)/);
});
test("Better Auth email/password signup is disabled server-side", async () => {
const source = await readFile(
join(process.cwd(), "convex/betterAuth/auth.ts"),
"utf8",
);
assert.match(source, /disableSignUp:\s*true/);
});
test("auth entry only exposes sign in", async () => {
const source = await readFile(
join(process.cwd(), "components/auth-entry.tsx"),
"utf8",
);
assert.doesNotMatch(source, /signUp/);
assert.doesNotMatch(source, /Account anlegen/);
assert.match(source, /authClient\.signIn\.email/);
});

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getBetterAuthServerConfig } from "../convex/betterAuth/env";
test("getBetterAuthServerConfig requires BETTER_AUTH_SECRET", () => {
assert.throws(
() => getBetterAuthServerConfig({}),
/Missing BETTER_AUTH_SECRET/,
);
});
test("getBetterAuthServerConfig can use a placeholder secret for schema generation", () => {
const config = getBetterAuthServerConfig(
{},
{ allowMissingSecret: true },
);
assert.deepEqual(config, {
appUrl: "http://localhost:3000",
secret: "convex-better-auth-schema-generation-secret",
});
});
test("getBetterAuthServerConfig uses BETTER_AUTH_URL before NEXT_PUBLIC_APP_URL", () => {
const config = getBetterAuthServerConfig({
BETTER_AUTH_SECRET: "test-secret",
BETTER_AUTH_URL: "http://auth.local",
NEXT_PUBLIC_APP_URL: "http://app.local",
});
assert.deepEqual(config, {
appUrl: "http://auth.local",
secret: "test-secret",
});
});
test("getBetterAuthServerConfig falls back to local app URL", () => {
const config = getBetterAuthServerConfig({
BETTER_AUTH_SECRET: "test-secret",
});
assert.deepEqual(config, {
appUrl: "http://localhost:3000",
secret: "test-secret",
});
});

View File

@@ -1,71 +0,0 @@
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,
});
});

View File

@@ -1,26 +0,0 @@
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

@@ -1,18 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getDashboardRedirectPath } from "../lib/route-guards";
import {
getDashboardRedirectPath,
shouldRedirectDashboardRequest,
} from "../lib/route-guards";
test("getDashboardRedirectPath sends guests back to the auth entry", () => {
assert.equal(getDashboardRedirectPath(null), "/");
test("getDashboardRedirectPath keeps authenticated users on dashboard", () => {
assert.equal(getDashboardRedirectPath(true), null);
});
test("getDashboardRedirectPath lets authenticated mock users stay on dashboard", () => {
test("getDashboardRedirectPath redirects unauthenticated users to the login page", () => {
assert.equal(getDashboardRedirectPath(false), "/login");
});
test("shouldRedirectDashboardRequest protects dashboard paths without authentication", () => {
assert.equal(
getDashboardRedirectPath({
name: "Matthias Meister",
email: "matthias@webdev-pipeline.local",
}),
null,
shouldRedirectDashboardRequest("/dashboard", false),
true,
);
assert.equal(
shouldRedirectDashboardRequest("/dashboard/leads", false),
true,
);
});
test("shouldRedirectDashboardRequest allows non-dashboard paths without authentication", () => {
assert.equal(
shouldRedirectDashboardRequest("/audit/example", false),
false,
);
assert.equal(shouldRedirectDashboardRequest("/", false), false);
});
test("shouldRedirectDashboardRequest allows authenticated dashboard sessions", () => {
assert.equal(shouldRedirectDashboardRequest("/dashboard", true), false);
assert.equal(shouldRedirectDashboardRequest("/dashboard/audit", true), false);
});