From a1d2c6e6c2609ef7feb416aafc87f3fb1f29b6e4 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 20 Jun 2026 16:37:19 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20phone=20login=20=E2=80=94=20PO?= =?UTF-8?q?ST=20/public/auth/member-login=20+=20/my/login=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning members can now sign in with just their phone number. The bot DMs them a 6-digit OTP; on verify a 30-day session cookie is set. First-time users are directed to their invite link from the login page. - Make OtpChallenge.groupId optional (migration) for re-login challenges - Add memberLogin / memberVerify service methods - Add POST /public/auth/member-login and /member-verify controller endpoints - Add /api/my/login BFF route (sets tower_member_token cookie) - Add /my/login page (phone → OTP two-step form) - /my/* now redirects to /my/login instead of /onboard on no session Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 3 + apps/api/prisma/schema.prisma | 2 +- .../modules/onboarding/onboarding.service.ts | 83 ++++++++++ .../public-onboarding.controller.ts | 24 ++- apps/web/app/api/my/login/route.ts | 25 +++ apps/web/app/my/layout.tsx | 2 +- apps/web/app/my/login/page.tsx | 145 ++++++++++++++++++ 7 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 apps/api/prisma/migrations/20260620000000_otpchallenge_groupid_optional/migration.sql create mode 100644 apps/web/app/api/my/login/route.ts create mode 100644 apps/web/app/my/login/page.tsx diff --git a/apps/api/prisma/migrations/20260620000000_otpchallenge_groupid_optional/migration.sql b/apps/api/prisma/migrations/20260620000000_otpchallenge_groupid_optional/migration.sql new file mode 100644 index 0000000..f7b0d76 --- /dev/null +++ b/apps/api/prisma/migrations/20260620000000_otpchallenge_groupid_optional/migration.sql @@ -0,0 +1,3 @@ +-- Make groupId optional on OtpChallenge so member re-login challenges +-- (which have no group context) can be created without a groupId. +ALTER TABLE "OtpChallenge" ALTER COLUMN "groupId" DROP NOT NULL; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f0386c9..c1988e3 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -496,7 +496,7 @@ model OtpChallenge { scopes ConsentScope[] retentionDays Int @default(90) policyVersion String - groupId String + groupId String? expiresAt DateTime consumedAt DateTime? sentAt DateTime? diff --git a/apps/api/src/modules/onboarding/onboarding.service.ts b/apps/api/src/modules/onboarding/onboarding.service.ts index fc2f443..dccc308 100644 --- a/apps/api/src/modules/onboarding/onboarding.service.ts +++ b/apps/api/src/modules/onboarding/onboarding.service.ts @@ -224,6 +224,89 @@ export class OnboardingService { }; } + async memberLogin(phone: string): Promise<{ challengeId: string; expiresInSeconds: number }> { + const pepper = this.config.get('JWT_SECRET') ?? ''; + const phoneHash = hashPhone(phone, pepper); + + const user = await this.prisma.towerUser.findFirst({ + where: { phoneHash }, + select: { id: true, tenantId: true, jid: true }, + }); + if (!user) throw new NotFoundException('No portal account found for this number. Use your original invite link to register first.'); + + const code = String(Math.floor(100000 + Math.random() * 900000)); + const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000); + const challengeId = randomBytes(16).toString('hex'); + + await this.prisma.otpChallenge.create({ + data: { + id: challengeId, + tenantId: user.tenantId, + jid: user.jid, + phoneHash, + code, + scopes: DEFAULT_SCOPES, + retentionDays: DEFAULT_RETENTION_DAYS, + policyVersion: this.policyVersion, + expiresAt, + }, + }); + + await this.audit.log({ + tenantId: user.tenantId, + action: AuditAction.OTP_REQUESTED, + resourceType: 'TowerUser', + resourceId: user.id, + payload: { jid: user.jid, source: 'member-login' }, + }); + + return { challengeId, expiresInSeconds: OTP_TTL_MIN * 60 }; + } + + async memberVerify(challengeId: string, phone: string, code: string): Promise<{ + memberToken: string; + user: { id: string; tenantId: string; jid: string; displayName: string | null }; + }> { + const pepper = this.config.get('JWT_SECRET') ?? ''; + const phoneHash = hashPhone(phone, pepper); + + const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } }); + if (!challenge) throw new NotFoundException('Challenge not found'); + if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used'); + if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Code expired'); + if (challenge.code !== code) throw new UnauthorizedException('Invalid code'); + if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch'); + + await this.prisma.otpChallenge.update({ + where: { id: challengeId }, + data: { consumedAt: new Date() }, + }); + + const user = await this.prisma.towerUser.findFirst({ + where: { phoneHash, tenantId: challenge.tenantId }, + select: { id: true, tenantId: true, jid: true, displayName: true, phoneHash: true }, + }); + if (!user) throw new NotFoundException('User not found'); + + await this.audit.log({ + tenantId: user.tenantId, + action: AuditAction.OTP_VERIFIED, + resourceType: 'TowerUser', + resourceId: user.id, + payload: { jid: user.jid, source: 'member-login' }, + }); + + const memberToken = await this.jwt.signAsync({ + kind: 'member', + sub: user.id, + tenantId: user.tenantId, + jid: user.jid, + phoneHash: user.phoneHash, + } as const); + + return { memberToken, user: { id: user.id, tenantId: user.tenantId, jid: user.jid, displayName: user.displayName } }; + } + private normalizeJid(jid: string): string { return jid.trim(); } diff --git a/apps/api/src/modules/onboarding/public-onboarding.controller.ts b/apps/api/src/modules/onboarding/public-onboarding.controller.ts index dcff222..9bbacf6 100644 --- a/apps/api/src/modules/onboarding/public-onboarding.controller.ts +++ b/apps/api/src/modules/onboarding/public-onboarding.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { OnboardingService } from './onboarding.service'; import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator'; import { ConsentScope } from '@tower/types'; @@ -18,6 +18,16 @@ class VerifyOtpDto { @IsInt() @Min(1) @IsOptional() retentionDays?: number; } +class MemberLoginDto { + @IsString() @MinLength(6) phone!: string; +} + +class MemberVerifyDto { + @IsString() challengeId!: string; + @IsString() @MinLength(6) phone!: string; + @IsString() @MinLength(6) code!: string; +} + @Controller('public') export class PublicOnboardingController { constructor(private readonly service: OnboardingService) {} @@ -46,4 +56,16 @@ export class PublicOnboardingController { body.retentionDays, ); } + + @Post('auth/member-login') + @Public() + memberLogin(@Body() body: MemberLoginDto) { + return this.service.memberLogin(body.phone); + } + + @Post('auth/member-verify') + @Public() + memberVerify(@Body() body: MemberVerifyDto) { + return this.service.memberVerify(body.challengeId, body.phone, body.code); + } } diff --git a/apps/web/app/api/my/login/route.ts b/apps/web/app/api/my/login/route.ts new file mode 100644 index 0000000..2a93bac --- /dev/null +++ b/apps/web/app/api/my/login/route.ts @@ -0,0 +1,25 @@ +import { buildMemberCookie, getApiBaseUrl, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json().catch(() => ({})); + const upstream = await fetch(`${getApiBaseUrl()}/public/auth/member-verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = (await upstream.json().catch(() => ({}))) as { + memberToken?: string; + user?: { id: string; tenantId: string; jid: string; displayName: string | null }; + message?: string; + }; + if (!upstream.ok) return jsonResponse(payload, upstream.status); + if (!payload.memberToken) return jsonResponse({ message: 'Missing token in upstream response' }, 502); + return jsonResponse( + { user: payload.user }, + 200, + { 'Set-Cookie': buildMemberCookie(payload.memberToken) }, + ); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx index f55fce4..e9d20b8 100644 --- a/apps/web/app/my/layout.tsx +++ b/apps/web/app/my/layout.tsx @@ -18,7 +18,7 @@ const NAV = [ export default async function MemberLayout({ children }: { children: React.ReactNode }) { const token = await getMemberToken(); - if (!token) redirect('/onboard'); + if (!token) redirect('/my/login'); return (
diff --git a/apps/web/app/my/login/page.tsx b/apps/web/app/my/login/page.tsx new file mode 100644 index 0000000..fe09653 --- /dev/null +++ b/apps/web/app/my/login/page.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +const API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001'; + +type Step = 'phone' | 'code'; + +export default function MemberLoginPage() { + const router = useRouter(); + const [step, setStep] = useState('phone'); + const [phone, setPhone] = useState(''); + const [challengeId, setChallengeId] = useState(''); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function requestOtp() { + setError(null); + setBusy(true); + try { + const res = await fetch(`${API_BASE}/public/auth/member-login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone }), + }); + const data = (await res.json().catch(() => ({}))) as { challengeId?: string; message?: string }; + if (!res.ok) { + setError(data.message ?? 'Failed to send code'); + return; + } + setChallengeId(data.challengeId ?? ''); + setStep('code'); + } finally { + setBusy(false); + } + } + + async function verifyOtp() { + setError(null); + setBusy(true); + try { + const res = await fetch('/api/my/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challengeId, phone, code }), + }); + const data = (await res.json().catch(() => ({}))) as { message?: string }; + if (!res.ok) { + setError(data.message ?? 'Verification failed'); + return; + } + router.push('/my'); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+
🏠
+

Sign in to your portal

+

+ {step === 'phone' + ? 'Enter your WhatsApp number and we\'ll send you a code.' + : `We sent a 6-digit code to ${phone} on WhatsApp.`} +

+
+ + {step === 'phone' && ( +
+ setPhone(e.target.value)} + placeholder="+91 98765 43210" + autoFocus + className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + {error &&

{error}

} + +

+ First time?{' '} + + Use your invite link + +

+
+ )} + + {step === 'code' && ( +
+ setCode(e.target.value.replace(/\D/g, ''))} + placeholder="000000" + autoFocus + className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.3em] font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + + {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ); +}