diff --git a/apps/backend/src/domains/users/auth/auth.service.google.spec.ts b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts index 0da63785..2b89ed7d 100644 --- a/apps/backend/src/domains/users/auth/auth.service.google.spec.ts +++ b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts @@ -441,7 +441,56 @@ describe("AuthService Google OAuth", () => { }); it("consumes pending Google onboarding sessions so reuse fails", async () => { - redis.getDel.mockResolvedValueOnce(null); + redis.getDel + .mockResolvedValueOnce( + JSON.stringify({ + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + providerAvatarUrl: "https://lh3.googleusercontent.com/avatar", + prefill: {}, + redirectTo: "/explore", + createdAt: new Date().toISOString(), + }), + ) + .mockResolvedValueOnce(null); + prisma.user.create.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + phone: "+2348012345678", + username: "user483729", + displayName: "Buyer", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + firstName: "Buyer", + lastName: "One", + role: UserRole.USER, + emailVerified: true, + phoneVerified: false, + profilePhotoUrl: "https://lh3.googleusercontent.com/avatar", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + const firstResult = await service.completeGoogleOnboardingSession( + "pending-session", + { + firstName: "Buyer", + lastName: "One", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + phone: "+2348012345678", + }, + {}, + ); + + expect(firstResult).toEqual( + expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + }), + ); + expect(redis.getDel).toHaveBeenCalledWith( + expect.stringMatching(/^auth:google:onboarding:/), + ); await expect( service.completeGoogleOnboardingSession( diff --git a/apps/web/src/app/(auth)/register/google/complete/page.tsx b/apps/web/src/app/(auth)/register/google/complete/page.tsx new file mode 100644 index 00000000..1aa7a050 --- /dev/null +++ b/apps/web/src/app/(auth)/register/google/complete/page.tsx @@ -0,0 +1,402 @@ +"use client"; + +import { Suspense, useEffect, useMemo, useState } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { CheckCircle, Mail, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { PageLoadingState } from "@/components/ui/brand-loader"; +import { + completeGoogleOnboarding, + getGoogleOnboardingPrefill, + type GoogleOnboardingPrefill, +} from "@/lib/auth"; +import type { ApiError } from "@/lib/api"; + +const PRIMARY_LINK_CLASS = + "inline-flex h-[52px] min-h-[52px] w-full items-center justify-center rounded-full bg-[var(--color-saffron)] px-8 text-base font-medium text-[var(--color-espresso)] font-cabinet transition-colors hover:bg-[var(--color-saffron-dark)]"; +const GHOST_LINK_CLASS = + "inline-flex h-[52px] min-h-[52px] w-full items-center justify-center rounded-full bg-transparent px-8 text-base text-[var(--foreground)] font-cabinet transition-colors hover:bg-[var(--surface-muted)]"; + +function calcAge(dobString: string): number { + const dob = new Date(dobString); + const now = new Date(); + const age = + now.getFullYear() - + dob.getFullYear() - + (now < new Date(now.getFullYear(), dob.getMonth(), dob.getDate()) ? 1 : 0); + return age; +} + +const NIGERIAN_PHONE_ERROR = "Enter a valid Nigerian mobile number."; + +function normalizeNigerianPhone(input: string): string | null { + const value = input.trim(); + + if (/^0[789]\d{9}$/.test(value)) { + return `+234${value.slice(1)}`; + } + + if (/^[789]\d{9}$/.test(value)) { + return `+234${value}`; + } + + if (/^\+234[789]\d{9}$/.test(value)) { + return value; + } + + return null; +} + +const completionSchema = z.object({ + firstName: z + .string() + .trim() + .min(1, "First name is required") + .max(60, "At most 60 characters"), + lastName: z + .string() + .trim() + .min(1, "Last name is required") + .max(60, "At most 60 characters"), + dateOfBirth: z + .string() + .min(1, "Date of birth is required") + .refine((value) => { + const parsed = new Date(value); + return !Number.isNaN(parsed.getTime()); + }, "Enter a valid date") + .refine((value) => new Date(value) < new Date(), "Date must be in the past") + .refine( + (value) => calcAge(value) >= 13, + "You must be at least 13 to use Twizrr", + ), + phone: z + .string() + .trim() + .refine((value) => normalizeNigerianPhone(value) !== null, { + message: NIGERIAN_PHONE_ERROR, + }) + .transform((value) => normalizeNigerianPhone(value) ?? value), +}); + +type CompletionFields = z.infer; + +type LoadState = + | { status: "loading" } + | { status: "ready"; prefill: GoogleOnboardingPrefill } + | { status: "error"; message: string }; + +function getGoogleOnboardingErrorMessage(error: ApiError): string { + if ( + error.status === 404 || + error.status === 410 || + error.code === "GOOGLE_ONBOARDING_SESSION_EXPIRED" || + error.code === "GOOGLE_ONBOARDING_SESSION_INVALID" + ) { + return "This Google sign-in session has expired. Please start again."; + } + + return error.message || "Google onboarding could not be loaded."; +} + +function getCompletionErrorMessage(error: ApiError): string { + if (error.code === "PHONE_TAKEN") { + return "That phone number is already linked to another twizrr account."; + } + + if (error.code === "EMAIL_TAKEN") { + return "That Google email is already linked to another twizrr account."; + } + + if (error.code === "AGE_RESTRICTED") { + return "You must be at least 13 to use Twizrr."; + } + + if ( + error.code === "GOOGLE_ONBOARDING_SESSION_EXPIRED" || + error.code === "GOOGLE_ONBOARDING_SESSION_INVALID" + ) { + return "This Google sign-in session has expired. Please start again."; + } + + return error.message || "Account setup could not be completed."; +} + +function MissingSessionState() { + return ( +
+

+ Google sign-in session missing +

+

+ Start Google sign-in again so we can finish setting up your Twizrr + account securely. +

+
+ + Back to register + + + Go to login + +
+
+ ); +} + +function GoogleOnboardingForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const sessionId = searchParams.get("session"); + + const [loadState, setLoadState] = useState({ status: "loading" }); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(completionSchema), + defaultValues: { + firstName: "", + lastName: "", + dateOfBirth: "", + phone: "", + }, + }); + const { reset } = form; + + useEffect(() => { + let cancelled = false; + + if (!sessionId) { + setLoadState({ + status: "error", + message: "Google sign-in session is missing.", + }); + return; + } + + setLoadState({ status: "loading" }); + getGoogleOnboardingPrefill(sessionId) + .then((prefill) => { + if (cancelled) return; + setLoadState({ status: "ready", prefill }); + reset({ + firstName: prefill.firstName ?? "", + lastName: prefill.lastName ?? "", + dateOfBirth: "", + phone: "", + }); + }) + .catch((error: ApiError) => { + if (cancelled) return; + setLoadState({ + status: "error", + message: getGoogleOnboardingErrorMessage(error), + }); + }); + + return () => { + cancelled = true; + }; + }, [reset, sessionId]); + + const avatarInitial = useMemo(() => { + if (loadState.status !== "ready") return "G"; + return ( + loadState.prefill.firstName?.charAt(0) || + loadState.prefill.providerEmail.charAt(0) || + "G" + ).toUpperCase(); + }, [loadState]); + + const onSubmit: SubmitHandler = async (fields) => { + if (!sessionId) { + setSubmitError("Google sign-in session is missing."); + return; + } + + setIsSubmitting(true); + setSubmitError(null); + + try { + const result = await completeGoogleOnboarding(sessionId, { + firstName: fields.firstName, + lastName: fields.lastName, + dateOfBirth: new Date(fields.dateOfBirth), + phone: fields.phone, + }); + router.push(result.redirectTo || "/explore"); + } catch (error) { + setSubmitError(getCompletionErrorMessage(error as ApiError)); + } finally { + setIsSubmitting(false); + } + }; + + if (!sessionId) { + return ; + } + + if (loadState.status === "loading") { + return ; + } + + if (loadState.status === "error") { + return ( +
+

+ Google setup could not continue +

+

+ {loadState.message} +

+
+ + Start again + + + Go to login + +
+
+ ); + } + + const { prefill } = loadState; + + return ( +
+
+
+ {prefill.providerAvatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + avatarInitial + )} +
+
+

+

+

+ {prefill.providerEmail} +

+
+
+ +

+ Finish setting up your Twizrr account +

+

+ Google confirms your email only. Add your name, birthday, and Nigerian + phone number to finish account setup. +

+ +
+
+
+
+
+
+ +
+ + + + + + + + + {submitError && ( +

+ {submitError} +

+ )} + + +
+
+ ); +} + +export default function GoogleOnboardingCompletePage() { + return ( + }> + + + ); +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 00000000..968ba0cb --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,39 @@ +import { api } from "@/lib/api"; + +export type GoogleOnboardingPrefill = { + providerEmail: string; + providerEmailVerified: boolean; + firstName: string | null; + lastName: string | null; + displayName: string | null; + providerAvatarUrl: string | null; +}; + +export type CompleteGoogleOnboardingPayload = { + firstName: string; + lastName: string; + dateOfBirth: Date; + phone: string; +}; + +export type CompleteGoogleOnboardingResponse = { + redirectTo: string; +}; + +export function getGoogleOnboardingPrefill( + sessionId: string, +): Promise { + return api.get( + `/auth/google/onboarding/${encodeURIComponent(sessionId)}`, + ); +} + +export function completeGoogleOnboarding( + sessionId: string, + payload: CompleteGoogleOnboardingPayload, +): Promise { + return api.post( + `/auth/google/onboarding/${encodeURIComponent(sessionId)}/complete`, + payload, + ); +}