From 5d81667dddaaf529013ab44e4b61f6ebcc3cbd1a Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 22 Feb 2026 03:10:18 -0800 Subject: [PATCH] feat: add dual tenant registration (self-service + invitation) Backend: - Enhanced register endpoint to accept companyName for self-service tenant creation with schema provisioning and admin user setup - Added TenantInvite entity with token-based invitation system - Added invite CRUD endpoints to TenantController (create/list/revoke) - Added public endpoints for invite validation and acceptance Frontend: - Created registration page with optional organization name field - Created invitation acceptance page at /invite/[token] - Added invite management UI to tenant detail page - Updated login page with link to registration Co-Authored-By: Claude Opus 4.6 --- it0-web-admin/next-env.d.ts | 5 + .../src/app/(admin)/tenants/[id]/page.tsx | 148 +++++++++ .../src/app/(auth)/invite/[token]/page.tsx | 192 +++++++++++ it0-web-admin/src/app/(auth)/login/page.tsx | 8 + .../src/app/(auth)/register/page.tsx | 162 ++++++++++ .../src/infrastructure/api/query-keys.ts | 1 + .../src/application/services/auth.service.ts | 302 +++++++++++++++++- .../services/auth-service/src/auth.module.ts | 3 +- .../domain/entities/tenant-invite.entity.ts | 31 ++ .../rest/controllers/auth.controller.ts | 34 +- .../rest/controllers/tenant.controller.ts | 64 ++++ 11 files changed, 941 insertions(+), 9 deletions(-) create mode 100644 it0-web-admin/next-env.d.ts create mode 100644 it0-web-admin/src/app/(auth)/invite/[token]/page.tsx create mode 100644 it0-web-admin/src/app/(auth)/register/page.tsx create mode 100644 packages/services/auth-service/src/domain/entities/tenant-invite.entity.ts diff --git a/it0-web-admin/next-env.d.ts b/it0-web-admin/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/it0-web-admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx b/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx index f83c3de..26f7493 100644 --- a/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx +++ b/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx @@ -36,6 +36,16 @@ interface TenantMembersResponse { total: number; } +interface TenantInvite { + id: string; + email: string; + role: string; + status: 'pending' | 'accepted' | 'expired' | 'revoked'; + expiresAt: string; + invitedBy: string | null; + createdAt: string; +} + interface TenantFormData { name: string; plan: TenantDetail['plan']; @@ -210,6 +220,9 @@ export default function TenantDetailPage() { // State ---------------------------------------------------------------- const [isEditing, setIsEditing] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); + const [showInviteForm, setShowInviteForm] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState('viewer'); const [form, setForm] = useState({ name: '', plan: 'free', @@ -239,6 +252,12 @@ export default function TenantDetailPage() { const members = membersData?.data ?? []; + const { data: invites = [] } = useQuery({ + queryKey: queryKeys.tenants.invites(id), + queryFn: () => apiClient(`/api/v1/admin/tenants/${id}/invites`), + enabled: !!id, + }); + // Mutations ------------------------------------------------------------ const updateMutation = useMutation({ mutationFn: (body: Record) => @@ -274,6 +293,25 @@ export default function TenantDetailPage() { }, }); + const sendInviteMutation = useMutation({ + mutationFn: (body: { email: string; role: string }) => + apiClient(`/api/v1/admin/tenants/${id}/invites`, { method: 'POST', body }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tenants.invites(id) }); + setShowInviteForm(false); + setInviteEmail(''); + setInviteRole('viewer'); + }, + }); + + const revokeInviteMutation = useMutation({ + mutationFn: (inviteId: string) => + apiClient(`/api/v1/admin/tenants/${id}/invites/${inviteId}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tenants.invites(id) }); + }, + }); + // Helpers -------------------------------------------------------------- const startEditing = useCallback(() => { if (!tenant) return; @@ -594,6 +632,116 @@ export default function TenantDetailPage() { + {/* Invitations */} +
+
+

Invitations

+ +
+ + {showInviteForm && ( +
+
+ + setInviteEmail(e.target.value)} + className="w-full px-3 py-2 bg-background border rounded-md text-sm" + placeholder="user@example.com" + /> +
+
+ + +
+ {sendInviteMutation.isError && ( +

+ {(sendInviteMutation.error as Error).message} +

+ )} +
+ + +
+
+ )} + + {invites.length === 0 ? ( +

+ No invitations sent yet. +

+ ) : ( +
+ + + + + + + + + + + {invites.map((inv) => ( + + + + + + + ))} + +
EmailRoleStatusActions
{inv.email}{inv.role} + + {inv.status} + + + {inv.status === 'pending' && ( + + )} +
+
+ )} +
+ + {/* Right column */}
{/* Quick Actions */} diff --git a/it0-web-admin/src/app/(auth)/invite/[token]/page.tsx b/it0-web-admin/src/app/(auth)/invite/[token]/page.tsx new file mode 100644 index 0000000..c3f8985 --- /dev/null +++ b/it0-web-admin/src/app/(auth)/invite/[token]/page.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import Link from 'next/link'; +import { apiClient } from '@/infrastructure/api/api-client'; + +interface InviteInfo { + email: string; + tenantName: string; + role: string; + expiresAt: string; +} + +interface AcceptResponse { + accessToken: string; + refreshToken: string; + user: { id: string; email: string; name: string; roles: string[]; tenantId: string }; +} + +export default function AcceptInvitePage() { + const router = useRouter(); + const params = useParams(); + const token = params.token as string; + + const [invite, setInvite] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + useEffect(() => { + async function validateInvite() { + try { + const data = await apiClient(`/api/v1/auth/invite/${token}`); + setInvite(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid invitation'); + } finally { + setLoading(false); + } + } + validateInvite(); + }, [token]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + setSubmitError('Passwords do not match'); + return; + } + if (password.length < 6) { + setSubmitError('Password must be at least 6 characters'); + return; + } + + setIsSubmitting(true); + setSubmitError(null); + + try { + const data = await apiClient('/api/v1/auth/accept-invite', { + method: 'POST', + body: { token, password, name }, + }); + + localStorage.setItem('access_token', data.accessToken); + localStorage.setItem('refresh_token', data.refreshToken); + localStorage.setItem('user', JSON.stringify(data.user)); + + try { + const payload = JSON.parse(atob(data.accessToken.split('.')[1])); + if (payload.tenantId) { + localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId })); + } + } catch { /* ignore */ } + + router.push('/dashboard'); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to accept invitation'); + } finally { + setIsSubmitting(false); + } + }; + + if (loading) { + return ( +
+

Validating invitation...

+
+ ); + } + + if (error) { + return ( +
+

Invalid Invitation

+

{error}

+ + Go to Login + +
+ ); + } + + return ( +
+
+

IT0

+

Accept Invitation

+
+ +
+

+ Organization:{' '} + {invite?.tenantName} +

+

+ Email:{' '} + {invite?.email} +

+

+ Role:{' '} + {invite?.role} +

+
+ +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="John Doe" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="At least 6 characters" + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="Re-enter your password" + required + /> +
+ + {submitError && ( +

{submitError}

+ )} + + +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+ ); +} diff --git a/it0-web-admin/src/app/(auth)/login/page.tsx b/it0-web-admin/src/app/(auth)/login/page.tsx index d916a43..72929c9 100644 --- a/it0-web-admin/src/app/(auth)/login/page.tsx +++ b/it0-web-admin/src/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { apiClient } from '@/infrastructure/api/api-client'; interface LoginResponse { @@ -87,6 +88,13 @@ export default function LoginPage() { {isLoading ? 'Signing in...' : 'Sign In'} + +

+ Don't have an account?{' '} + + Create one + +

); } diff --git a/it0-web-admin/src/app/(auth)/register/page.tsx b/it0-web-admin/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..7564370 --- /dev/null +++ b/it0-web-admin/src/app/(auth)/register/page.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { apiClient } from '@/infrastructure/api/api-client'; + +interface RegisterResponse { + accessToken: string; + refreshToken: string; + user: { id: string; email: string; name: string; roles: string[]; tenantId: string }; +} + +export default function RegisterPage() { + const router = useRouter(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [companyName, setCompanyName] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + setIsLoading(true); + setError(null); + + try { + const data = await apiClient('/api/v1/auth/register', { + method: 'POST', + body: { + email, + password, + name, + ...(companyName ? { companyName } : {}), + }, + }); + + localStorage.setItem('access_token', data.accessToken); + localStorage.setItem('refresh_token', data.refreshToken); + localStorage.setItem('user', JSON.stringify(data.user)); + + try { + const payload = JSON.parse(atob(data.accessToken.split('.')[1])); + if (payload.tenantId) { + localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId })); + } + } catch { /* ignore decode errors */ } + + router.push('/dashboard'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

IT0

+

Create your account

+
+ +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="John Doe" + required + /> +
+ +
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="you@example.com" + required + /> +
+ +
+ + setCompanyName(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="Acme Corp" + /> +

+ Provide a name to create a new organization. Leave blank to join as a viewer. +

+
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="At least 6 characters" + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder="Re-enter your password" + required + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+ ); +} diff --git a/it0-web-admin/src/infrastructure/api/query-keys.ts b/it0-web-admin/src/infrastructure/api/query-keys.ts index 576d42d..6f0b729 100644 --- a/it0-web-admin/src/infrastructure/api/query-keys.ts +++ b/it0-web-admin/src/infrastructure/api/query-keys.ts @@ -60,6 +60,7 @@ export const queryKeys = { all: ['tenants'] as const, list: (params?: Record) => [...queryKeys.tenants.all, 'list', params] as const, detail: (id: string) => [...queryKeys.tenants.all, 'detail', id] as const, + invites: (id: string) => [...queryKeys.tenants.all, 'invites', id] as const, }, channels: { all: ['channels'] as const, diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts index 4271af8..d06cda0 100644 --- a/packages/services/auth-service/src/application/services/auth.service.ts +++ b/packages/services/auth-service/src/application/services/auth.service.ts @@ -2,20 +2,29 @@ import { Injectable, UnauthorizedException, ConflictException, + BadRequestException, + NotFoundException, + Logger, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import * as crypto from 'crypto'; +import { TenantProvisioningService } from '@it0/database'; import { UserRepository } from '../../infrastructure/repositories/user.repository'; import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository'; import { User } from '../../domain/entities/user.entity'; import { ApiKey } from '../../domain/entities/api-key.entity'; +import { Tenant } from '../../domain/entities/tenant.entity'; +import { TenantInvite } from '../../domain/entities/tenant-invite.entity'; import { RoleType } from '../../domain/value-objects/role-type.vo'; import { JwtPayload } from '../../infrastructure/strategies/jwt.strategy'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); private readonly refreshSecret: string; constructor( @@ -23,6 +32,12 @@ export class AuthService { private readonly apiKeyRepository: ApiKeyRepository, private readonly jwtService: JwtService, private readonly configService: ConfigService, + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + @InjectRepository(TenantInvite) + private readonly inviteRepository: Repository, + private readonly tenantProvisioningService: TenantProvisioningService, + private readonly dataSource: DataSource, ) { this.refreshSecret = this.configService.get('JWT_REFRESH_SECRET') || @@ -35,7 +50,7 @@ export class AuthService { ): Promise<{ accessToken: string; refreshToken: string; - user: { id: string; email: string; name: string; roles: string[] }; + user: { id: string; email: string; name: string; roles: string[]; tenantId: string }; }> { const user = await this.userRepository.findByEmail(email); if (!user || !user.isActive) { @@ -59,16 +74,28 @@ export class AuthService { email: user.email, name: user.name, roles: user.roles, + tenantId: user.tenantId, }, }; } + /** + * Register a new user. + * If companyName is provided, creates a new tenant with schema provisioning + * and sets the user as admin (self-service registration). + * Otherwise joins the default tenant as viewer. + */ async register( email: string, password: string, name: string, - tenantId?: string, - ): Promise<{ id: string; email: string; name: string }> { + companyName?: string, + ): Promise<{ + accessToken: string; + refreshToken: string; + user: { id: string; email: string; name: string; roles: string[]; tenantId: string }; + }> { + // Check email uniqueness across all schemas (public users table check) const existing = await this.userRepository.findByEmail(email); if (existing) { throw new ConflictException('Email already registered'); @@ -76,9 +103,15 @@ export class AuthService { const passwordHash = await bcrypt.hash(password, 12); + if (companyName) { + // Self-service: create tenant + provision schema + create admin user + return this.registerWithNewTenant(email, passwordHash, name, companyName); + } + + // Default: join default tenant as viewer const user = new User(); user.id = crypto.randomUUID(); - user.tenantId = tenantId || 'default'; + user.tenantId = 'default'; user.email = email; user.passwordHash = passwordHash; user.name = name; @@ -89,7 +122,266 @@ export class AuthService { await this.userRepository.save(user); - return { id: user.id, email: user.email, name: user.name }; + const tokens = this.generateTokens(user); + return { + ...tokens, + user: { + id: user.id, + email: user.email, + name: user.name, + roles: user.roles, + tenantId: user.tenantId, + }, + }; + } + + private async registerWithNewTenant( + email: string, + passwordHash: string, + name: string, + companyName: string, + ) { + // Generate slug from company name + const slug = companyName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 40); + + // Check slug uniqueness + const existingTenant = await this.tenantRepository.findOneBy({ slug }); + if (existingTenant) { + throw new ConflictException('Organization name already taken'); + } + + // 1. Create tenant record in public schema + const tenant = this.tenantRepository.create({ + id: crypto.randomUUID(), + name: companyName, + slug, + plan: 'free', + status: 'active', + adminEmail: email, + maxServers: 10, + maxUsers: 5, + maxStandingOrders: 20, + maxAgentTokensPerMonth: 100000, + }); + await this.tenantRepository.save(tenant); + + // 2. Provision the tenant's database schema + try { + await this.tenantProvisioningService.provisionTenant(slug); + this.logger.log(`Schema provisioned for self-service tenant "${slug}"`); + } catch (err) { + // Rollback: remove tenant record if schema provision fails + await this.tenantRepository.remove(tenant); + this.logger.error(`Failed to provision schema for "${slug}":`, err); + throw new BadRequestException('Failed to create organization. Please try again.'); + } + + // 3. Insert admin user directly into the new tenant schema + const userId = crypto.randomUUID(); + const now = new Date(); + const schemaName = `it0_t_${slug}`; + + const qr = this.dataSource.createQueryRunner(); + await qr.connect(); + try { + await qr.query(`SET search_path TO "${schemaName}", public`); + await qr.query( + `INSERT INTO users (id, tenant_id, email, password_hash, name, roles, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [userId, slug, email, passwordHash, name, [RoleType.ADMIN], true, now, now], + ); + } finally { + await qr.release(); + } + + // Build user object for token generation + const user = new User(); + user.id = userId; + user.tenantId = slug; + user.email = email; + user.passwordHash = passwordHash; + user.name = name; + user.roles = [RoleType.ADMIN]; + user.isActive = true; + + const tokens = this.generateTokens(user); + return { + ...tokens, + user: { + id: user.id, + email: user.email, + name: user.name, + roles: user.roles, + tenantId: user.tenantId, + }, + }; + } + + /* ---- Invitation Flow ---- */ + + async createInvite( + tenantId: string, + email: string, + role: string, + invitedBy: string, + ): Promise { + // Check tenant exists + const tenant = await this.tenantRepository.findOneBy({ id: tenantId }); + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + // Check for existing pending invite + const existing = await this.inviteRepository.findOne({ + where: { tenantId, email, status: 'pending' as const }, + }); + if (existing) { + throw new ConflictException('An invitation has already been sent to this email'); + } + + const invite = this.inviteRepository.create({ + id: crypto.randomUUID(), + tenantId, + email, + token: crypto.randomBytes(32).toString('hex'), + role: role || RoleType.VIEWER, + status: 'pending', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + invitedBy, + }); + + return this.inviteRepository.save(invite); + } + + async listInvites(tenantId: string): Promise { + return this.inviteRepository.find({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + }); + } + + async revokeInvite(inviteId: string, tenantId: string): Promise { + const invite = await this.inviteRepository.findOne({ + where: { id: inviteId, tenantId }, + }); + if (!invite) { + throw new NotFoundException('Invite not found'); + } + invite.status = 'revoked'; + await this.inviteRepository.save(invite); + } + + async validateInvite(token: string): Promise<{ + email: string; + tenantName: string; + role: string; + expiresAt: string; + }> { + const invite = await this.inviteRepository.findOneBy({ token }); + if (!invite) { + throw new NotFoundException('Invalid invitation link'); + } + if (invite.status !== 'pending') { + throw new BadRequestException(`Invitation has been ${invite.status}`); + } + if (new Date() > invite.expiresAt) { + invite.status = 'expired'; + await this.inviteRepository.save(invite); + throw new BadRequestException('Invitation has expired'); + } + + const tenant = await this.tenantRepository.findOneBy({ id: invite.tenantId }); + return { + email: invite.email, + tenantName: tenant?.name || 'Unknown', + role: invite.role, + expiresAt: invite.expiresAt.toISOString(), + }; + } + + async acceptInvite( + token: string, + password: string, + name: string, + ): Promise<{ + accessToken: string; + refreshToken: string; + user: { id: string; email: string; name: string; roles: string[]; tenantId: string }; + }> { + const invite = await this.inviteRepository.findOneBy({ token }); + if (!invite || invite.status !== 'pending') { + throw new BadRequestException('Invalid or expired invitation'); + } + if (new Date() > invite.expiresAt) { + invite.status = 'expired'; + await this.inviteRepository.save(invite); + throw new BadRequestException('Invitation has expired'); + } + + // Find the tenant to get the slug (used as schema name) + const tenant = await this.tenantRepository.findOneBy({ id: invite.tenantId }); + if (!tenant || tenant.status !== 'active') { + throw new BadRequestException('Tenant is not active'); + } + + const passwordHash = await bcrypt.hash(password, 12); + const userId = crypto.randomUUID(); + const now = new Date(); + const schemaName = `it0_t_${tenant.slug}`; + + // Insert user into the tenant's schema + const qr = this.dataSource.createQueryRunner(); + await qr.connect(); + try { + await qr.query(`SET search_path TO "${schemaName}", public`); + + // Check if email already exists in this tenant + const existingRows = await qr.query( + `SELECT id FROM users WHERE email = $1 LIMIT 1`, + [invite.email], + ); + if (existingRows.length > 0) { + throw new ConflictException('Email already registered in this organization'); + } + + await qr.query( + `INSERT INTO users (id, tenant_id, email, password_hash, name, roles, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [userId, tenant.slug, invite.email, passwordHash, name, [invite.role], true, now, now], + ); + } finally { + await qr.release(); + } + + // Mark invite as accepted + invite.status = 'accepted'; + await this.inviteRepository.save(invite); + + // Build user object for token generation + const user = new User(); + user.id = userId; + user.tenantId = tenant.slug; + user.email = invite.email; + user.passwordHash = passwordHash; + user.name = name; + user.roles = [invite.role]; + user.isActive = true; + + const tokens = this.generateTokens(user); + return { + ...tokens, + user: { + id: user.id, + email: user.email, + name: user.name, + roles: user.roles, + tenantId: user.tenantId, + }, + }; } async refreshToken( diff --git a/packages/services/auth-service/src/auth.module.ts b/packages/services/auth-service/src/auth.module.ts index e93891c..9bd81fb 100644 --- a/packages/services/auth-service/src/auth.module.ts +++ b/packages/services/auth-service/src/auth.module.ts @@ -19,12 +19,13 @@ import { User } from './domain/entities/user.entity'; import { Role } from './domain/entities/role.entity'; import { ApiKey } from './domain/entities/api-key.entity'; import { Tenant } from './domain/entities/tenant.entity'; +import { TenantInvite } from './domain/entities/tenant-invite.entity'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), DatabaseModule.forRoot(), - TypeOrmModule.forFeature([User, Role, ApiKey, Tenant]), + TypeOrmModule.forFeature([User, Role, ApiKey, Tenant, TenantInvite]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ useFactory: () => ({ diff --git a/packages/services/auth-service/src/domain/entities/tenant-invite.entity.ts b/packages/services/auth-service/src/domain/entities/tenant-invite.entity.ts new file mode 100644 index 0000000..f880dd5 --- /dev/null +++ b/packages/services/auth-service/src/domain/entities/tenant-invite.entity.ts @@ -0,0 +1,31 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity({ name: 'tenant_invites', schema: 'public' }) +export class TenantInvite { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255 }) + tenantId!: string; + + @Column({ type: 'varchar', length: 255 }) + email!: string; + + @Column({ type: 'varchar', length: 255, unique: true }) + token!: string; + + @Column({ type: 'varchar', length: 30, default: 'viewer' }) + role!: string; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status!: 'pending' | 'accepted' | 'expired' | 'revoked'; + + @Column({ type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ type: 'varchar', length: 255, nullable: true }) + invitedBy!: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts index cdc7bee..cb973c7 100644 --- a/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts +++ b/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common'; +import { Controller, Post, Body, Get, Param, UseGuards, Request } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { AuthService } from '../../../application/services/auth.service'; @@ -11,11 +11,39 @@ export class AuthController { return this.authService.login(body.email, body.password); } + /** + * Register a new user. + * If companyName is provided, creates a new tenant (self-service registration). + * Otherwise joins the default tenant as viewer. + */ @Post('register') async register( - @Body() body: { email: string; password: string; name: string }, + @Body() body: { email: string; password: string; name: string; companyName?: string }, ) { - return this.authService.register(body.email, body.password, body.name); + return this.authService.register( + body.email, + body.password, + body.name, + body.companyName, + ); + } + + /** + * Validate an invitation token (public endpoint). + */ + @Get('invite/:token') + async validateInvite(@Param('token') token: string) { + return this.authService.validateInvite(token); + } + + /** + * Accept an invitation and create a user account (public endpoint). + */ + @Post('accept-invite') + async acceptInvite( + @Body() body: { token: string; password: string; name: string }, + ) { + return this.authService.acceptInvite(body.token, body.password, body.name); } @Get('profile') diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts index 08f6768..bd55d14 100644 --- a/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts +++ b/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts @@ -2,10 +2,12 @@ import { Controller, Get, Post, + Delete, Patch, Param, Body, UseGuards, + Request, NotFoundException, Logger, } from '@nestjs/common'; @@ -14,6 +16,7 @@ import { Repository } from 'typeorm'; import { RolesGuard, Roles } from '@it0/common'; import { TenantProvisioningService } from '@it0/database'; import { Tenant } from '../../../domain/entities/tenant.entity'; +import { AuthService } from '../../../application/services/auth.service'; import * as crypto from 'crypto'; @Controller('api/v1/admin/tenants') @@ -26,6 +29,7 @@ export class TenantController { @InjectRepository(Tenant) private readonly tenantRepository: Repository, private readonly tenantProvisioningService: TenantProvisioningService, + private readonly authService: AuthService, ) {} private toDto(t: Tenant) { @@ -152,4 +156,64 @@ export class TenantController { const saved = await this.tenantRepository.save(tenant); return this.toDto(saved); } + + /* ---- Invitation Management ---- */ + + /** + * POST /api/v1/admin/tenants/:id/invites + * Send an invitation to join this tenant. + */ + @Post(':id/invites') + async createInvite( + @Param('id') tenantId: string, + @Body() body: { email: string; role?: string }, + @Request() req: any, + ) { + const invite = await this.authService.createInvite( + tenantId, + body.email, + body.role || 'viewer', + req.user?.sub || 'system', + ); + return { + id: invite.id, + email: invite.email, + role: invite.role, + token: invite.token, + status: invite.status, + expiresAt: invite.expiresAt, + createdAt: invite.createdAt, + }; + } + + /** + * GET /api/v1/admin/tenants/:id/invites + * List all invitations for this tenant. + */ + @Get(':id/invites') + async listInvites(@Param('id') tenantId: string) { + const invites = await this.authService.listInvites(tenantId); + return invites.map((inv) => ({ + id: inv.id, + email: inv.email, + role: inv.role, + status: inv.status, + expiresAt: inv.expiresAt, + invitedBy: inv.invitedBy, + createdAt: inv.createdAt, + })); + } + + /** + * DELETE /api/v1/admin/tenants/:id/invites/:inviteId + * Revoke a pending invitation. + */ + @Delete(':id/invites/:inviteId') + async revokeInvite( + @Param('id') tenantId: string, + @Param('inviteId') inviteId: string, + ) { + await this.authService.revokeInvite(inviteId, tenantId); + return { success: true }; + } }