Merge pull request #135 from Since-3/feature/social-login

added google social login
This commit is contained in:
Rajpreet0 2026-07-28 18:45:11 +02:00 • committed by GitHub
commit 3db6541380
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 355 additions and 9 deletions

View file

@ -15,6 +15,10 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "dlvlsahpuahlymvemzkl.supabase.co",
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
],
},
};

View file

@ -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 <CompleteProfileView />;
}

View file

@ -0,0 +1,57 @@
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 body = await request.json();
const parsed = completeProfileSchema.safeParse({
birthday: body.birthday,
plz: body.plz,
place: body.city,
street: body.street,
gender: body.gender,
});
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: {
birthday: new Date(birthday),
plz,
city,
street,
gender,
},
});
(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);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View file

@ -1,6 +1,61 @@
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 }> {
// 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: { email } }),
prisma.manager.findUnique({ where: { email } }),
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 +70,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 +88,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);
}

View file

@ -127,6 +127,28 @@ 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 siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin;
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${siteUrl}/auth/callback?next=/dashboard`,
},
});
if (error) {
toast.error("Google-Login fehlgeschlagen");
}
};
const registerAdmin = async (formData: AdminRegisterFormData) => {
setErrors({});
setLoading(true);
@ -191,6 +213,7 @@ export const useAuth = () => {
registerUser,
registerAdmin,
loginUser,
loginWithGoogle,
setErrors,
};
};

View file

@ -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();

View file

@ -1,11 +1,23 @@
interface AuthButtonProps {
Icon: React.ComponentType<{ size?: number }>;
label: string;
onClick?: () => void;
}
const AuthButton: React.FC<AuthButtonProps> = ({ Icon, label }) => {
const AuthButton: React.FC<AuthButtonProps> = ({ Icon, label, onClick }) => {
return (
<div 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">
<div
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
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"
>
<Icon size={20} />
<p className="ml-2 mt-1">{label}</p>
</div>

View file

@ -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<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="flex min-h-screen">
{/* Sidebar */}
<div className="hidden lg:block lg:w-[40%]">
<div className="hidden lg:block fixed left-0 top-0 h-screen w-[40%] p-2 z-10">
<AuthSidebarComponent
title="Fast geschafft"
description="Nur noch ein paar Angaben, damit wir dir alle Funktionen anbieten können."
footer="info@kumasy.de"
/>
</div>
</div>
{/* Formular */}
<div className="w-full lg:w-[60%] lg:ml-auto flex justify-center items-center min-h-screen px-4">
<div className="w-full max-w-lg flex flex-col gap-4 z-20">
<h1 className="text-2xl font-extrabold text-blue mb-8 mt-8">Profil vervollständigen</h1>
<form onSubmit={handleSubmit} className="space-y-5">
<InputComponent
isLabel
label="Geburtsdatum"
type="date"
id="complete-profile-birthday"
max={new Date().toISOString().split("T")[0]}
value={birthday}
onChange={(e) => setBirthday(e.target.value)}
error={errors.birthday}
/>
<AddressAutocompleteComponent
idPrefix="complete-profile"
streetValue={street}
plzValue={plz}
placeValue={place}
onStreetChange={setStreet}
onPlzChange={setPlz}
onPlaceChange={setPlace}
onSelect={({ street, plz, place }) => {
setStreet(street);
setPlz(plz);
setPlace(place);
}}
streetError={errors.street}
plzError={errors.plz}
placeError={errors.place}
/>
<DropdownComponent
label="Geschlecht"
selected={gender}
onSelect={setGender}
error={errors.gender}
/>
<Button type="submit" disabled={loading} className="w-full mt-6">
{loading ? <Spinner /> : "Speichern"}
</Button>
</form>
</div>
</div>
</div>
);
};
export default CompleteProfileView;

View file

@ -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 */}
<div className="flex flex-col sm:flex-row gap-4 mt-4">
<AuthButton Icon={FaGoogle} label={t("loginWithGoogle")} />
<AuthButton Icon={FaGoogle} label={t("loginWithGoogle")} onClick={loginWithGoogle} />
<AuthButton Icon={FaFacebook} label={t("loginWithFacebook")} />
</div>

View file

@ -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 */}
<div className="flex flex-col sm:flex-row gap-4 mt-4">
<AuthButton Icon={FaGoogle} label="Login mit Google" />
<AuthButton Icon={FaGoogle} label="Login mit Google" onClick={loginWithGoogle} />
<AuthButton Icon={FaFacebook} label="Login mit Facebook" />
</div>

View file

@ -63,3 +63,35 @@ export const userRegisterSchema = z
});
export type UserRegisterSchema = z.infer<typeof userRegisterSchema>;
// 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<typeof completeProfileSchema>;