mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #135 from Since-3/feature/social-login
added google social login
This commit is contained in:
commit
3db6541380
11 changed files with 355 additions and 9 deletions
|
|
@ -15,6 +15,10 @@ const nextConfig: NextConfig = {
|
|||
protocol: "https",
|
||||
hostname: "dlvlsahpuahlymvemzkl.supabase.co",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
13
src/app/(auth)/register/complete-profile/page.tsx
Normal file
13
src/app/(auth)/register/complete-profile/page.tsx
Normal 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 />;
|
||||
}
|
||||
57
src/app/api/auth/complete-profile/route.ts
Normal file
57
src/app/api/auth/complete-profile/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
127
src/modules/auth/ui/views/complete-profile-view.tsx
Normal file
127
src/modules/auth/ui/views/complete-profile-view.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue