feat: member phone login — POST /public/auth/member-login + /my/login page
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -496,7 +496,7 @@ model OtpChallenge {
|
|||||||
scopes ConsentScope[]
|
scopes ConsentScope[]
|
||||||
retentionDays Int @default(90)
|
retentionDays Int @default(90)
|
||||||
policyVersion String
|
policyVersion String
|
||||||
groupId String
|
groupId String?
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
consumedAt DateTime?
|
consumedAt DateTime?
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
|
|||||||
@@ -224,6 +224,89 @@ export class OnboardingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async memberLogin(phone: string): Promise<{ challengeId: string; expiresInSeconds: number }> {
|
||||||
|
const pepper = this.config.get<string>('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<string>('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 {
|
private normalizeJid(jid: string): string {
|
||||||
return jid.trim();
|
return jid.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { OnboardingService } from './onboarding.service';
|
||||||
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
||||||
import { ConsentScope } from '@tower/types';
|
import { ConsentScope } from '@tower/types';
|
||||||
@@ -18,6 +18,16 @@ class VerifyOtpDto {
|
|||||||
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
|
@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')
|
@Controller('public')
|
||||||
export class PublicOnboardingController {
|
export class PublicOnboardingController {
|
||||||
constructor(private readonly service: OnboardingService) {}
|
constructor(private readonly service: OnboardingService) {}
|
||||||
@@ -46,4 +56,16 @@ export class PublicOnboardingController {
|
|||||||
body.retentionDays,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { buildMemberCookie, getApiBaseUrl, jsonResponse } from '../../../_lib/api';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
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) },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ const NAV = [
|
|||||||
|
|
||||||
export default async function MemberLayout({ children }: { children: React.ReactNode }) {
|
export default async function MemberLayout({ children }: { children: React.ReactNode }) {
|
||||||
const token = await getMemberToken();
|
const token = await getMemberToken();
|
||||||
if (!token) redirect('/onboard');
|
if (!token) redirect('/my/login');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full -m-6 min-h-screen">
|
<div className="flex h-full -m-6 min-h-screen">
|
||||||
|
|||||||
@@ -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<Step>('phone');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [challengeId, setChallengeId] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm bg-white rounded-2xl border border-gray-200 shadow-sm p-7 space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-100 flex items-center justify-center text-xl mb-4">🏠</div>
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">Sign in to your portal</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{step === 'phone'
|
||||||
|
? 'Enter your WhatsApp number and we\'ll send you a code.'
|
||||||
|
: `We sent a 6-digit code to ${phone} on WhatsApp.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'phone' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => 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 && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={requestOtp}
|
||||||
|
disabled={busy || phone.replace(/\D/g, '').length < 8}
|
||||||
|
className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{busy ? 'Sending…' : 'Send code'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-center text-gray-400">
|
||||||
|
First time?{' '}
|
||||||
|
<a href="/onboard" className="text-indigo-600 hover:underline">
|
||||||
|
Use your invite link
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'code' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={requestOtp}
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs text-indigo-600 hover:underline disabled:opacity-50 w-full text-center"
|
||||||
|
>
|
||||||
|
Didn't get it? Resend code
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStep('phone'); setCode(''); setError(null); }}
|
||||||
|
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={verifyOtp}
|
||||||
|
disabled={busy || code.length < 6}
|
||||||
|
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{busy ? 'Verifying…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user