From cef00378d7c6e1ab0bad8538e1bad8f7412ad6aa Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 28 Jul 2026 18:10:38 +0200 Subject: [PATCH 1/2] added google social login --- next.config.ts | 4 + .../(auth)/register/complete-profile/page.tsx | 13 ++ src/app/api/auth/complete-profile/route.ts | 42 ++++++ src/app/auth/callback/route.ts | 72 +++++++++- src/hooks/useAuth.ts | 21 +++ src/middleware.ts | 6 +- src/modules/auth/ui/components/AuthButton.tsx | 16 ++- .../auth/ui/views/complete-profile-view.tsx | 127 ++++++++++++++++++ src/modules/auth/ui/views/login-view.tsx | 4 +- .../auth/ui/views/register-user-view.tsx | 4 +- .../auth/validation/userRegisterSchema.ts | 32 +++++ 11 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 src/app/(auth)/register/complete-profile/page.tsx create mode 100644 src/app/api/auth/complete-profile/route.ts create mode 100644 src/modules/auth/ui/views/complete-profile-view.tsx diff --git a/next.config.ts b/next.config.ts index 0e56d46..2b1e268 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,6 +15,10 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "dlvlsahpuahlymvemzkl.supabase.co", }, + { + protocol: "https", + hostname: "lh3.googleusercontent.com", + }, ], }, }; diff --git a/src/app/(auth)/register/complete-profile/page.tsx b/src/app/(auth)/register/complete-profile/page.tsx new file mode 100644 index 0000000..704eec0 --- /dev/null +++ b/src/app/(auth)/register/complete-profile/page.tsx @@ -0,0 +1,13 @@ +import CompleteProfileView from "@/src/modules/auth/ui/views/complete-profile-view"; +import { requireAuthWithData, isUser } from "@/src/lib/auth/getUser"; +import { redirect } from "next/navigation"; + +export default async function CompleteProfile() { + const userData = await requireAuthWithData(); + + if (!isUser(userData)) { + redirect("/dashboard"); + } + + return ; +} diff --git a/src/app/api/auth/complete-profile/route.ts b/src/app/api/auth/complete-profile/route.ts new file mode 100644 index 0000000..0c18c80 --- /dev/null +++ b/src/app/api/auth/complete-profile/route.ts @@ -0,0 +1,42 @@ +import { requireAuthWithData, isUser } from "@/src/lib/auth/getUser"; +import { prisma } from "@/src/lib/prisma"; +import { NextResponse } from "next/server"; + +// Trägt die Felder nach, die Google (oder andere OAuth-Provider) beim Signup +// nicht liefern: Geburtsdatum, Adresse, Geschlecht. Legt keinen neuen Account an, +// sondern aktualisiert die bestehende, per OAuth-Callback bereits angelegte User-Zeile. +export async function POST(request: Request) { + try { + const userData = await requireAuthWithData(); + + if (!isUser(userData)) { + return NextResponse.json( + { error: "Profil-Vervollständigung ist nur für reguläre Nutzer verfügbar." }, + { status: 403 } + ); + } + + const body = await request.json(); + const { birthday, plz, city, street, gender } = body; + + if (!birthday || !plz || !city || !street || !gender) { + return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); + } + + await prisma.user.update({ + where: { id: userData.id }, + data: { + birthday: new Date(birthday), + plz, + city, + street, + gender, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error in /api/auth/complete-profile:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 7d5fec6..aa809a4 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,6 +1,55 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/src/lib/supabase/server"; import { syncEmailAfterConfirmation } from "@/src/modules/settings/actions/personal-settings-actions"; +import { prisma } from "@/src/lib/prisma"; +import { Prisma } from "@prisma/client"; + +// Legt für einen neuen OAuth-Login (z.B. Google) einen User-Datensatz an, +// falls die E-Mail noch keiner der drei Rollen (User/Manager/Employee) zugeordnet ist. +// Bestehende Konten (gleiche E-Mail) werden dadurch automatisch erkannt statt dupliziert. +// Rückgabewert signalisiert, ob ein neuer User angelegt wurde (Google liefert kein +// Geburtsdatum/Adresse/Geschlecht - diese Felder müssen danach nachgetragen werden). +async function provisionOAuthUser(userId: string, email: string): Promise<{ isNewUser: boolean }> { + const [existingUser, existingManager, existingEmployee] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId } }), + prisma.manager.findUnique({ where: { id: userId } }), + prisma.employee.findUnique({ where: { email } }), + ]); + + if (existingUser || existingManager || existingEmployee) { + return { isNewUser: false }; + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const name = + (user?.user_metadata?.full_name as string | undefined) || + (user?.user_metadata?.name as string | undefined) || + null; + + const pbSrc = + (user?.user_metadata?.avatar_url as string | undefined) || + (user?.user_metadata?.picture as string | undefined) || + null; + + try { + await prisma.user.create({ + data: { id: userId, email, name, pbSrc }, + }); + } catch (err) { + // P2002 = Unique-Constraint-Verletzung: ein paralleler Callback-Aufruf + // hat den Datensatz bereits angelegt - kein Fehlerfall. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { + return { isNewUser: false }; + } + throw err; + } + + return { isNewUser: true }; +} export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; @@ -15,12 +64,17 @@ export async function GET(request: NextRequest) { } const supabase = await createClient(); - const { error } = await supabase.auth.exchangeCodeForSession(code); + const { + data: { session }, + error, + } = await supabase.auth.exchangeCodeForSession(code); if (error) { return NextResponse.redirect(new URL(next, request.url)); } + let isNewOAuthUser = false; + if (isEmailChange) { const syncResult = await syncEmailAfterConfirmation(); if (!syncResult.success) { @@ -28,11 +82,25 @@ export async function GET(request: NextRequest) { fallback.searchParams.set("emailSync", "failed"); return NextResponse.redirect(fallback); } + } else if (session?.user?.email) { + try { + const result = await provisionOAuthUser(session.user.id, session.user.email); + isNewOAuthUser = result.isNewUser; + } catch (err) { + console.error("OAuth-Provisionierung fehlgeschlagen:", err); + const fallback = new URL("/login", request.url); + fallback.searchParams.set("error", "oauth_provisioning_failed"); + return NextResponse.redirect(fallback); + } } + // Neu per OAuth angelegte User haben noch kein Geburtsdatum/Adresse/Geschlecht + // (Google liefert das nicht) - deshalb erst zur Profil-Vervollständigung leiten. + const target = isNewOAuthUser ? "/register/complete-profile" : next; + // Hard redirect so the browser re-fetches the page fully, // bypassing any Next.js client-side cache. - const url = new URL(next, request.url); + const url = new URL(target, request.url); url.searchParams.set("t", Date.now().toString()); return NextResponse.redirect(url); } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index a699cf0..4e66e42 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -127,6 +127,26 @@ export const useAuth = () => { } }; + /** + * Startet den Google-OAuth-Flow über Supabase. + * Der Browser wird zu Google weitergeleitet und landet danach + * auf /auth/callback, wo die Session final erstellt wird. + */ + const loginWithGoogle = async () => { + setErrors({}); + + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/dashboard`, + }, + }); + + if (error) { + toast.error("Google-Login fehlgeschlagen"); + } + }; + const registerAdmin = async (formData: AdminRegisterFormData) => { setErrors({}); setLoading(true); @@ -191,6 +211,7 @@ export const useAuth = () => { registerUser, registerAdmin, loginUser, + loginWithGoogle, setErrors, }; }; diff --git a/src/middleware.ts b/src/middleware.ts index 59e23c7..ca861fd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -30,8 +30,12 @@ export async function middleware(request: NextRequest) { } = await supabase.auth.getUser(); // Redirect verified authenticated users away from auth pages before the page renders. + // /register/complete-profile ist eine Ausnahme: die ist für bereits eingeloggte + // OAuth-Nutzer gedacht, die ihr Profil noch vervollständigen müssen. const authRoutes = ["/login", "/register", "/forgot-password"]; - const isAuthRoute = authRoutes.some((route) => request.nextUrl.pathname.startsWith(route)); + const isAuthRoute = + authRoutes.some((route) => request.nextUrl.pathname.startsWith(route)) && + request.nextUrl.pathname !== "/register/complete-profile"; if (isAuthRoute && user) { const url = request.nextUrl.clone(); diff --git a/src/modules/auth/ui/components/AuthButton.tsx b/src/modules/auth/ui/components/AuthButton.tsx index 86ee0ed..15036bc 100644 --- a/src/modules/auth/ui/components/AuthButton.tsx +++ b/src/modules/auth/ui/components/AuthButton.tsx @@ -1,11 +1,23 @@ interface AuthButtonProps { Icon: React.ComponentType<{ size?: number }>; label: string; + onClick?: () => void; } -const AuthButton: React.FC = ({ Icon, label }) => { +const AuthButton: React.FC = ({ Icon, label, onClick }) => { return ( -
+
{ + if (onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick(); + } + }} + className="flex gap-2 py-2 px-3 items-center justify-center rounded-xl border-1 border-blue text-blue w-full sm:w-fit cursor-pointer hover:scale-105 transition-all" + >

{label}

diff --git a/src/modules/auth/ui/views/complete-profile-view.tsx b/src/modules/auth/ui/views/complete-profile-view.tsx new file mode 100644 index 0000000..2f6130b --- /dev/null +++ b/src/modules/auth/ui/views/complete-profile-view.tsx @@ -0,0 +1,127 @@ +"use client"; +import { useState } from "react"; +import { toast } from "sonner"; +import { ZodError } from "zod"; +import { useRouter } from "next/navigation"; +import AuthSidebarComponent from "../components/AuthSidebarComponent"; +import AddressAutocompleteComponent from "@/src/components/layout/AddressAutocompleteComponent"; +import InputComponent from "@/src/components/layout/InputComponent"; +import DropdownComponent from "@/src/components/layout/DropdownComponent"; +import { Button } from "@/src/components/ui/button"; +import { Spinner } from "@/src/components/ui/spinner"; +import { completeProfileSchema } from "../../validation/userRegisterSchema"; + +const CompleteProfileView = () => { + const router = useRouter(); + + const [birthday, setBirthday] = useState(""); + const [street, setStreet] = useState(""); + const [plz, setPlz] = useState(""); + const [place, setPlace] = useState(""); + const [gender, setGender] = useState(""); + const [errors, setErrors] = useState>({}); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + setLoading(true); + + try { + completeProfileSchema.parse({ birthday, plz, place, street, gender }); + + const res = await fetch("/api/auth/complete-profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ birthday, plz, city: place, street, gender }), + }); + + const data = await res.json(); + + if (!res.ok) { + toast.error(data.error || "Speichern fehlgeschlagen"); + return; + } + + toast.success("Profil vervollständigt 🎉"); + router.push("/dashboard"); + } catch (err) { + if (err instanceof ZodError) { + const fieldErrors: Record = {}; + err.issues.forEach((issue) => { + const path = issue.path[0]; + if (typeof path === "string") fieldErrors[path] = issue.message; + }); + setErrors(fieldErrors); + return; + } + toast.error("Ein unbekannter Fehler ist aufgetreten."); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Sidebar */} +
+
+ +
+
+ + {/* Formular */} +
+
+

Profil vervollständigen

+ +
+ setBirthday(e.target.value)} + error={errors.birthday} + /> + { + setStreet(street); + setPlz(plz); + setPlace(place); + }} + streetError={errors.street} + plzError={errors.plz} + placeError={errors.place} + /> + + + + +
+
+
+ ); +}; + +export default CompleteProfileView; diff --git a/src/modules/auth/ui/views/login-view.tsx b/src/modules/auth/ui/views/login-view.tsx index e959800..800c6f1 100644 --- a/src/modules/auth/ui/views/login-view.tsx +++ b/src/modules/auth/ui/views/login-view.tsx @@ -20,7 +20,7 @@ const LoginView = () => { const [remember, setRemember] = useState(false); // ------------- Hooks ------------- - const { loginUser, errors, loading } = useAuth(); + const { loginUser, loginWithGoogle, errors, loading } = useAuth(); const router = useRouter(); const t = useTranslations("LoginView"); @@ -107,7 +107,7 @@ const LoginView = () => { {/* Social Login Buttons */}
- +
diff --git a/src/modules/auth/ui/views/register-user-view.tsx b/src/modules/auth/ui/views/register-user-view.tsx index cd3fd64..a198056 100644 --- a/src/modules/auth/ui/views/register-user-view.tsx +++ b/src/modules/auth/ui/views/register-user-view.tsx @@ -20,7 +20,7 @@ const RegisterUserView = () => { const [step, setStep] = useState(1); // ------------- Hooks ------------- - const { registerUser, loading, errors, setErrors } = useAuth(); + const { registerUser, loginWithGoogle, loading, errors, setErrors } = useAuth(); const router = useRouter(); const formData = useAuthFormStore(); @@ -212,7 +212,7 @@ const RegisterUserView = () => { {/* Social login options */}
- +
diff --git a/src/modules/auth/validation/userRegisterSchema.ts b/src/modules/auth/validation/userRegisterSchema.ts index a062754..6826a7d 100644 --- a/src/modules/auth/validation/userRegisterSchema.ts +++ b/src/modules/auth/validation/userRegisterSchema.ts @@ -63,3 +63,35 @@ export const userRegisterSchema = z }); export type UserRegisterSchema = z.infer; + +// Für OAuth-Signups (z.B. Google): Name/E-Mail/Passwort kommen bereits vom Provider, +// nur die fehlenden Profildaten müssen noch nachgetragen werden. +export const completeProfileSchema = z.object({ + birthday: z.iso + .date({ + message: "Ungültiges Datumsformat", + }) + .refine( + (date) => { + const selectedDate = new Date(date); + selectedDate.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return selectedDate <= today; + }, + { + message: "Geburtsdatum darf nicht in der Zukunft liegen", + } + ), + plz: z.string().min(1, "PLZ erforderlich"), + place: z.string().min(1, "Wohnort erforderlich"), + street: z.string().min(1, "Straße erforderlich"), + gender: z + .enum(["Men", "Woman", "Various"]) + .or(z.literal("")) + .refine((val) => val !== "", { + message: "Geschlecht ist erforderlich", + }), +}); + +export type CompleteProfileSchema = z.infer; From 127aacb081cde4366feca5c36e11ed178137ead2 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 28 Jul 2026 18:41:53 +0200 Subject: [PATCH 2/2] see comments --- src/app/api/auth/complete-profile/route.ts | 39 +++++++++++++------ src/app/auth/callback/route.ts | 10 ++++- src/hooks/useAuth.ts | 4 +- src/modules/auth/ui/components/AuthButton.tsx | 4 +- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/app/api/auth/complete-profile/route.ts b/src/app/api/auth/complete-profile/route.ts index 0c18c80..02c4541 100644 --- a/src/app/api/auth/complete-profile/route.ts +++ b/src/app/api/auth/complete-profile/route.ts @@ -1,28 +1,41 @@ import { requireAuthWithData, isUser } from "@/src/lib/auth/getUser"; import { prisma } from "@/src/lib/prisma"; +import { completeProfileSchema } from "@/src/modules/auth/validation/userRegisterSchema"; +import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; // Trägt die Felder nach, die Google (oder andere OAuth-Provider) beim Signup // nicht liefern: Geburtsdatum, Adresse, Geschlecht. Legt keinen neuen Account an, // sondern aktualisiert die bestehende, per OAuth-Callback bereits angelegte User-Zeile. export async function POST(request: Request) { + // requireAuthWithData() löst bei fehlender Session einen next/navigation-Redirect aus, + // der als spezieller Error implementiert ist - darf nicht im try/catch landen, + // sonst wird daraus fälschlich ein 500 statt der Weiterleitung zu /login. + const userData = await requireAuthWithData(); + + if (!isUser(userData)) { + return NextResponse.json( + { error: "Profil-Vervollständigung ist nur für reguläre Nutzer verfügbar." }, + { status: 403 } + ); + } + try { - const userData = await requireAuthWithData(); - - if (!isUser(userData)) { - return NextResponse.json( - { error: "Profil-Vervollständigung ist nur für reguläre Nutzer verfügbar." }, - { status: 403 } - ); - } - const body = await request.json(); - const { birthday, plz, city, street, gender } = body; + const parsed = completeProfileSchema.safeParse({ + birthday: body.birthday, + plz: body.plz, + place: body.city, + street: body.street, + gender: body.gender, + }); - if (!birthday || !plz || !city || !street || !gender) { - return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); + if (!parsed.success) { + return NextResponse.json({ error: "Ungültige Profildaten" }, { status: 400 }); } + const { birthday, plz, place: city, street, gender } = parsed.data; + await prisma.user.update({ where: { id: userData.id }, data: { @@ -34,6 +47,8 @@ export async function POST(request: Request) { }, }); + (revalidateTag as (tag: string, type: "max") => void)(`user-data-${userData.email}`, "max"); + return NextResponse.json({ success: true }); } catch (error) { console.error("Error in /api/auth/complete-profile:", error); diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index aa809a4..243851d 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -10,9 +10,15 @@ import { Prisma } from "@prisma/client"; // Rückgabewert signalisiert, ob ein neuer User angelegt wurde (Google liefert kein // Geburtsdatum/Adresse/Geschlecht - diese Felder müssen danach nachgetragen werden). async function provisionOAuthUser(userId: string, email: string): Promise<{ isNewUser: boolean }> { + // Per E-Mail prüfen (nicht per ID): die Supabase-Auth-ID des OAuth-Logins muss + // nicht mit der ID einer bereits bestehenden Zeile übereinstimmen (z.B. wenn die + // Prisma-Zeile manuell gelöscht wurde, der Supabase-Auth-User aber weiterbestand, + // oder die E-Mail schon einem Manager/Employee gehört). Ein reiner ID-Check würde + // hier fälschlich "kein bestehender Account" annehmen und an der Unique-Constraint + // auf email scheitern. const [existingUser, existingManager, existingEmployee] = await Promise.all([ - prisma.user.findUnique({ where: { id: userId } }), - prisma.manager.findUnique({ where: { id: userId } }), + prisma.user.findUnique({ where: { email } }), + prisma.manager.findUnique({ where: { email } }), prisma.employee.findUnique({ where: { email } }), ]); diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 4e66e42..d5e0ba4 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -135,10 +135,12 @@ export const useAuth = () => { const loginWithGoogle = async () => { setErrors({}); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin; + const { error } = await supabase.auth.signInWithOAuth({ provider: "google", options: { - redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/dashboard`, + redirectTo: `${siteUrl}/auth/callback?next=/dashboard`, }, }); diff --git a/src/modules/auth/ui/components/AuthButton.tsx b/src/modules/auth/ui/components/AuthButton.tsx index 15036bc..337a1c6 100644 --- a/src/modules/auth/ui/components/AuthButton.tsx +++ b/src/modules/auth/ui/components/AuthButton.tsx @@ -7,8 +7,8 @@ interface AuthButtonProps { const AuthButton: React.FC = ({ Icon, label, onClick }) => { return (
{ if (onClick && (e.key === "Enter" || e.key === " ")) {