diff --git a/frontend/bun.lock b/frontend/bun.lock
index ba0f7da..08282cb 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -5,9 +5,15 @@
"": {
"name": "frontend",
"dependencies": {
+ "@supabase/ssr": "^0.9.0",
+ "@supabase/supabase-js": "^2.100.0",
+ "@tanstack/react-query": "^5.95.2",
+ "date-fns": "^4.1.0",
+ "lucide-react": "^1.6.0",
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "sonner": "^2.0.7",
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -151,6 +157,22 @@
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
+ "@supabase/auth-js": ["@supabase/auth-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pdT3ye3UVRN1Cg0wom6BmyY+XTtp5DiJaYnPi6j8ht5i8Lq8kfqxJMJz9GI9YDKk3w1nhGOPnh6Qz5qpyYm+1w=="],
+
+ "@supabase/functions-js": ["@supabase/functions-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-keLg79RPwP+uiwHuxFPTFgDRxPV46LM4j/swjyR2GKJgWniTVSsgiBHfbIBDcrQwehLepy09b/9QSHUywtKRWQ=="],
+
+ "@supabase/phoenix": ["@supabase/phoenix@0.4.0", "", {}, "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="],
+
+ "@supabase/postgrest-js": ["@supabase/postgrest-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-xYNvNbBJaXOGcrZ44wxwp5830uo1okMHGS8h8dm3u4f0xcZ39yzbryUsubTJW41MG2gbL/6U57cA4Pi6YMZ9pA=="],
+
+ "@supabase/realtime-js": ["@supabase/realtime-js@2.100.0", "", { "dependencies": { "@supabase/phoenix": "^0.4.0", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2AZs00zzEF0HuCKY8grz5eCYlwEfVi5HONLZFoNR6aDfxQivl8zdQYNjyFoqN2MZiVhQHD7u6XV/xHwM8mCEHw=="],
+
+ "@supabase/ssr": ["@supabase/ssr@0.9.0", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.97.0" } }, "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q=="],
+
+ "@supabase/storage-js": ["@supabase/storage-js@2.100.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-d4EeuK6RNIgYNA2MU9kj8lQrLm5AzZ+WwpWjGkii6SADQNIGTC/uiaTRu02XJ5AmFALQfo8fLl9xuCkO6Xw+iQ=="],
+
+ "@supabase/supabase-js": ["@supabase/supabase-js@2.100.0", "", { "dependencies": { "@supabase/auth-js": "2.100.0", "@supabase/functions-js": "2.100.0", "@supabase/postgrest-js": "2.100.0", "@supabase/realtime-js": "2.100.0", "@supabase/storage-js": "2.100.0" } }, "sha512-r0tlcukejJXJ1m/2eG/Ya5eYs4W8AC7oZfShpG3+SIo/eIU9uIt76ZeYI1SoUwUmcmzlAbgch+HDZDR/toVQPQ=="],
+
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
@@ -183,6 +205,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="],
+ "@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
+
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -197,6 +223,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
@@ -319,6 +347,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
+
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -331,6 +361,8 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
+ "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -463,6 +495,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
+
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -583,6 +617,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+ "lucide-react": ["lucide-react@1.6.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -705,6 +741,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
@@ -779,6 +817,8 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
+
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/frontend/package.json b/frontend/package.json
index bf6dce2..c0252fe 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,9 +9,15 @@
"lint": "eslint"
},
"dependencies": {
+ "@supabase/ssr": "^0.9.0",
+ "@supabase/supabase-js": "^2.100.0",
+ "@tanstack/react-query": "^5.95.2",
+ "date-fns": "^4.1.0",
+ "lucide-react": "^1.6.0",
+ "next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
- "next": "15.5.14"
+ "sonner": "^2.0.7"
},
"devDependencies": {
"typescript": "^5",
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
new file mode 100644
index 0000000..d166fc6
--- /dev/null
+++ b/frontend/src/app/(app)/layout.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx
new file mode 100644
index 0000000..6b5039d
--- /dev/null
+++ b/frontend/src/app/(app)/page.tsx
@@ -0,0 +1,10 @@
+export default function DashboardPage() {
+ return (
+
+
Dashboard
+
+ Willkommen bei KanzlAI
+
+
+ );
+}
diff --git a/frontend/src/app/(auth)/callback/page.tsx b/frontend/src/app/(auth)/callback/page.tsx
new file mode 100644
index 0000000..2372b04
--- /dev/null
+++ b/frontend/src/app/(auth)/callback/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx
new file mode 100644
index 0000000..f7eac22
--- /dev/null
+++ b/frontend/src/app/(auth)/layout.tsx
@@ -0,0 +1,9 @@
+export const dynamic = "force-dynamic";
+
+export default function AuthLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return <>{children}>;
+}
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..d1a0fdd
--- /dev/null
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -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(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 (
+
+
+
+
+ Link gesendet
+
+
+ Wir haben einen Login-Link an{" "}
+ {email}{" "}
+ gesendet. Bitte pruefen Sie Ihren Posteingang.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ KanzlAI
+
+
+ Melden Sie sich an
+
+
+
+
+
+
+
+
+
+
+
+ Noch kein Konto?{" "}
+
+ Registrieren
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..afa4a0a
--- /dev/null
+++ b/frontend/src/app/(auth)/register/page.tsx
@@ -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(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 (
+
+
+
+
+ KanzlAI
+
+
+ Erstellen Sie Ihr Konto
+
+
+
+
+
+
+ Bereits registriert?{" "}
+
+ Anmelden
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index a2dc41e..2291f25 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -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;
}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 27c6387..a151265 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -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 (
-
+
- {children}
+ {children}
);
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
deleted file mode 100644
index 39c0ee5..0000000
--- a/frontend/src/app/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function Home() {
- return (
-
- KanzlAI-mGMT
-
- );
-}
diff --git a/frontend/src/components/Providers.tsx b/frontend/src/components/Providers.tsx
new file mode 100644
index 0000000..4e22c03
--- /dev/null
+++ b/frontend/src/components/Providers.tsx
@@ -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 (
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx
new file mode 100644
index 0000000..5a80c6d
--- /dev/null
+++ b/frontend/src/components/layout/Header.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..218b06c
--- /dev/null
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/components/layout/TenantSwitcher.tsx b/frontend/src/components/layout/TenantSwitcher.tsx
new file mode 100644
index 0000000..4581b1c
--- /dev/null
+++ b/frontend/src/components/layout/TenantSwitcher.tsx
@@ -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([]);
+ const [current, setCurrent] = useState(null);
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ api.get("/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 (
+
+
+
+ {open && tenants.length > 1 && (
+
+ {tenants.map((tenant) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..d3b9e8e
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -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 {
+ 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(
+ path: string,
+ options: RequestInit = {},
+ ): Promise {
+ 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(path: string) {
+ return this.request(path, { method: "GET" });
+ }
+
+ post(path: string, body?: unknown) {
+ return this.request(path, {
+ method: "POST",
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ }
+
+ put(path: string, body?: unknown) {
+ return this.request(path, {
+ method: "PUT",
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ }
+
+ delete(path: string) {
+ return this.request(path, { method: "DELETE" });
+ }
+}
+
+export const api = new ApiClient();
diff --git a/frontend/src/lib/supabase/client.ts b/frontend/src/lib/supabase/client.ts
new file mode 100644
index 0000000..2ad2591
--- /dev/null
+++ b/frontend/src/lib/supabase/client.ts
@@ -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!,
+ );
+}
diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts
new file mode 100644
index 0000000..16fe0de
--- /dev/null
+++ b/frontend/src/lib/supabase/server.ts
@@ -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.
+ }
+ },
+ },
+ },
+ );
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..422159d
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,117 @@
+export interface Tenant {
+ id: string;
+ name: string;
+ slug: string;
+ settings: Record;
+ 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;
+ 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;
+}
+
+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;
+ 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;
+ uploaded_by?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ApiError {
+ error: string;
+ status: number;
+}
+
+export interface PaginatedResponse {
+ data: T[];
+ total: number;
+ page: number;
+ per_page: number;
+}
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
new file mode 100644
index 0000000..7dba35b
--- /dev/null
+++ b/frontend/src/middleware.ts
@@ -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)$).*)",
+ ],
+};