added login and protected routes

This commit is contained in:
Rajpreet0 2025-11-23 16:53:03 +01:00
parent 07aa278aee
commit 1b534dfc3b
21 changed files with 589 additions and 137 deletions

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"prisma.pinToPrisma6": true
}

View file

@ -1,7 +1,7 @@
import { requireGuest } from "@/src/lib/auth/getUser";
import LoginView from "@/src/modules/auth/ui/views/login-view";
const Login = () => {
export default async function LoginPage() {
await requireGuest();
return <LoginView />;
};
export default Login;
}

View file

@ -1,7 +1,7 @@
import RegisterUserView from "@/src/modules/auth/ui/views/register-user-view";
import { requireGuest } from "@/src/lib/auth/getUser";
const RegisterUser = () => {
export default async function RegisterUser() {
await requireGuest();
return <RegisterUserView />;
};
export default RegisterUser;
}

View file

@ -1,5 +1,71 @@
const Dashboard = () => {
return <div>Du hast dich erfolgreich eingeloggt</div>;
};
import { LogoutButton } from "@/src/components/layout/LogoutButton";
import { requireAuthWithData } from "@/src/lib/auth/getUser";
export default Dashboard;
export default async function DashboardPage() {
const userData = await requireAuthWithData();
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-4">Willkommen zurück!</h1>
<p className="text-gray-600">
Schön, dich wiederzusehen, {userData?.name || userData.email}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-2">Profil</h2>
<p className="text-gray-600 text-sm">Verwalte deine persönlichen Daten</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-2">Trainings</h2>
<p className="text-gray-600 text-sm">Deine gebuchten Trainingseinheiten</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-2">Einstellungen</h2>
<p className="text-gray-600 text-sm">Passe deine Einstellungen an</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Benutzerdaten</h2>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="mt-1 text-sm text-gray-900">{userData?.name || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">E-Mail</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Geburtsdatum</dt>
<dd className="mt-1 text-sm text-gray-900">
{userData?.birthday ? new Date(userData.birthday).toLocaleDateString("de-DE") : "—"}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Wohnort</dt>
<dd className="mt-1 text-sm text-gray-900">
{userData?.plz && userData?.city ? `${userData.plz} ${userData.city}` : "—"}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Geschlecht</dt>
<dd className="mt-1 text-sm text-gray-900">{userData?.gender || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Registriert seit</dt>
<dd className="mt-1 text-sm text-gray-900">
{userData?.createdAt ? new Date(userData.createdAt).toLocaleDateString("de-DE") : "—"}
</dd>
</div>
</dl>
</div>
<LogoutButton />
</div>
);
}

View file

@ -0,0 +1,31 @@
// app/api/auth/login/route.ts
import { createClient } from "@/src/lib/supabase/server";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
try {
const body = await req.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json({ error: "E-Mail und Passwort sind erforderlich" }, { status: 400 });
}
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
console.error("❌ Login Error:", error.message);
return NextResponse.json({ error: "E-Mail oder Passwort ist falsch" }, { status: 401 });
}
return NextResponse.json({ user: data.user, session: data.session }, { status: 200 });
} catch (error) {
console.error("Error in /api/auth/login:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View file

@ -1,14 +1,8 @@
import { supabaseServer } from "@/src/lib/supabaseClient";
// app/api/auth/register/route.ts
import { createClient } from "@/src/lib/supabase/server";
import { prisma } from "@/src/lib/prisma";
import { NextResponse } from "next/server";
/**
** Handles POST request to register and save a User in the DB
*
*
* @param {Request} request - Incoming HTTP request containing user data in JSON format
* @returns {Promise<NextResponse>} - JSON response with exisiting or newly created user
*/
export async function POST(request: Request) {
try {
const body = await request.json();
@ -18,11 +12,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
}
const { data, error } = await supabaseServer.auth.signUp({
const supabase = await createClient();
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: "http://localhost:3000/login",
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"}/login`,
data: {
name,
gender,

View file

@ -2,13 +2,14 @@ import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { AuthInitializer } from "../components/AuthInitializer";
const salmondFont = localFont({
src: "../public/font/salmond.woff",
});
export const metadata: Metadata = {
title: "Create Next App",
title: "Kumasy",
description: "Generated by create next app",
};
@ -20,6 +21,7 @@ export default function RootLayout({
return (
<html lang="en" className={salmondFont.className}>
<body className={`antialiased`}>
<AuthInitializer />
<main>{children}</main>
<Toaster
toastOptions={{

View file

@ -0,0 +1,21 @@
"use client";
import { useEffect, useRef } from "react";
import { useAuthStore } from "@/src/store/authStore";
export function AuthInitializer() {
const initAuth = useAuthStore((state) => state.initAuth);
const initialized = useAuthStore((state) => state.initialized);
const hasRun = useRef(false);
useEffect(() => {
// Prevent double initialization in development (React StrictMode)
if (hasRun.current || initialized) return;
hasRun.current = true;
console.log("🎬 [INIT] AuthInitializer mounting");
initAuth();
}, [initAuth, initialized]);
return null;
}

View file

@ -0,0 +1,14 @@
"use client";
import { useAuthStore } from "@/src/store/authStore";
import { Button } from "@/src/components/ui/button";
export function LogoutButton() {
const signOut = useAuthStore((state) => state.signOut);
return (
<Button onClick={signOut} variant="destructive" size="sm">
Logout
</Button>
);
}

122
src/hooks/useAuth.ts Normal file
View file

@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { userRegisterSchema } from "../modules/auth/validation/userRegisterSchema";
import { toast } from "sonner";
import { ZodError } from "zod";
import { userLoginSchema } from "../modules/auth/validation/userLoginSchema";
import { createClient } from "@/src/lib/supabase/client";
import { useAuthStore } from "@/src/store/authStore";
import { useRouter } from "next/navigation";
import { RegisterFormData } from "../types/auth";
export const useAuth = () => {
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const supabase = createClient();
const setUser = useAuthStore((state) => state.setUser);
const router = useRouter();
const registerUser = async (formData: RegisterFormData) => {
setErrors({});
setLoading(true);
try {
userRegisterSchema.parse(formData);
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: formData.email,
password: formData.password,
name: formData.fullName,
birthday: formData.birthday,
plz: formData.plz,
city: formData.place,
street: formData.street,
houseNumber: "",
gender: formData.gender,
}),
});
const data = await res.json();
if (!res.ok) {
setErrors({ email: data.error || "Registrierung fehlgeschlagen" });
toast.error(data.error || "Registrierung fehlgeschlagen");
return false;
}
toast.success("Bitte bestätige deine E-Mail, um die Registrierung abzuschließen.");
return true;
} catch (err) {
if (err instanceof ZodError) {
const fieldErrors: Record<string, string> = {};
err.issues.forEach((issue) => {
const path = issue.path[0];
if (typeof path === "string") fieldErrors[path] = issue.message;
});
setErrors(fieldErrors);
return false;
}
toast.error("Ein unbekannter Fehler ist aufgetreten.");
return false;
} finally {
setLoading(false);
}
};
const loginUser = async (email: string, password: string, remember: boolean) => {
setErrors({});
setLoading(true);
try {
userLoginSchema.parse({ email, password });
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setErrors({ email: "E-Mail oder Passwort ist falsch" });
toast.error("E-Mail oder Passwort ist falsch");
return false;
}
// Update Zustand store
setUser(data.user);
toast.success("Erfolgreich eingeloggt 🎉");
// Navigate and refresh
router.push("/dashboard");
router.refresh();
return true;
} catch (err) {
if (err instanceof ZodError) {
const fieldErrors: Record<string, string> = {};
err.issues.forEach((issue) => {
const path = issue.path[0];
if (typeof path === "string") fieldErrors[path] = issue.message;
});
setErrors(fieldErrors);
return false;
}
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
return false;
} finally {
setLoading(false);
}
};
return {
loading,
errors,
registerUser,
loginUser,
setErrors,
};
};

109
src/lib/auth/getUser.ts Normal file
View file

@ -0,0 +1,109 @@
import { createClient } from "@/src/lib/supabase/server";
import { prisma } from "@/src/lib/prisma";
import { cache } from "react";
// Define the user data type
export type UserData = {
id: string;
name: string | null;
email: string;
birthday: Date | null;
plz: string | null;
city: string | null;
street: string | null;
houseNumber: string | null;
gender: string | null;
pbSrc: string | null;
createdAt: Date | null;
};
// Cache entry type
type CacheEntry = {
data: UserData;
timestamp: number;
};
// Simple in-memory cache with timestamp
const userDataCache = new Map<string, CacheEntry>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let userDataCallCount = 0;
// Cache Supabase user (per request)
export const getUser = cache(async () => {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
console.log("🔐 [AUTH] getUser called - User:", user?.email || "none");
return user;
});
// Cache user data from database with time-based cache
export const getUserData = cache(async (): Promise<UserData | null> => {
userDataCallCount++;
console.log(`📊 [DB] getUserData called (count: ${userDataCallCount})`);
const user = await getUser();
if (!user) return null;
// Check in-memory cache
const cached = userDataCache.get(user.id);
const now = Date.now();
if (cached && now - cached.timestamp < CACHE_TTL) {
console.log(
`⚡ [CACHE] Using cached data (${Math.round((now - cached.timestamp) / 1000)}s old)`
);
return cached.data;
}
console.log("🗄️ [DB] Fetching user data from Prisma for:", user.email);
const userData = await prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
name: true,
email: true,
birthday: true,
plz: true,
city: true,
street: true,
houseNumber: true,
gender: true,
pbSrc: true,
createdAt: true,
},
});
console.log("✅ [DB] User data fetched:", userData?.name);
// Store in cache
if (userData) {
userDataCache.set(user.id, { data: userData, timestamp: now });
}
return userData;
});
export async function requireAuth() {
const user = await getUser();
return user!;
}
export async function requireAuthWithData() {
const userData = await getUserData();
if (!userData) {
throw new Error("User not found");
}
return userData;
}
export async function requireGuest() {
const user = await getUser();
if (user) {
const { redirect } = await import("next/navigation");
redirect("/dashboard");
}
}

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,27 @@
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 {
// Server Component - ignore
}
},
},
}
);
}

View file

@ -1,18 +0,0 @@
import { createBrowserClient } from "@supabase/ssr";
import { createClient } from "@supabase/supabase-js";
export const supabaseBrowser = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
throw new Error(
"❌ Missing Supabase environment variables! Make sure NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set."
);
}
export const supabaseServer = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

58
src/middleware.ts Normal file
View file

@ -0,0 +1,58 @@
// middleware.ts (at root level)
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();
// Protected routes
const protectedRoutes = ["/dashboard", "/profile", "/trainings"];
const isProtectedRoute = protectedRoutes.some((route) =>
request.nextUrl.pathname.startsWith(route)
);
if (!user && isProtectedRoute) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
// Auth routes (redirect if already logged in)
const authRoutes = ["/login", "/register"];
const isAuthRoute = authRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
if (user && isAuthRoute) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
};

View file

@ -8,49 +8,20 @@ import { Label } from "@/src/components/ui/label";
import { Checkbox } from "@/src/components/ui/checkbox";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/src/lib/supabaseClient";
import { Spinner } from "@/src/components/ui/spinner";
import { useAuth } from "@/src/hooks/useAuth";
const LoginView = () => {
//TODO: RIGHT LOGIN with Schema etc.
const { loginUser, errors, loading } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = async () => {
setError("");
setLoading(true);
try {
const { data, error } = await supabaseBrowser.auth.signInWithPassword({
email,
password,
});
if (error) {
console.error("❌ Login Error:", error.message);
setError("E-Mail oder Passwort ist falsch");
setLoading(false);
return;
}
// Optional: Session speichern
if (remember) {
localStorage.setItem("since3_remember_me", "true");
} else {
localStorage.removeItem("since3_remember_me");
}
// Redirect nach Login
const success = await loginUser(email, password, remember);
if (success) {
router.push("/dashboard");
} catch (err) {
console.error(err);
setError("Ein unerwarteter Fehler ist aufgetreten");
} finally {
setLoading(false);
}
};
@ -79,6 +50,7 @@ const LoginView = () => {
id="login-email"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={errors.email}
/>
<InputComponent
isLabel
@ -87,10 +59,11 @@ const LoginView = () => {
id="login-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
error={errors.password}
/>
<Button onClick={handleLogin} disabled={loading} className="w-full mt-6">
{loading ? <Spinner /> : "Weiter"}
{loading ? <Spinner /> : "Anmelden"}
</Button>
<div className="flex flex-col sm:flex-row sm:justify-between w-full mt-6 gap-4 text-sm">
@ -119,7 +92,7 @@ const LoginView = () => {
<p>Ich habe noch keinen Account</p>
<p
className="text-yellow hover:underline cursor-pointer"
onClick={() => router.push("/register")}
onClick={() => router.push("/register/user")}
>
Sign Up
</p>

View file

@ -6,17 +6,16 @@ import { Button } from "@/src/components/ui/button";
import AuthButton from "../components/AuthButton";
import { FaGoogle, FaFacebook } from "react-icons/fa";
import { useAuthFormStore } from "../../store/authFormState";
import { userRegisterSchema, userRegisterStep1Schema } from "../../validation/userRegisterSchema";
import { userRegisterStep1Schema } from "../../validation/userRegisterSchema";
import { ZodError } from "zod";
import DropdownComponent from "@/src/components/layout/DropdownComponent";
import { Spinner } from "@/src/components/ui/spinner";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { useAuth } from "@/src/hooks/useAuth";
const RegisterUserView = () => {
const [step, setStep] = useState(1);
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const { registerUser, loading, errors, setErrors } = useAuth();
const router = useRouter();
const formData = useAuthFormStore();
@ -51,69 +50,26 @@ const RegisterUserView = () => {
};
const handleRegister = async () => {
setErrors({});
setLoading(true);
try {
userRegisterSchema.parse({
fullName: formData.fullName,
email: formData.email,
password: formData.password,
passwordConfirm: formData.passwordConfirm,
birthday: formData.birthday,
plz: formData.plz,
place: formData.place,
street: formData.street,
gender: formData.gender,
});
const success = await registerUser({
fullName: formData.fullName,
email: formData.email,
password: formData.password,
passwordConfirm: formData.passwordConfirm,
birthday: formData.birthday,
plz: formData.plz,
place: formData.place,
street: formData.street,
gender: formData.gender,
});
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: formData.email,
password: formData.password,
name: formData.fullName,
birthday: formData.birthday,
plz: formData.plz,
city: formData.place,
street: formData.street,
houseNumber: "",
gender: formData.gender,
}),
});
const data = await res.json();
if (!res.ok) {
setErrors({ email: data.error || "Registrierung fehlgeschlagen" });
return;
}
toast.info("Bitte Email bestätigen für erfolgreiche Registrierung");
if (success) {
formData.resetForm();
setStep(1);
setErrors({});
router.push("/login");
} catch (err) {
if (err instanceof ZodError) {
const fieldErrors: Record<string, string> = {};
err.issues.forEach((issue) => {
const path = issue.path[0];
if (typeof path === "string") {
fieldErrors[path] = issue.message;
}
});
setErrors(fieldErrors);
} else {
toast.error(`Registrierung fehlgeschlagen: ${err}`);
}
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screena">
<div className="flex min-h-screen">
{/* Sidebar */}
<div className="hidden lg:flex lg:w-1/6 ">
<div className="fixed p-4 left-0 top-0 h-screen w-1/2 z-10">
@ -173,7 +129,7 @@ const RegisterUserView = () => {
/>
<Button className="w-full mt-6" onClick={handleNext}>
Weiter
{loading ? <Spinner /> : "Weiter"}
</Button>
</>
)}

View file

@ -0,0 +1,8 @@
import { z } from "zod";
export const userLoginSchema = z.object({
email: z.string().email("Ungültige E-Mail-Adresse"),
password: z.string().min(8, "Passwort muss mindestens 8 Zeichen enthalten"),
});
export type UserLoginSchema = z.infer<typeof userLoginSchema>;

60
src/store/authStore.ts Normal file
View file

@ -0,0 +1,60 @@
// src/store/authStore.ts
import { create } from "zustand";
import { createClient } from "@/src/lib/supabase/client";
import { User } from "@supabase/supabase-js";
interface AuthState {
user: User | null;
loading: boolean;
initialized: boolean;
setUser: (user: User | null) => void;
initAuth: () => Promise<void>;
signOut: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
loading: true,
initialized: false,
setUser: (user) => {
console.log("👤 [ZUSTAND] setUser:", user?.email || "null");
set({ user, loading: false });
},
initAuth: async () => {
// Prevent multiple initializations
if (get().initialized) {
console.log("⏭️ [ZUSTAND] Already initialized, skipping");
return;
}
console.log("🚀 [ZUSTAND] initAuth starting");
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
console.log("🔐 [ZUSTAND] Initial session:", session?.user?.email || "none");
set({ user: session?.user ?? null, loading: false, initialized: true });
// Listen for auth changes (login/logout only)
supabase.auth.onAuthStateChange((event, session) => {
console.log("🔄 [ZUSTAND] Auth event:", event, session?.user?.email || "none");
// Only update on actual auth changes, not on token refresh
if (event === "SIGNED_IN" || event === "SIGNED_OUT" || event === "USER_UPDATED") {
set({ user: session?.user ?? null, loading: false });
}
});
},
signOut: async () => {
console.log("👋 [ZUSTAND] Signing out");
const supabase = createClient();
await supabase.auth.signOut();
set({ user: null });
window.location.href = "/login";
},
}));

16
src/types/auth.ts Normal file
View file

@ -0,0 +1,16 @@
export interface RegisterFormData {
fullName: string;
email: string;
password: string;
passwordConfirm: string;
birthday: string;
plz: string;
place: string;
street: string;
gender: string;
}
export interface LoginFormData {
email: string;
password: string;
}