feat: add frontend auth pages, app layout, and Supabase integration (Phase 1E)

- Auth pages: login (password + magic link), register (with firm name), callback
- Supabase client setup: browser client, server client, middleware for session refresh
- App layout: sidebar (Dashboard, Akten, Fristen, Termine, AI Analyse, Einstellungen),
  header with user info and tenant switcher
- Shared: API client with auth headers, TypeScript types matching Go models,
  QueryClientProvider + Toaster providers
- Dependencies: @supabase/supabase-js, @supabase/ssr, @tanstack/react-query,
  lucide-react, date-fns, sonner
This commit is contained in:
m
2026-03-25 13:39:16 +01:00
parent 2c16f26448
commit bf225284d8
21 changed files with 959 additions and 29 deletions

View File

@@ -0,0 +1,20 @@
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
export const dynamic = "force-dynamic";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden bg-neutral-50">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function DashboardPage() {
return (
<div>
<h1 className="text-lg font-semibold text-neutral-900">Dashboard</h1>
<p className="mt-1 text-sm text-neutral-500">
Willkommen bei KanzlAI
</p>
</div>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function CallbackPage() {
const router = useRouter();
const supabase = createClient();
useEffect(() => {
supabase.auth.onAuthStateChange((event) => {
if (event === "SIGNED_IN") {
router.push("/");
router.refresh();
}
});
}, [router, supabase.auth]);
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<p className="text-sm text-neutral-500">Authentifizierung...</p>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,189 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"password" | "magic">("password");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [magicSent, setMagicSent] = useState(false);
const router = useRouter();
const supabase = createClient();
async function handlePasswordLogin(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
async function handleMagicLink(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/callback`,
},
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setMagicSent(true);
setLoading(false);
}
if (magicSent) {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
Link gesendet
</h1>
<p className="mt-2 text-sm text-neutral-500">
Wir haben einen Login-Link an{" "}
<span className="font-medium text-neutral-700">{email}</span>{" "}
gesendet. Bitte pruefen Sie Ihren Posteingang.
</p>
</div>
<button
onClick={() => setMagicSent(false)}
className="w-full text-center text-sm text-neutral-500 hover:text-neutral-700"
>
Zurueck zum Login
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
KanzlAI
</h1>
<p className="mt-1 text-sm text-neutral-500">
Melden Sie sich an
</p>
</div>
<div className="flex rounded-md border border-neutral-200 bg-neutral-50 p-0.5">
<button
onClick={() => setMode("password")}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "password"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Passwort
</button>
<button
onClick={() => setMode("magic")}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "magic"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Magic Link
</button>
</div>
<form
onSubmit={mode === "password" ? handlePasswordLogin : handleMagicLink}
className="space-y-4"
>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-700"
>
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="anwalt@kanzlei.de"
/>
</div>
{mode === "password" && (
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-700"
>
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
/>
</div>
)}
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 disabled:opacity-50"
>
{loading
? "..."
: mode === "password"
? "Anmelden"
: "Link senden"}
</button>
</form>
<p className="text-center text-sm text-neutral-500">
Noch kein Konto?{" "}
<a
href="/register"
className="font-medium text-neutral-900 hover:underline"
>
Registrieren
</a>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { api } from "@/lib/api";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firmName, setFirmName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
// 1. Create auth user
const { data, error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/callback`,
},
});
if (authError) {
setError(authError.message);
setLoading(false);
return;
}
// 2. Create tenant via backend (the backend adds the user as owner)
if (data.session) {
try {
await api.post("/tenants", { name: firmName });
} catch (err: unknown) {
const apiErr = err as { error?: string };
setError(apiErr.error || "Kanzlei konnte nicht erstellt werden");
setLoading(false);
return;
}
router.push("/");
router.refresh();
} else {
// Email confirmation required
router.push("/login");
}
setLoading(false);
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
KanzlAI
</h1>
<p className="mt-1 text-sm text-neutral-500">
Erstellen Sie Ihr Konto
</p>
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label
htmlFor="firm"
className="block text-sm font-medium text-neutral-700"
>
Kanzleiname
</label>
<input
id="firm"
type="text"
value={firmName}
onChange={(e) => setFirmName(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="Muster & Partner Rechtsanwaelte"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-700"
>
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="anwalt@kanzlei.de"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-700"
>
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
/>
<p className="mt-1 text-xs text-neutral-400">Mindestens 8 Zeichen</p>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 disabled:opacity-50"
>
{loading ? "..." : "Konto erstellen"}
</button>
</form>
<p className="text-center text-sm text-neutral-500">
Bereits registriert?{" "}
<a
href="/login"
className="font-medium text-neutral-900 hover:underline"
>
Anmelden
</a>
</p>
</div>
</div>
);
}

View File

@@ -1,26 +1,11 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/Providers";
import "./globals.css";
const geistSans = Geist({
@@ -13,7 +14,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "KanzlAI-mGMT",
title: "KanzlAI",
description: "Kanzleimanagement online",
};
@@ -23,11 +24,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="de">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
{children}
<Providers>{children}</Providers>
</body>
</html>
);

View File

@@ -1,7 +0,0 @@
export default function Home() {
return (
<main className="flex min-h-screen items-center justify-center">
<h1 className="text-4xl font-bold">KanzlAI-mGMT</h1>
</main>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="bottom-right" richColors />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { TenantSwitcher } from "./TenantSwitcher";
import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function Header() {
const [email, setEmail] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
setEmail(user?.email ?? null);
});
}, [supabase.auth]);
async function handleLogout() {
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
return (
<header className="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4">
<div />
<div className="flex items-center gap-3">
<TenantSwitcher />
{email && (
<span className="text-sm text-neutral-500">{email}</span>
)}
<button
onClick={handleLogout}
title="Abmelden"
className="rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
FolderOpen,
Clock,
Calendar,
Brain,
Settings,
} from "lucide-react";
const navigation = [
{ name: "Dashboard", href: "/", icon: LayoutDashboard },
{ name: "Akten", href: "/akten", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "AI Analyse", href: "/ai", icon: Brain },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="flex h-full w-56 flex-col border-r border-neutral-200 bg-white">
<div className="flex h-14 items-center border-b border-neutral-200 px-4">
<span className="text-sm font-semibold text-neutral-900">KanzlAI</span>
</div>
<nav className="flex-1 space-y-0.5 p-2">
{navigation.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
isActive
? "bg-neutral-100 font-medium text-neutral-900"
: "text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
}`}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.name}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { api } from "@/lib/api";
import type { TenantWithRole } from "@/lib/types";
import { ChevronsUpDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
export function TenantSwitcher() {
const [tenants, setTenants] = useState<TenantWithRole[]>([]);
const [current, setCurrent] = useState<TenantWithRole | null>(null);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
api.get<TenantWithRole[]>("/tenants").then((data) => {
setTenants(data);
const savedId = localStorage.getItem("kanzlai_tenant_id");
const match = data.find((t) => t.id === savedId) || data[0];
if (match) {
setCurrent(match);
localStorage.setItem("kanzlai_tenant_id", match.id);
}
}).catch(() => {
// Not authenticated or no tenants
});
}, []);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function switchTenant(tenant: TenantWithRole) {
setCurrent(tenant);
localStorage.setItem("kanzlai_tenant_id", tenant.id);
setOpen(false);
window.location.reload();
}
if (!current) return null;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50"
>
<span className="max-w-[160px] truncate">{current.name}</span>
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
</button>
{open && tenants.length > 1 && (
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
{tenants.map((tenant) => (
<button
key={tenant.id}
onClick={() => switchTenant(tenant)}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
tenant.id === current.id
? "bg-neutral-50 font-medium text-neutral-900"
: "text-neutral-600 hover:bg-neutral-50"
}`}
>
<span className="truncate">{tenant.name}</span>
<span className="ml-auto text-xs text-neutral-400">
{tenant.role}
</span>
</button>
))}
</div>
)}
</div>
);
}

77
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,77 @@
import { createClient } from "@/lib/supabase/client";
import type { ApiError } from "@/lib/types";
class ApiClient {
private baseUrl = "/api";
private async getHeaders(): Promise<HeadersInit> {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (session?.access_token) {
headers["Authorization"] = `Bearer ${session.access_token}`;
}
const tenantId = typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
return headers;
}
private async request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = await this.getHeaders();
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: { ...headers, ...options.headers },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err: ApiError = {
error: body.error || res.statusText,
status: res.status,
};
throw err;
}
if (res.status === 204) return undefined as T;
return res.json();
}
get<T>(path: string) {
return this.request<T>(path, { method: "GET" });
}
post<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
}
put<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: "PUT",
body: body ? JSON.stringify(body) : undefined,
});
}
delete<T>(path: string) {
return this.request<T>(path, { method: "DELETE" });
}
}
export const api = new ApiClient();

View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}

View File

@@ -0,0 +1,29 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// setAll is called from Server Components where cookies
// cannot be set. This is safe to ignore when middleware
// handles the session refresh.
}
},
},
},
);
}

117
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,117 @@
export interface Tenant {
id: string;
name: string;
slug: string;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface TenantWithRole extends Tenant {
role: string;
}
export interface UserTenant {
user_id: string;
tenant_id: string;
role: string;
created_at: string;
}
export interface Case {
id: string;
tenant_id: string;
case_number: string;
title: string;
case_type?: string;
court?: string;
court_ref?: string;
status: string;
ai_summary?: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface Party {
id: string;
tenant_id: string;
case_id: string;
name: string;
role?: string;
representative?: string;
contact_info: Record<string, unknown>;
}
export interface Deadline {
id: string;
tenant_id: string;
case_id: string;
title: string;
description?: string;
due_date: string;
original_due_date?: string;
warning_date?: string;
source: string;
rule_id?: string;
status: string;
completed_at?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface Appointment {
id: string;
tenant_id: string;
case_id?: string;
title: string;
description?: string;
start_at: string;
end_at?: string;
location?: string;
appointment_type?: string;
created_at: string;
updated_at: string;
}
export interface CaseEvent {
id: string;
tenant_id: string;
case_id: string;
event_type?: string;
title: string;
description?: string;
event_date?: string;
created_by?: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface Document {
id: string;
tenant_id: string;
case_id: string;
title: string;
doc_type?: string;
file_path?: string;
file_size?: number;
mime_type?: string;
ai_extracted?: Record<string, unknown>;
uploaded_by?: string;
created_at: string;
updated_at: string;
}
export interface ApiError {
error: string;
status: number;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
}

View File

@@ -0,0 +1,60 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
);
},
},
},
);
const {
data: { user },
} = await supabase.auth.getUser();
const { pathname } = request.nextUrl;
// Auth pages — redirect to app if already logged in
if (user && (pathname === "/login" || pathname === "/register")) {
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
// Protected routes — redirect to login if not authenticated
if (
!user &&
!pathname.startsWith("/login") &&
!pathname.startsWith("/register") &&
!pathname.startsWith("/callback")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};