Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abb6799c3d | |||
| a1d2c6e6c2 |
@@ -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[]
|
||||
retentionDays Int @default(90)
|
||||
policyVersion String
|
||||
groupId String
|
||||
groupId String?
|
||||
expiresAt DateTime
|
||||
consumedAt 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 {
|
||||
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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { SuperAdminGuard } from './super-admin.guard';
|
||||
import { SuperAdminService } from './super-admin.service';
|
||||
|
||||
class CreateOrgDto {
|
||||
@IsString() @MinLength(2) slug!: string;
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
}
|
||||
|
||||
class CreateOrgAdminDto {
|
||||
@IsString() email!: string;
|
||||
@IsString() @IsOptional() name?: string;
|
||||
@IsString() @MinLength(8) password!: string;
|
||||
}
|
||||
|
||||
class AssignTenantOrgDto {
|
||||
@IsString() @IsOptional() organizationId?: string | null;
|
||||
}
|
||||
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@Controller('admin')
|
||||
export class AdminOrgsController {
|
||||
constructor(private readonly service: SuperAdminService) {}
|
||||
|
||||
@Get('orgs')
|
||||
listOrgs() {
|
||||
return this.service.listOrgs();
|
||||
}
|
||||
|
||||
@Post('orgs')
|
||||
createOrg(@Body() body: CreateOrgDto) {
|
||||
return this.service.createOrg(body.slug, body.name);
|
||||
}
|
||||
|
||||
@Get('orgs/:id')
|
||||
getOrg(@Param('id') id: string) {
|
||||
return this.service.getOrg(id);
|
||||
}
|
||||
|
||||
@Post('orgs/:id/admins')
|
||||
createOrgAdmin(@Param('id') id: string, @Body() body: CreateOrgAdminDto) {
|
||||
return this.service.createOrgAdmin(id, body.email, body.name, body.password);
|
||||
}
|
||||
|
||||
@Patch('tenants/:id/org')
|
||||
assignTenantToOrg(@Param('id') id: string, @Body() body: AssignTenantOrgDto) {
|
||||
return this.service.assignTenantToOrg(id, body.organizationId ?? null);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { SuperAdminController } from './super-admin.controller';
|
||||
import { AdminOrgsController } from './admin-orgs.controller';
|
||||
import { SuperAdminService } from './super-admin.service';
|
||||
import { SuperAdminGuard } from './super-admin.guard';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
@@ -18,7 +19,7 @@ import { PrismaModule } from '../../prisma/prisma.module';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [SuperAdminController],
|
||||
controllers: [SuperAdminController, AdminOrgsController],
|
||||
providers: [SuperAdminService, SuperAdminGuard],
|
||||
exports: [SuperAdminGuard, JwtModule],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConflictException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -32,4 +32,81 @@ export class SuperAdminService {
|
||||
if (!admin) throw new UnauthorizedException('Super admin not found');
|
||||
return { id: admin.id, email: admin.email, name: admin.name };
|
||||
}
|
||||
|
||||
async listOrgs() {
|
||||
return this.prisma.organization.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
_count: { select: { tenants: true, orgAdmins: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createOrg(slug: string, name: string) {
|
||||
const existing = await this.prisma.organization.findUnique({ where: { slug } });
|
||||
if (existing) throw new ConflictException(`Organization with slug "${slug}" already exists`);
|
||||
return this.prisma.organization.create({
|
||||
data: { slug, name },
|
||||
select: { id: true, slug: true, name: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getOrg(id: string) {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
tenants: {
|
||||
select: { id: true, slug: true, name: true, isActive: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
orgAdmins: {
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!org) throw new NotFoundException('Organization not found');
|
||||
return org;
|
||||
}
|
||||
|
||||
async createOrgAdmin(orgId: string, email: string, name: string | undefined, password: string) {
|
||||
const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
|
||||
if (!org) throw new NotFoundException('Organization not found');
|
||||
|
||||
const existing = await this.prisma.orgAdmin.findUnique({
|
||||
where: { organizationId_email: { organizationId: orgId, email } },
|
||||
});
|
||||
if (existing) throw new ConflictException('An admin with this email already exists in this organization');
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const admin = await this.prisma.orgAdmin.create({
|
||||
data: { organizationId: orgId, email, name, passwordHash },
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
});
|
||||
return admin;
|
||||
}
|
||||
|
||||
async assignTenantToOrg(tenantId: string, organizationId: string | null) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
|
||||
if (organizationId !== null) {
|
||||
const org = await this.prisma.organization.findUnique({ where: { id: organizationId } });
|
||||
if (!org) throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
return this.prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: { organizationId },
|
||||
select: { id: true, slug: true, name: true, organizationId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const SUPER_ADMIN_LINKS = [
|
||||
{ href: '/admin/drafts', label: 'AI Drafts' },
|
||||
];
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/signup', '/onboard'];
|
||||
const PUBLIC_PATHS = ['/login', '/signup', '/onboard', '/member-login'];
|
||||
const ADMIN_PATHS = ['/admin'];
|
||||
const MEMBER_PATHS = ['/my'];
|
||||
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('/member-login');
|
||||
|
||||
return (
|
||||
<div className="flex h-full -m-6 min-h-screen">
|
||||
|
||||
Reference in New Issue
Block a user