From 3f0f3509bdb96626a9703000ce621155cbf240f4 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 19 May 2026 20:13:51 +1200 Subject: [PATCH 1/2] fix: fixed linting issues --- cms/.env.example | 3 + cms/src/app/signup/check-email/route.ts | 56 +++++ cms/src/app/stripe/_lib/activatePaidSignup.ts | 118 ++++++++++ cms/src/app/stripe/_lib/signupPassword.ts | 48 ++++ cms/src/app/stripe/checkout/confirm/route.ts | 62 ++++++ cms/src/app/stripe/checkout/route.ts | 147 +++++++++++-- cms/src/app/stripe/webhook/route.ts | 18 +- cms/src/collections/Members.ts | 38 +++- cms/src/collections/Users.ts | 118 +++++++++- cms/src/payload-types.ts | 50 +++++ cms/tests/helpers/seedUser.ts | 3 +- web/README.md | 14 ++ web/package-lock.json | 120 ++++++++++ web/src/app/api/auth/google/callback/route.ts | 133 +++++++++++ web/src/app/api/auth/google/route.ts | 44 ++++ web/src/app/api/auth/google/session/route.ts | 31 +++ web/src/app/api/checkout/route.ts | 60 ++++- web/src/app/api/signup/check-email/route.ts | 47 ++++ .../signup/_components/AccountSignUpStep.tsx | 100 +++++++++ .../app/signup/_components/ContactStep.tsx | 33 --- web/src/app/signup/_components/SignupForm.tsx | 206 +++++++++++++++--- web/src/app/signup/_components/types.ts | 6 +- web/src/app/signup/success/page.tsx | 56 +++-- web/src/components/InputField.tsx | 6 + web/src/components/Navbar.tsx | 2 +- web/src/lib/googleSignupSession.ts | 81 +++++++ 26 files changed, 1490 insertions(+), 110 deletions(-) create mode 100644 cms/src/app/signup/check-email/route.ts create mode 100644 cms/src/app/stripe/_lib/activatePaidSignup.ts create mode 100644 cms/src/app/stripe/_lib/signupPassword.ts create mode 100644 cms/src/app/stripe/checkout/confirm/route.ts create mode 100644 web/src/app/api/auth/google/callback/route.ts create mode 100644 web/src/app/api/auth/google/route.ts create mode 100644 web/src/app/api/auth/google/session/route.ts create mode 100644 web/src/app/api/signup/check-email/route.ts create mode 100644 web/src/app/signup/_components/AccountSignUpStep.tsx create mode 100644 web/src/lib/googleSignupSession.ts diff --git a/cms/.env.example b/cms/.env.example index 5eae80e..189f114 100644 --- a/cms/.env.example +++ b/cms/.env.example @@ -8,3 +8,6 @@ STRIPE_PRICE_ID=price... # URL of the public web app (used for Stripe success/cancel redirects) WEB_URL=http://localhost:3000 + +# Secret used to temporarily encrypt signup passwords until Stripe confirms payment +SIGNUP_ENCRYPTION_KEY=YOUR_SIGNUP_ENCRYPTION_KEY_HERE diff --git a/cms/src/app/signup/check-email/route.ts b/cms/src/app/signup/check-email/route.ts new file mode 100644 index 0000000..0f9a9e9 --- /dev/null +++ b/cms/src/app/signup/check-email/route.ts @@ -0,0 +1,56 @@ +import configPromise from '@payload-config' +import { getPayload } from 'payload' + +function normalizeEmail(email: string) { + return email.trim().toLowerCase() +} + +function isValidEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +} + +export async function POST(request: Request) { + let body: { email?: string } + + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const email = normalizeEmail(body.email ?? '') + + if (!isValidEmail(email)) { + return Response.json({ error: 'Valid email is required' }, { status: 400 }) + } + + const payload = await getPayload({ config: configPromise }) + + const [existingUser, existingRegisteredMember] = await Promise.all([ + payload.find({ + collection: 'users', + limit: 1, + overrideAccess: true, + pagination: false, + where: { email: { equals: email } }, + }), + payload.find({ + collection: 'members', + limit: 1, + overrideAccess: true, + pagination: false, + where: { + and: [{ email: { equals: email } }, { status: { not_equals: 'pending' } }], + }, + }), + ]) + + if (existingUser.docs.length > 0 || existingRegisteredMember.docs.length > 0) { + return Response.json( + { available: false, error: 'An account with this email already exists' }, + { status: 409 }, + ) + } + + return Response.json({ available: true }) +} diff --git a/cms/src/app/stripe/_lib/activatePaidSignup.ts b/cms/src/app/stripe/_lib/activatePaidSignup.ts new file mode 100644 index 0000000..4ff5582 --- /dev/null +++ b/cms/src/app/stripe/_lib/activatePaidSignup.ts @@ -0,0 +1,118 @@ +import type { Payload } from 'payload' + +import { decryptSignupPassword } from './signupPassword' + +type ActivatePaidSignupArgs = { + memberId: number + payload: Payload + stripeCustomerId?: null | string +} + +type MemberWithSignupPassword = { + areaOfStudy?: null | string + authProvider?: 'email' | 'google' | null + email: string + encryptedSignupPassword?: null | string + ethnicity?: 'chinese' | 'eurasian' | 'indian' | 'malay' | 'other' | null + firstName?: null | string + gender?: 'female' | 'male' | 'non-binary' | 'prefer-not-to-say' | null + googleSub?: null | string + id: number + lastName?: null | string + membershipExpiryDate?: null | string + name: string + phone: string + returningMember?: boolean | null + studentId?: null | string + upi?: null | string + yearOfUniversity?: '1' | '2' | '3' | '4' | '5+' | 'postgrad' | null +} + +function splitName(name: string) { + const [firstName, ...rest] = name.trim().split(/\s+/) + + return { + firstName: firstName || undefined, + lastName: rest.join(' ') || undefined, + } +} + +export async function activatePaidSignup({ + memberId, + payload, + stripeCustomerId, +}: ActivatePaidSignupArgs) { + const member = (await payload.findByID({ + collection: 'members', + id: memberId, + overrideAccess: true, + showHiddenFields: true, + })) as MemberWithSignupPassword + + const existingUser = await payload.find({ + collection: 'users', + limit: 1, + overrideAccess: true, + pagination: false, + where: { + email: { + equals: member.email, + }, + }, + }) + + const fallbackName = splitName(member.name) + const userData = { + name: member.name, + authProvider: member.authProvider || 'email', + googleSub: member.googleSub, + firstName: member.firstName || fallbackName.firstName, + lastName: member.lastName || fallbackName.lastName, + phone: member.phone, + membershipStatus: 'active' as const, + membershipExpiryDate: member.membershipExpiryDate, + stripeCustomerId, + upi: member.upi, + studentId: member.studentId, + areaOfStudy: member.areaOfStudy, + yearOfUniversity: member.yearOfUniversity, + gender: member.gender, + ethnicity: member.ethnicity, + returningMember: member.returningMember ?? false, + } + + if (existingUser.docs.length === 0) { + if (!member.encryptedSignupPassword) { + throw new Error('Paid member is missing encrypted signup password') + } + + await payload.create({ + collection: 'users', + overrideAccess: true, + data: { + email: member.email, + password: decryptSignupPassword(member.encryptedSignupPassword), + role: 'member', + ...userData, + }, + }) + } else { + await payload.update({ + collection: 'users', + id: existingUser.docs[0].id, + overrideAccess: true, + data: userData, + }) + } + + await payload.update({ + collection: 'members', + id: memberId, + overrideAccess: true, + data: { + status: 'active', + encryptedSignupPassword: null, + ...(stripeCustomerId ? { stripeCustomerId } : {}), + }, + }) +} diff --git a/cms/src/app/stripe/_lib/signupPassword.ts b/cms/src/app/stripe/_lib/signupPassword.ts new file mode 100644 index 0000000..9b82b0f --- /dev/null +++ b/cms/src/app/stripe/_lib/signupPassword.ts @@ -0,0 +1,48 @@ +import crypto from 'crypto' + +const algorithm = 'aes-256-gcm' +const version = 'v1' + +function getEncryptionKey() { + const secret = process.env.SIGNUP_ENCRYPTION_KEY || process.env.PAYLOAD_SECRET + + if (!secret) { + throw new Error('SIGNUP_ENCRYPTION_KEY or PAYLOAD_SECRET must be configured') + } + + return crypto.createHash('sha256').update(secret).digest() +} + +export function encryptSignupPassword(password: string) { + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv(algorithm, getEncryptionKey(), iv) + const encrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + + return [ + version, + iv.toString('base64url'), + authTag.toString('base64url'), + encrypted.toString('base64url'), + ].join(':') +} + +export function decryptSignupPassword(value: string) { + const [storedVersion, iv, authTag, encrypted] = value.split(':') + + if (storedVersion !== version || !iv || !authTag || !encrypted) { + throw new Error('Invalid encrypted signup password') + } + + const decipher = crypto.createDecipheriv( + algorithm, + getEncryptionKey(), + Buffer.from(iv, 'base64url'), + ) + decipher.setAuthTag(Buffer.from(authTag, 'base64url')) + + return Buffer.concat([ + decipher.update(Buffer.from(encrypted, 'base64url')), + decipher.final(), + ]).toString('utf8') +} diff --git a/cms/src/app/stripe/checkout/confirm/route.ts b/cms/src/app/stripe/checkout/confirm/route.ts new file mode 100644 index 0000000..747a176 --- /dev/null +++ b/cms/src/app/stripe/checkout/confirm/route.ts @@ -0,0 +1,62 @@ +import { NextRequest } from 'next/server' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import Stripe from 'stripe' +import { activatePaidSignup } from '../../_lib/activatePaidSignup' + +export const GET = async (request: NextRequest) => { + const sessionId = request.nextUrl.searchParams.get('session_id') + + if (!sessionId) { + return Response.json({ error: 'Missing session_id' }, { status: 400 }) + } + + const stripeSecretKey = process.env.STRIPE_SECRET_KEY + + if (!stripeSecretKey) { + return Response.json({ error: 'Stripe not configured' }, { status: 500 }) + } + + const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) + + let session: Stripe.Checkout.Session + try { + session = await stripe.checkout.sessions.retrieve(sessionId) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to retrieve Stripe session' + return Response.json({ error: message }, { status: 502 }) + } + + const memberId = Number(session.metadata?.memberId) + const stripeCustomerId = + typeof session.customer === 'string' ? session.customer : (session.customer?.id ?? null) + + if (session.payment_status !== 'paid') { + return Response.json({ + confirmed: false, + paymentStatus: session.payment_status, + }) + } + + if (!Number.isFinite(memberId)) { + return Response.json({ error: 'Stripe session is missing member metadata' }, { status: 422 }) + } + + const payload = await getPayload({ config: configPromise }) + + try { + await activatePaidSignup({ + memberId, + payload, + stripeCustomerId, + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to activate paid signup' + return Response.json({ error: message }, { status: 500 }) + } + + return Response.json({ + confirmed: true, + paymentStatus: session.payment_status, + }) +} diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index b6982ba..4a7d896 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -1,12 +1,43 @@ import { NextRequest } from 'next/server' import configPromise from '@payload-config' +import crypto from 'crypto' import { getPayload } from 'payload' import Stripe from 'stripe' +import { encryptSignupPassword } from '../_lib/signupPassword' + +function getWebUrl(request: NextRequest) { + const forwardedWebUrl = request.headers.get('x-web-url') + const configuredWebUrl = process.env.WEB_URL + const fallbackWebUrl = 'http://localhost:3000' + + for (const value of [forwardedWebUrl, configuredWebUrl, fallbackWebUrl]) { + if (!value) continue + + try { + const url = new URL(value) + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.origin + } + } catch { + continue + } + } + + return fallbackWebUrl +} + +function normalizeEmail(email: string) { + return email.trim().toLowerCase() +} export const POST = async (request: NextRequest) => { let body: { + firstName?: string + lastName?: string name?: string + authProvider?: 'email' | 'google' email?: string + googleSub?: string password?: string phone?: string upi?: string @@ -24,8 +55,12 @@ export const POST = async (request: NextRequest) => { } const { + firstName, + lastName, name, + authProvider: requestedAuthProvider, email, + googleSub, password, phone, upi, @@ -36,10 +71,14 @@ export const POST = async (request: NextRequest) => { ethnicity, returningMember, } = body + const authProvider = requestedAuthProvider === 'google' ? 'google' : 'email' + const normalizedEmail = email ? normalizeEmail(email) : '' + if ( !name || - !email || - !password || + !normalizedEmail || + (authProvider === 'email' && !password) || + (authProvider === 'google' && !googleSub) || !phone || !upi || !studentId || @@ -52,7 +91,7 @@ export const POST = async (request: NextRequest) => { return Response.json( { error: - 'Missing required fields: name, email, password, phone, upi, studentId, areaOfStudy, yearOfUniversity, gender, ethnicity, returningMember', + 'Missing required fields: name, email, phone, upi, studentId, areaOfStudy, yearOfUniversity, gender, ethnicity, returningMember', }, { status: 400 }, ) @@ -60,7 +99,7 @@ export const POST = async (request: NextRequest) => { const stripeSecretKey = process.env.STRIPE_SECRET_KEY const priceId = process.env.STRIPE_PRICE_ID - const webUrl = process.env.WEB_URL || 'http://localhost:3000' + const webUrl = getWebUrl(request) if (!stripeSecretKey || !priceId) { return Response.json({ error: 'Stripe not configured' }, { status: 500 }) @@ -68,6 +107,11 @@ export const POST = async (request: NextRequest) => { const payload = await getPayload({ config: configPromise }) const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) + const memberPassword = + authProvider === 'google' ? crypto.randomBytes(32).toString('base64url') : (password ?? '') + const encryptedSignupPassword = encryptSignupPassword(memberPassword) + const trimmedFirstName = firstName?.trim() + const trimmedLastName = lastName?.trim() // Reuse an existing pending member so a transient Stripe failure doesn't // permanently block the email from retrying. @@ -75,9 +119,35 @@ export const POST = async (request: NextRequest) => { let memberCreatedHere = false let existingStripeCustomerId: string | null | undefined + const [existingUser, existingRegisteredMember] = await Promise.all([ + payload.find({ + collection: 'users', + limit: 1, + overrideAccess: true, + pagination: false, + where: { email: { equals: normalizedEmail } }, + }), + payload.find({ + collection: 'members', + limit: 1, + overrideAccess: true, + pagination: false, + where: { + and: [{ email: { equals: normalizedEmail } }, { status: { not_equals: 'pending' } }], + }, + }), + ]) + + if (existingUser.docs.length > 0 || existingRegisteredMember.docs.length > 0) { + return Response.json({ error: 'An account with this email already exists' }, { status: 409 }) + } + const existing = await payload.find({ collection: 'members', - where: { and: [{ email: { equals: email } }, { status: { equals: 'pending' } }] }, + overrideAccess: true, + where: { + and: [{ email: { equals: normalizedEmail } }, { status: { equals: 'pending' } }], + }, limit: 1, }) @@ -93,8 +163,13 @@ export const POST = async (request: NextRequest) => { overrideAccess: true, data: { name, + firstName: trimmedFirstName, + lastName: trimmedLastName, + authProvider, phone, - password, + googleSub: authProvider === 'google' ? googleSub : null, + password: memberPassword, + encryptedSignupPassword, upi, studentId, areaOfStudy, @@ -114,8 +189,13 @@ export const POST = async (request: NextRequest) => { overrideAccess: true, data: { name, - email, - password, + firstName: trimmedFirstName, + lastName: trimmedLastName, + authProvider, + email: normalizedEmail, + googleSub: authProvider === 'google' ? googleSub : undefined, + password: memberPassword, + encryptedSignupPassword, phone, upi, studentId, @@ -132,7 +212,10 @@ export const POST = async (request: NextRequest) => { } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to create member' if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) { - return Response.json({ error: 'An account with this email already exists' }, { status: 409 }) + return Response.json( + { error: 'An account with this email already exists' }, + { status: 409 }, + ) } return Response.json({ error: message }, { status: 400 }) } @@ -146,7 +229,7 @@ export const POST = async (request: NextRequest) => { customerId = existingStripeCustomerId } else { try { - const customer = await stripe.customers.create({ email, name }) + const customer = await stripe.customers.create({ email: normalizedEmail, name }) customerId = customer.id customerCreatedHere = true // Persist the customer ID so future retries can reuse it. @@ -160,7 +243,9 @@ export const POST = async (request: NextRequest) => { .catch(() => {}) } catch (err: unknown) { if (memberCreatedHere) { - await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) + await payload + .delete({ collection: 'members', id: memberId, overrideAccess: true }) + .catch(() => {}) } const message = err instanceof Error ? err.message : 'Failed to create Stripe customer' return Response.json({ error: message }, { status: 502 }) @@ -178,12 +263,24 @@ export const POST = async (request: NextRequest) => { }) if (!session.url) { - if (memberCreatedHere) { - await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) - if (customerCreatedHere) { - await stripe.customers.del(customerId).catch(() => {}) + if (customerCreatedHere) { + await stripe.customers.del(customerId).catch(() => {}) + if (!memberCreatedHere) { + await payload + .update({ + collection: 'members', + id: memberId, + overrideAccess: true, + data: { stripeCustomerId: null }, + }) + .catch(() => {}) } } + if (memberCreatedHere) { + await payload + .delete({ collection: 'members', id: memberId, overrideAccess: true }) + .catch(() => {}) + } return Response.json( { error: 'Stripe did not provide a checkout URL for the created session' }, { status: 502 }, @@ -192,12 +289,24 @@ export const POST = async (request: NextRequest) => { return Response.json({ checkoutUrl: session.url }) } catch (err: unknown) { - if (memberCreatedHere) { - await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) - if (customerCreatedHere) { - await stripe.customers.del(customerId).catch(() => {}) + if (customerCreatedHere) { + await stripe.customers.del(customerId).catch(() => {}) + if (!memberCreatedHere) { + await payload + .update({ + collection: 'members', + id: memberId, + overrideAccess: true, + data: { stripeCustomerId: null }, + }) + .catch(() => {}) } } + if (memberCreatedHere) { + await payload + .delete({ collection: 'members', id: memberId, overrideAccess: true }) + .catch(() => {}) + } const message = err instanceof Error ? err.message : 'Failed to create Stripe checkout session' return Response.json({ error: message }, { status: 502 }) } diff --git a/cms/src/app/stripe/webhook/route.ts b/cms/src/app/stripe/webhook/route.ts index 872323e..69f29aa 100644 --- a/cms/src/app/stripe/webhook/route.ts +++ b/cms/src/app/stripe/webhook/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import configPromise from '@payload-config' import { getPayload } from 'payload' import Stripe from 'stripe' +import { activatePaidSignup } from '../_lib/activatePaidSignup' export const POST = async (request: NextRequest) => { const stripeSecretKey = process.env.STRIPE_SECRET_KEY @@ -38,7 +39,7 @@ export const POST = async (request: NextRequest) => { const { memberId: rawMemberId } = session.metadata ?? {} const stripeCustomerId = - typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null + typeof session.customer === 'string' ? session.customer : (session.customer?.id ?? null) const memberId = Number(rawMemberId) if (!Number.isFinite(memberId)) { @@ -52,17 +53,14 @@ export const POST = async (request: NextRequest) => { const payload = await getPayload({ config: configPromise }) try { - await payload.update({ - collection: 'members', - id: memberId, - data: { - status: 'active', - ...(stripeCustomerId ? { stripeCustomerId } : {}), - }, + await activatePaidSignup({ + memberId, + payload, + stripeCustomerId, }) } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to update member' - console.error('Stripe webhook: failed to update member', { memberId, error: message }) + const message = err instanceof Error ? err.message : 'Failed to activate paid signup' + console.error('Stripe webhook: failed to activate paid signup', { memberId, error: message }) // Return 500 so Stripe retries with exponential backoff return Response.json({ error: message }, { status: 500 }) } diff --git a/cms/src/collections/Members.ts b/cms/src/collections/Members.ts index 10fb5ff..6680449 100644 --- a/cms/src/collections/Members.ts +++ b/cms/src/collections/Members.ts @@ -15,26 +15,51 @@ export const Members: CollectionConfig = { type: 'text', required: true, }, + { + name: 'firstName', + type: 'text', + }, + { + name: 'lastName', + type: 'text', + }, { name: 'phone', type: 'text', required: true, }, + { + name: 'authProvider', + type: 'select', + defaultValue: 'email', + options: [ + { label: 'Email', value: 'email' }, + { label: 'Google', value: 'google' }, + ], + required: true, + }, + { + name: 'googleSub', + type: 'text', + admin: { + description: 'Google account subject identifier for OAuth signups.', + }, + }, { name: 'status', type: 'select', options: [ { label: 'ACTIVE', - value: 'active' + value: 'active', }, { label: 'EXPIRED', - value: 'expired' + value: 'expired', }, { label: 'PENDING', - value: 'pending' + value: 'pending', }, ], required: true, @@ -48,6 +73,11 @@ export const Members: CollectionConfig = { name: 'stripeCustomerId', type: 'text', }, + { + name: 'encryptedSignupPassword', + type: 'text', + hidden: true, + }, { name: 'emergencyContactName', type: 'text', @@ -106,5 +136,5 @@ export const Members: CollectionConfig = { type: 'checkbox', defaultValue: false, }, - ] + ], } diff --git a/cms/src/collections/Users.ts b/cms/src/collections/Users.ts index c683d0e..a27bb71 100644 --- a/cms/src/collections/Users.ts +++ b/cms/src/collections/Users.ts @@ -5,9 +5,125 @@ export const Users: CollectionConfig = { admin: { useAsTitle: 'email', }, + access: { + admin: ({ req }) => { + if (req.user?.collection !== 'users') return false + + return (req.user as { role?: string | null }).role !== 'member' + }, + }, auth: true, fields: [ // Email added by default - // Add more fields as needed + { + name: 'role', + type: 'select', + defaultValue: 'admin', + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'Member', value: 'member' }, + ], + required: true, + }, + { + name: 'name', + type: 'text', + }, + { + name: 'authProvider', + type: 'select', + defaultValue: 'email', + options: [ + { label: 'Email', value: 'email' }, + { label: 'Google', value: 'google' }, + ], + }, + { + name: 'googleSub', + type: 'text', + admin: { + description: 'Google account subject identifier for OAuth signups.', + }, + }, + { + name: 'firstName', + type: 'text', + }, + { + name: 'lastName', + type: 'text', + }, + { + name: 'phone', + type: 'text', + }, + { + name: 'membershipStatus', + type: 'select', + defaultValue: 'active', + options: [ + { label: 'Active', value: 'active' }, + { label: 'Expired', value: 'expired' }, + { label: 'Pending', value: 'pending' }, + ], + }, + { + name: 'membershipExpiryDate', + type: 'date', + }, + { + name: 'stripeCustomerId', + type: 'text', + }, + { + name: 'upi', + type: 'text', + }, + { + name: 'studentId', + type: 'text', + }, + { + name: 'areaOfStudy', + type: 'text', + }, + { + name: 'yearOfUniversity', + type: 'select', + options: [ + { label: 'Year 1', value: '1' }, + { label: 'Year 2', value: '2' }, + { label: 'Year 3', value: '3' }, + { label: 'Year 4', value: '4' }, + { label: 'Year 5+', value: '5+' }, + { label: 'Postgraduate', value: 'postgrad' }, + ], + }, + { + name: 'gender', + type: 'select', + options: [ + { label: 'Male', value: 'male' }, + { label: 'Female', value: 'female' }, + { label: 'Non-binary', value: 'non-binary' }, + { label: 'Prefer not to say', value: 'prefer-not-to-say' }, + ], + }, + { + name: 'ethnicity', + type: 'select', + options: [ + { label: 'Chinese', value: 'chinese' }, + { label: 'Malay', value: 'malay' }, + { label: 'Indian', value: 'indian' }, + { label: 'Eurasian', value: 'eurasian' }, + { label: 'Other', value: 'other' }, + ], + }, + { + name: 'returningMember', + type: 'checkbox', + defaultValue: false, + }, ], } diff --git a/cms/src/payload-types.ts b/cms/src/payload-types.ts index 0b6d415..576619e 100644 --- a/cms/src/payload-types.ts +++ b/cms/src/payload-types.ts @@ -152,6 +152,26 @@ export interface MemberAuthOperations { */ export interface User { id: number + role: 'admin' | 'member' + name?: string | null + authProvider?: ('email' | 'google') | null + /** + * Google account subject identifier for OAuth signups. + */ + googleSub?: string | null + firstName?: string | null + lastName?: string | null + phone?: string | null + membershipStatus?: ('active' | 'expired' | 'pending') | null + membershipExpiryDate?: string | null + stripeCustomerId?: string | null + upi?: string | null + studentId?: string | null + areaOfStudy?: string | null + yearOfUniversity?: ('1' | '2' | '3' | '4' | '5+' | 'postgrad') | null + gender?: ('male' | 'female' | 'non-binary' | 'prefer-not-to-say') | null + ethnicity?: ('chinese' | 'malay' | 'indian' | 'eurasian' | 'other') | null + returningMember?: boolean | null updatedAt: string createdAt: string email: string @@ -247,10 +267,18 @@ export interface Exec { export interface Member { id: number name: string + firstName?: string | null + lastName?: string | null phone: string + authProvider: 'email' | 'google' + /** + * Google account subject identifier for OAuth signups. + */ + googleSub?: string | null status: 'active' | 'expired' | 'pending' membershipExpiryDate?: string | null stripeCustomerId?: string | null + encryptedSignupPassword?: string | null emergencyContactName?: string | null emergencyContactPhone?: string | null upi?: string | null @@ -384,6 +412,23 @@ export interface PayloadMigration { * via the `definition` "users_select". */ export interface UsersSelect { + role?: T + name?: T + authProvider?: T + googleSub?: T + firstName?: T + lastName?: T + phone?: T + membershipStatus?: T + membershipExpiryDate?: T + stripeCustomerId?: T + upi?: T + studentId?: T + areaOfStudy?: T + yearOfUniversity?: T + gender?: T + ethnicity?: T + returningMember?: T updatedAt?: T createdAt?: T email?: T @@ -472,10 +517,15 @@ export interface ExecsSelect { */ export interface MembersSelect { name?: T + firstName?: T + lastName?: T phone?: T + authProvider?: T + googleSub?: T status?: T membershipExpiryDate?: T stripeCustomerId?: T + encryptedSignupPassword?: T emergencyContactName?: T emergencyContactPhone?: T upi?: T diff --git a/cms/tests/helpers/seedUser.ts b/cms/tests/helpers/seedUser.ts index f0f5c86..af1956a 100644 --- a/cms/tests/helpers/seedUser.ts +++ b/cms/tests/helpers/seedUser.ts @@ -4,7 +4,8 @@ import config from '../../src/payload.config.js' export const testUser = { email: 'dev@payloadcms.com', password: 'test', -} + role: 'admin', +} as const /** * Seeds a test user for e2e admin tests. diff --git a/web/README.md b/web/README.md index e215bc4..4bc0453 100644 --- a/web/README.md +++ b/web/README.md @@ -2,6 +2,20 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next- ## Getting Started +Create a `.env.local` file with the app URLs and OAuth keys used by the signup flow: + +```bash +CMS_URL=http://localhost:3001 +NEXT_PUBLIC_CMS_URL=http://localhost:3001 +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_OAUTH_COOKIE_SECRET=use-a-long-random-secret +# Optional when the derived localhost callback is not correct: +# GOOGLE_OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/google/callback +``` + +The Google OAuth client must allow the redirect URI shown above for local development. + First, run the development server: ```bash diff --git a/web/package-lock.json b/web/package-lock.json index 36af562..cf24158 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -654,6 +654,126 @@ "node_modules/typescript": { "resolved": "../node_modules/.pnpm/typescript@5.7.3/node_modules/typescript", "link": true + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/web/src/app/api/auth/google/callback/route.ts b/web/src/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..d87dfbe --- /dev/null +++ b/web/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + createGoogleSignupSession, + googleOAuthStateCookie, + googleSignupSessionCookie, +} from '@/lib/googleSignupSession' + +type GoogleTokenResponse = { + access_token?: string + error?: string + error_description?: string + expires_in?: number + id_token?: string + scope?: string + token_type?: string +} + +type GoogleTokenInfoResponse = { + aud?: string + email?: string + email_verified?: boolean | string + family_name?: string + given_name?: string + name?: string + sub?: string +} + +function getRedirectUri(request: NextRequest) { + const configuredRedirectUri = process.env.GOOGLE_OAUTH_REDIRECT_URI + if (configuredRedirectUri) return configuredRedirectUri + + return `${request.nextUrl.origin}/api/auth/google/callback` +} + +function redirectToSignup( + request: NextRequest, + params: Record, +) { + const url = new URL('/signup', request.nextUrl.origin) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + + return NextResponse.redirect(url) +} + +export async function GET(request: NextRequest) { + const clientId = process.env.GOOGLE_CLIENT_ID + const clientSecret = process.env.GOOGLE_CLIENT_SECRET + + if (!clientId || !clientSecret) { + return Response.json( + { error: 'Google OAuth is not configured' }, + { status: 500 }, + ) + } + + const code = request.nextUrl.searchParams.get('code') + const state = request.nextUrl.searchParams.get('state') + const storedState = request.cookies.get(googleOAuthStateCookie)?.value + + if (!code || !state || !storedState || state !== storedState) { + const response = redirectToSignup(request, { google: 'invalid_state' }) + response.cookies.delete(googleOAuthStateCookie) + return response + } + + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: getRedirectUri(request), + }), + }) + const tokenData = (await tokenResponse.json()) as GoogleTokenResponse + + if (!tokenResponse.ok || !tokenData.id_token) { + console.error( + '[google-oauth] token exchange failed', + tokenData.error, + tokenData.error_description, + ) + const response = redirectToSignup(request, { google: 'token_failed' }) + response.cookies.delete(googleOAuthStateCookie) + return response + } + + const tokenInfoResponse = await fetch( + `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(tokenData.id_token)}`, + ) + const tokenInfo = (await tokenInfoResponse.json()) as GoogleTokenInfoResponse + + if ( + !tokenInfoResponse.ok || + tokenInfo.aud !== clientId || + !tokenInfo.sub || + !tokenInfo.email || + tokenInfo.email_verified === false || + tokenInfo.email_verified === 'false' + ) { + console.error('[google-oauth] invalid id token') + const response = redirectToSignup(request, { google: 'invalid_token' }) + response.cookies.delete(googleOAuthStateCookie) + return response + } + + const response = redirectToSignup(request, { google: 'connected' }) + response.cookies.delete(googleOAuthStateCookie) + response.cookies.set( + googleSignupSessionCookie, + createGoogleSignupSession({ + email: tokenInfo.email, + firstName: tokenInfo.given_name, + googleSub: tokenInfo.sub, + lastName: tokenInfo.family_name, + name: tokenInfo.name, + }), + { + httpOnly: true, + maxAge: 30 * 60, + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }, + ) + + return response +} diff --git a/web/src/app/api/auth/google/route.ts b/web/src/app/api/auth/google/route.ts new file mode 100644 index 0000000..7dd8c6e --- /dev/null +++ b/web/src/app/api/auth/google/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + createOAuthState, + googleOAuthStateCookie, +} from '@/lib/googleSignupSession' + +function getRedirectUri(request: NextRequest) { + const configuredRedirectUri = process.env.GOOGLE_OAUTH_REDIRECT_URI + if (configuredRedirectUri) return configuredRedirectUri + + return `${request.nextUrl.origin}/api/auth/google/callback` +} + +export function GET(request: NextRequest) { + const clientId = process.env.GOOGLE_CLIENT_ID + + if (!clientId) { + return Response.json( + { error: 'GOOGLE_CLIENT_ID not configured' }, + { status: 500 }, + ) + } + + const state = createOAuthState() + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth') + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('redirect_uri', getRedirectUri(request)) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', 'openid email profile') + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('prompt', 'select_account') + + const response = NextResponse.redirect(authUrl) + response.cookies.set(googleOAuthStateCookie, state, { + httpOnly: true, + maxAge: 10 * 60, + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }) + + return response +} diff --git a/web/src/app/api/auth/google/session/route.ts b/web/src/app/api/auth/google/session/route.ts new file mode 100644 index 0000000..2c43ac4 --- /dev/null +++ b/web/src/app/api/auth/google/session/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + googleSignupSessionCookie, + readGoogleSignupSession, +} from '@/lib/googleSignupSession' + +export function GET(request: NextRequest) { + const profile = readGoogleSignupSession( + request.cookies.get(googleSignupSessionCookie)?.value, + ) + + if (!profile) { + return Response.json({ error: 'No Google signup session' }, { status: 404 }) + } + + return Response.json({ profile }) +} + +export function DELETE() { + const response = NextResponse.json({ ok: true }) + response.cookies.set(googleSignupSessionCookie, '', { + httpOnly: true, + maxAge: 0, + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }) + + return response +} diff --git a/web/src/app/api/checkout/route.ts b/web/src/app/api/checkout/route.ts index 781deff..77f85a2 100644 --- a/web/src/app/api/checkout/route.ts +++ b/web/src/app/api/checkout/route.ts @@ -1,5 +1,18 @@ import { NextRequest } from 'next/server' +import { + googleSignupSessionCookie, + readGoogleSignupSession, +} from '@/lib/googleSignupSession' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function textValue(value: unknown) { + return typeof value === 'string' ? value : undefined +} + export const POST = async (request: NextRequest) => { const cmsUrl = process.env.CMS_URL @@ -7,6 +20,10 @@ export const POST = async (request: NextRequest) => { return Response.json({ error: 'CMS_URL not configured' }, { status: 500 }) } + const webUrl = + request.headers.get('origin') ?? + `${request.headers.get('x-forwarded-proto') ?? 'http'}://${request.headers.get('host')}` + let body: unknown try { body = await request.json() @@ -14,11 +31,52 @@ export const POST = async (request: NextRequest) => { return Response.json({ error: 'Invalid request body' }, { status: 400 }) } + if (!isRecord(body)) { + return Response.json({ error: 'Invalid request body' }, { status: 400 }) + } + + if (body.authProvider === 'google') { + const profile = readGoogleSignupSession( + request.cookies.get(googleSignupSessionCookie)?.value, + ) + + if (!profile) { + return Response.json( + { + error: 'Google sign up session expired. Please connect Google again.', + }, + { status: 401 }, + ) + } + + const firstName = + textValue(body.firstName)?.trim() || profile.firstName || '' + const lastName = textValue(body.lastName)?.trim() || profile.lastName || '' + + body = { + ...body, + authProvider: 'google', + email: profile.email, + firstName, + googleSub: profile.googleSub, + lastName, + name: `${firstName} ${lastName}`.trim() || profile.name || profile.email, + } + } else { + body = { + ...body, + authProvider: 'email', + } + } + let cmsResponse: Response try { cmsResponse = await fetch(`${cmsUrl}/stripe/checkout`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Web-Url': webUrl, + }, body: JSON.stringify(body), }) } catch (err: unknown) { diff --git a/web/src/app/api/signup/check-email/route.ts b/web/src/app/api/signup/check-email/route.ts new file mode 100644 index 0000000..80e9a60 --- /dev/null +++ b/web/src/app/api/signup/check-email/route.ts @@ -0,0 +1,47 @@ +import { NextRequest } from 'next/server' + +export async function POST(request: NextRequest) { + const cmsUrl = process.env.CMS_URL + + if (!cmsUrl) { + return Response.json({ error: 'CMS_URL not configured' }, { status: 500 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid request body' }, { status: 400 }) + } + + let cmsResponse: Response + try { + cmsResponse = await fetch(`${cmsUrl}/signup/check-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to reach CMS' + return Response.json({ error: message }, { status: 502 }) + } + + const cmsBody = await cmsResponse.text() + let data: unknown + + if (!cmsBody) { + data = { error: 'Empty CMS response' } + } else { + try { + data = JSON.parse(cmsBody) + } catch { + console.error( + '[api/signup/check-email] CMS returned non-JSON body:', + cmsBody, + ) + data = { error: 'Email availability service error. Please try again.' } + } + } + + return Response.json(data, { status: cmsResponse.status }) +} diff --git a/web/src/app/signup/_components/AccountSignUpStep.tsx b/web/src/app/signup/_components/AccountSignUpStep.tsx new file mode 100644 index 0000000..7913afc --- /dev/null +++ b/web/src/app/signup/_components/AccountSignUpStep.tsx @@ -0,0 +1,100 @@ +import type { FormData } from './types' +import CardSection from '@/components/CardSection' +import InputField from '@/components/InputField' +import { FcGoogle } from 'react-icons/fc' + +export default function AccountSignUpStep({ + data, + onChange, + fieldErrors, + onGoogleAuth, + onUseEmailAuth, +}: { + data: FormData + onChange: (field: keyof FormData, value: string) => void + fieldErrors: Record + onGoogleAuth: () => void + onUseEmailAuth: () => void +}) { + const isGoogleSignup = data.authProvider === 'google' + + return ( + + + + {isGoogleSignup && ( +
+ Google account connected for {data.email}. +
+ )} + + {!isGoogleSignup && ( +
+
+ + or + +
+
+ )} + + onChange('email', v)} + error={fieldErrors.email} + readOnly={isGoogleSignup} + /> + {!isGoogleSignup && ( + <> + onChange('password', v)} + error={fieldErrors.password} + /> + onChange('confirmPassword', v)} + error={fieldErrors.confirmPassword} + /> + + )} + + {isGoogleSignup && ( + + )} + + ) +} diff --git a/web/src/app/signup/_components/ContactStep.tsx b/web/src/app/signup/_components/ContactStep.tsx index 102b121..675bfe9 100644 --- a/web/src/app/signup/_components/ContactStep.tsx +++ b/web/src/app/signup/_components/ContactStep.tsx @@ -35,17 +35,6 @@ export default function ContactStep({ error={fieldErrors.lastName} />
- onChange('email', v)} - error={fieldErrors.email} - /> onChange('phone', v)} error={fieldErrors.phone} /> - onChange('password', v)} - error={fieldErrors.password} - /> - onChange('confirmPassword', v)} - error={fieldErrors.confirmPassword} - /> ) } diff --git a/web/src/app/signup/_components/SignupForm.tsx b/web/src/app/signup/_components/SignupForm.tsx index fe2bf54..12a21bb 100644 --- a/web/src/app/signup/_components/SignupForm.tsx +++ b/web/src/app/signup/_components/SignupForm.tsx @@ -1,9 +1,10 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useSearchParams } from 'next/navigation' import { TOTAL_STEPS, initialFormData, type FormData } from './types' import ProgressBar from '@/components/ProgressBar' +import AccountSignUpStep from './AccountSignUpStep' import ContactStep from './ContactStep' import UniInfoStep from './UniInfoStep' import AdditionalInfoStep from './AdditionalInfoStep' @@ -12,13 +13,83 @@ import PaymentStep from '@/components/PaymentStep' export default function SignupForm() { const searchParams = useSearchParams() const wasCancelled = searchParams.get('cancelled') === 'true' + const googleStatus = searchParams.get('google') + const googleConnectionError = + googleStatus && googleStatus !== 'connected' + ? 'Google sign up could not be completed. Please try again.' + : null const [step, setStep] = useState(1) const [formData, setFormData] = useState(initialFormData) + const [isCheckingEmail, setIsCheckingEmail] = useState(false) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState>({}) + useEffect(() => { + if (!googleStatus) return + + if (googleStatus !== 'connected') return + + let isActive = true + + async function loadGoogleProfile() { + try { + const response = await fetch('/api/auth/google/session') + const result = (await response.json()) as { + profile?: { + email?: string + firstName?: string + googleSub?: string + lastName?: string + } + } + + if (!isActive) return + + if ( + !response.ok || + !result.profile?.email || + !result.profile.googleSub + ) { + setError( + 'Google sign up session expired. Please connect Google again.', + ) + return + } + + setFormData((prev) => ({ + ...prev, + authProvider: 'google', + email: result.profile?.email ?? '', + firstName: prev.firstName || result.profile?.firstName || '', + googleSub: result.profile?.googleSub ?? '', + lastName: prev.lastName || result.profile?.lastName || '', + password: '', + confirmPassword: '', + })) + setFieldErrors((prev) => { + const next = { ...prev } + delete next.email + delete next.password + delete next.confirmPassword + return next + }) + setError(null) + } catch { + if (isActive) { + setError('Google sign up could not be loaded. Please try again.') + } + } + } + + loadGoogleProfile() + + return () => { + isActive = false + } + }, [googleStatus]) + function handleChange(field: keyof FormData, value: string) { setFormData((prev) => ({ ...prev, [field]: value })) setFieldErrors((prev) => { @@ -32,26 +103,33 @@ export default function SignupForm() { function validateStep(s: number): Record { const errors: Record = {} if (s === 1) { - if (!formData.firstName.trim()) - errors.firstName = 'First name is required' - if (!formData.lastName.trim()) errors.lastName = 'Last name is required' if ( !formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) ) { errors.email = 'Valid email is required' } - if (!formData.phone.trim()) errors.phone = 'Phone number is required' - if ( - formData.password.length < 8 || - !/[a-zA-Z]/.test(formData.password) || - !/[0-9]/.test(formData.password) - ) - errors.password = - 'Password must be at least 8 characters and include a letter and a number' - if (formData.password !== formData.confirmPassword) - errors.confirmPassword = 'Passwords do not match' + if (formData.authProvider === 'google') { + if (!formData.googleSub) { + errors.email = 'Please connect your Google account again' + } + } else { + if ( + formData.password.length < 8 || + !/[a-zA-Z]/.test(formData.password) || + !/[0-9]/.test(formData.password) + ) + errors.password = + 'Password must be at least 8 characters and include a letter and a number' + if (formData.password !== formData.confirmPassword) + errors.confirmPassword = 'Passwords do not match' + } } else if (s === 2) { + if (!formData.firstName.trim()) + errors.firstName = 'First name is required' + if (!formData.lastName.trim()) errors.lastName = 'Last name is required' + if (!formData.phone.trim()) errors.phone = 'Phone number is required' + } else if (s === 3) { if (!formData.upi.trim()) errors.upi = 'UPI is required' if (!formData.studentId.trim()) errors.studentId = 'Student ID is required' @@ -59,7 +137,7 @@ export default function SignupForm() { errors.areaOfStudy = 'Area of study is required' if (!formData.yearOfUniversity) errors.yearOfUniversity = 'Year of university is required' - } else if (s === 3) { + } else if (s === 4) { if (!formData.gender) errors.gender = 'Gender is required' if (!formData.ethnicity) errors.ethnicity = 'Ethnicity is required' if (!formData.returningMember) @@ -68,12 +146,47 @@ export default function SignupForm() { return errors } - function handleNext() { + async function checkEmailAvailability() { + setIsCheckingEmail(true) + try { + const response = await fetch('/api/signup/check-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: formData.email }), + }) + const result = (await response.json()) as { + available?: boolean + error?: string + } + + if (!response.ok || result.available === false) { + const message = result.error ?? 'This email is already registered' + setFieldErrors((prev) => ({ ...prev, email: message })) + return false + } + + return true + } catch { + setError('Could not check this email. Please try again.') + return false + } finally { + setIsCheckingEmail(false) + } + } + + async function handleNext() { const errors = validateStep(step) if (Object.keys(errors).length > 0) { setFieldErrors(errors) return } + + if (step === 1) { + setError(null) + const isEmailAvailable = await checkEmailAvailability() + if (!isEmailAvailable) return + } + setFieldErrors({}) if (step < TOTAL_STEPS) setStep((s) => s + 1) } @@ -82,21 +195,42 @@ export default function SignupForm() { if (step > 1) setStep((s) => s - 1) } + function handleGoogleAuth() { + window.location.href = '/api/auth/google' + } + + function handleUseEmailAuth() { + fetch('/api/auth/google/session', { method: 'DELETE' }).catch(() => {}) + setFormData((prev) => ({ + ...prev, + authProvider: 'email', + googleSub: '', + })) + } + const handlePay = async () => { setError(null) const step1Errors = validateStep(1) const step2Errors = validateStep(2) const step3Errors = validateStep(3) - const allErrors = { ...step1Errors, ...step2Errors, ...step3Errors } + const step4Errors = validateStep(4) + const allErrors = { + ...step1Errors, + ...step2Errors, + ...step3Errors, + ...step4Errors, + } if (Object.keys(allErrors).length > 0) { setFieldErrors(allErrors) if (Object.keys(step1Errors).length > 0) { setStep(1) } else if (Object.keys(step2Errors).length > 0) { setStep(2) - } else { + } else if (Object.keys(step3Errors).length > 0) { setStep(3) + } else { + setStep(4) } return } @@ -107,8 +241,12 @@ export default function SignupForm() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + firstName: formData.firstName, + lastName: formData.lastName, name: `${formData.firstName} ${formData.lastName}`, + authProvider: formData.authProvider, email: formData.email, + googleSub: formData.googleSub, password: formData.password, phone: formData.phone, upi: formData.upi, @@ -124,7 +262,13 @@ export default function SignupForm() { const result = await response.json() if (!response.ok || !result.checkoutUrl) { - setError(result.error ?? 'Something went wrong. Please try again.') + const message = + result.error ?? 'Something went wrong. Please try again.' + setError(message) + if (response.status === 409) { + setFieldErrors((prev) => ({ ...prev, email: message })) + setStep(1) + } return } @@ -146,36 +290,45 @@ export default function SignupForm() {
)} - {error && ( + {(error || googleConnectionError) && (
- {error} + {error || googleConnectionError}
)} {step === 1 && ( - )} {step === 2 && ( - )} {step === 3 && ( - )} {step === 4 && ( + + )} + {step === 5 && ( )} @@ -193,9 +346,10 @@ export default function SignupForm() { {step < TOTAL_STEPS && ( )} diff --git a/web/src/app/signup/_components/types.ts b/web/src/app/signup/_components/types.ts index e4ee540..325bde9 100644 --- a/web/src/app/signup/_components/types.ts +++ b/web/src/app/signup/_components/types.ts @@ -1,9 +1,11 @@ -export const TOTAL_STEPS = 4 +export const TOTAL_STEPS = 5 export type FormData = { + authProvider: 'email' | 'google' firstName: string lastName: string email: string + googleSub: string phone: string password: string confirmPassword: string @@ -17,9 +19,11 @@ export type FormData = { } export const initialFormData: FormData = { + authProvider: 'email', firstName: '', lastName: '', email: '', + googleSub: '', phone: '', password: '', confirmPassword: '', diff --git a/web/src/app/signup/success/page.tsx b/web/src/app/signup/success/page.tsx index 90ea159..374b14a 100644 --- a/web/src/app/signup/success/page.tsx +++ b/web/src/app/signup/success/page.tsx @@ -4,26 +4,56 @@ interface Props { searchParams: Promise<{ session_id?: string }> } +type ConfirmationResult = 'confirmed' | 'pending' | 'unavailable' | 'missing' + +async function confirmCheckoutSession( + sessionId?: string, +): Promise { + if (!sessionId) return 'missing' + + const cmsUrl = process.env.CMS_URL + if (!cmsUrl) return 'unavailable' + + try { + const response = await fetch( + `${cmsUrl}/stripe/checkout/confirm?session_id=${encodeURIComponent(sessionId)}`, + { cache: 'no-store' }, + ) + + if (!response.ok) return 'unavailable' + + const result = (await response.json()) as { + confirmed?: boolean + } + + return result.confirmed ? 'confirmed' : 'pending' + } catch { + return 'unavailable' + } +} + export default async function SignupSuccessPage({ searchParams }: Props) { const params = await searchParams - const hasSession = Boolean(params.session_id) + const confirmation = await confirmCheckoutSession(params.session_id) + + const messageByConfirmation: Record = { + confirmed: + 'Your payment has been confirmed and your SSA membership is now active.', + pending: + 'We received your signup and are confirming your payment details. Your membership will be activated once confirmation is complete.', + unavailable: + 'Your payment was completed, but we could not confirm it automatically. Please check your email for updates.', + missing: + 'If you completed your payment, your membership will be activated shortly.', + } return (

Welcome to SSA!

- {hasSession ? ( -

- We received your signup and are confirming your payment details. - Your membership will be activated once confirmation is complete. - Check your email for updates. -

- ) : ( -

- If you completed your payment, your membership will be activated - shortly. -

- )} +

+ {messageByConfirmation[confirmation]} +

{error && ( diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx index 914fb18..f8354ef 100644 --- a/web/src/components/Navbar.tsx +++ b/web/src/components/Navbar.tsx @@ -12,7 +12,7 @@ const navLinks = [ { label: 'Sponsors', href: '/sponsors' }, ] -const ctaLink = { label: 'Join SSA!', href: '/contact' } +const ctaLink = { label: 'Join SSA!', href: '/signup' } export default function Navbar() { const [hidden, setHidden] = useState(false) diff --git a/web/src/lib/googleSignupSession.ts b/web/src/lib/googleSignupSession.ts new file mode 100644 index 0000000..fa5d5ca --- /dev/null +++ b/web/src/lib/googleSignupSession.ts @@ -0,0 +1,81 @@ +import crypto from 'crypto' + +export type GoogleSignupProfile = { + email: string + firstName?: string + googleSub: string + lastName?: string + name?: string +} + +export const googleSignupSessionCookie = 'ssa_google_signup' +export const googleOAuthStateCookie = 'ssa_google_oauth_state' + +const sessionVersion = 'v1' + +function getCookieSecret() { + const secret = + process.env.GOOGLE_OAUTH_COOKIE_SECRET || process.env.AUTH_SECRET + + if (!secret) { + throw new Error('GOOGLE_OAUTH_COOKIE_SECRET must be configured') + } + + return secret +} + +function sign(value: string) { + return crypto + .createHmac('sha256', getCookieSecret()) + .update(value) + .digest('base64url') +} + +function timingSafeEqual(a: string, b: string) { + const aBuffer = Buffer.from(a) + const bBuffer = Buffer.from(b) + + return ( + aBuffer.length === bBuffer.length && + crypto.timingSafeEqual(aBuffer, bBuffer) + ) +} + +export function createGoogleSignupSession(profile: GoogleSignupProfile) { + const payload = Buffer.from(JSON.stringify(profile)).toString('base64url') + const signedPayload = `${sessionVersion}.${payload}` + + return `${signedPayload}.${sign(signedPayload)}` +} + +export function readGoogleSignupSession(value?: null | string) { + if (!value) return null + + const [version, payload, signature] = value.split('.') + if (version !== sessionVersion || !payload || !signature) return null + + const signedPayload = `${version}.${payload}` + if (!timingSafeEqual(signature, sign(signedPayload))) return null + + try { + const parsed = JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8'), + ) as Partial + + if (!parsed.email || !parsed.googleSub) return null + + return { + email: parsed.email, + firstName: parsed.firstName, + googleSub: parsed.googleSub, + lastName: parsed.lastName, + name: parsed.name, + } satisfies GoogleSignupProfile + } catch { + return null + } +} + +export function createOAuthState() { + return crypto.randomBytes(24).toString('base64url') +} From f070ca2042778a58b486d2180b334deb0515e5e8 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 22 May 2026 19:52:25 +1200 Subject: [PATCH 2/2] fix: fixing stripe webhook web url issue and users are now defaulted to member --- cms/src/app/stripe/checkout/route.ts | 16 +++++++++++----- cms/src/collections/Users.ts | 2 +- web/src/app/api/checkout/route.ts | 5 ----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index 4a7d896..f546cd9 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -5,12 +5,14 @@ import { getPayload } from 'payload' import Stripe from 'stripe' import { encryptSignupPassword } from '../_lib/signupPassword' -function getWebUrl(request: NextRequest) { - const forwardedWebUrl = request.headers.get('x-web-url') +function getWebUrl() { const configuredWebUrl = process.env.WEB_URL const fallbackWebUrl = 'http://localhost:3000' - for (const value of [forwardedWebUrl, configuredWebUrl, fallbackWebUrl]) { + for (const value of [ + configuredWebUrl, + process.env.NODE_ENV === 'production' ? null : fallbackWebUrl, + ]) { if (!value) continue try { @@ -23,7 +25,7 @@ function getWebUrl(request: NextRequest) { } } - return fallbackWebUrl + return null } function normalizeEmail(email: string) { @@ -99,12 +101,16 @@ export const POST = async (request: NextRequest) => { const stripeSecretKey = process.env.STRIPE_SECRET_KEY const priceId = process.env.STRIPE_PRICE_ID - const webUrl = getWebUrl(request) + const webUrl = getWebUrl() if (!stripeSecretKey || !priceId) { return Response.json({ error: 'Stripe not configured' }, { status: 500 }) } + if (!webUrl) { + return Response.json({ error: 'WEB_URL not configured' }, { status: 500 }) + } + const payload = await getPayload({ config: configPromise }) const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) const memberPassword = diff --git a/cms/src/collections/Users.ts b/cms/src/collections/Users.ts index a27bb71..cf98e54 100644 --- a/cms/src/collections/Users.ts +++ b/cms/src/collections/Users.ts @@ -18,7 +18,7 @@ export const Users: CollectionConfig = { { name: 'role', type: 'select', - defaultValue: 'admin', + defaultValue: 'member', options: [ { label: 'Admin', value: 'admin' }, { label: 'Member', value: 'member' }, diff --git a/web/src/app/api/checkout/route.ts b/web/src/app/api/checkout/route.ts index 77f85a2..ca60b1a 100644 --- a/web/src/app/api/checkout/route.ts +++ b/web/src/app/api/checkout/route.ts @@ -20,10 +20,6 @@ export const POST = async (request: NextRequest) => { return Response.json({ error: 'CMS_URL not configured' }, { status: 500 }) } - const webUrl = - request.headers.get('origin') ?? - `${request.headers.get('x-forwarded-proto') ?? 'http'}://${request.headers.get('host')}` - let body: unknown try { body = await request.json() @@ -75,7 +71,6 @@ export const POST = async (request: NextRequest) => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Web-Url': webUrl, }, body: JSON.stringify(body), })