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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-22 03:10:18 -08:00
parent 9a33cef951
commit 5d81667ddd
11 changed files with 941 additions and 9 deletions

5
it0-web-admin/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@ -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<TenantFormData>({
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<TenantInvite[]>(`/api/v1/admin/tenants/${id}/invites`),
enabled: !!id,
});
// Mutations ------------------------------------------------------------
const updateMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
@ -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() {
</div>
</div>
{/* Invitations */}
<div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Invitations</h2>
<button
onClick={() => setShowInviteForm(!showInviteForm)}
className="px-3 py-1 text-xs rounded-md bg-primary text-primary-foreground hover:opacity-90"
>
+ Invite User
</button>
</div>
{showInviteForm && (
<div className="mb-4 p-4 border rounded-md bg-muted/30 space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
className="w-full px-3 py-2 bg-background border rounded-md text-sm"
placeholder="user@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Role</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className="w-full px-3 py-2 bg-background border rounded-md text-sm"
>
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</div>
{sendInviteMutation.isError && (
<p className="text-xs text-red-500">
{(sendInviteMutation.error as Error).message}
</p>
)}
<div className="flex gap-2">
<button
onClick={() => sendInviteMutation.mutate({ email: inviteEmail, role: inviteRole })}
disabled={!inviteEmail || sendInviteMutation.isPending}
className="px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
>
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invite'}
</button>
<button
onClick={() => { setShowInviteForm(false); setInviteEmail(''); }}
className="px-3 py-1.5 text-xs border rounded-md hover:bg-muted"
>
Cancel
</button>
</div>
</div>
)}
{invites.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No invitations sent yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Email</th>
<th className="text-left px-3 py-2 font-medium">Role</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-right px-3 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{invites.map((inv) => (
<tr key={inv.id} className="border-b last:border-b-0 hover:bg-muted/30">
<td className="px-3 py-2">{inv.email}</td>
<td className="px-3 py-2 capitalize">{inv.role}</td>
<td className="px-3 py-2">
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
inv.status === 'pending' && 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
inv.status === 'accepted' && 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
inv.status === 'expired' && 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
inv.status === 'revoked' && 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
)}>
{inv.status}
</span>
</td>
<td className="px-3 py-2 text-right">
{inv.status === 'pending' && (
<button
onClick={() => revokeInviteMutation.mutate(inv.id)}
disabled={revokeInviteMutation.isPending}
className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Right column */}
<div className="space-y-6">
{/* Quick Actions */}

View File

@ -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<InviteInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
async function validateInvite() {
try {
const data = await apiClient<InviteInfo>(`/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<AcceptResponse>('/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 (
<div className="w-full max-w-md p-8 bg-card rounded-lg border text-center">
<p className="text-muted-foreground">Validating invitation...</p>
</div>
);
}
if (error) {
return (
<div className="w-full max-w-md p-8 bg-card rounded-lg border text-center space-y-4">
<h1 className="text-xl font-bold text-red-500">Invalid Invitation</h1>
<p className="text-muted-foreground">{error}</p>
<Link
href="/login"
className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90"
>
Go to Login
</Link>
</div>
);
}
return (
<div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border">
<div className="text-center">
<h1 className="text-3xl font-bold">IT0</h1>
<p className="text-muted-foreground mt-2">Accept Invitation</p>
</div>
<div className="bg-muted/50 rounded-md p-4 space-y-1 text-sm">
<p>
<span className="text-muted-foreground">Organization:</span>{' '}
<span className="font-medium">{invite?.tenantName}</span>
</p>
<p>
<span className="text-muted-foreground">Email:</span>{' '}
<span className="font-medium">{invite?.email}</span>
</p>
<p>
<span className="text-muted-foreground">Role:</span>{' '}
<span className="font-medium capitalize">{invite?.role}</span>
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Full Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="John Doe"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="At least 6 characters"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="Re-enter your password"
required
/>
</div>
{submitError && (
<p className="text-sm text-red-500">{submitError}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium"
>
{isSubmitting ? 'Joining...' : 'Join Organization'}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</div>
);
}

View File

@ -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'}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Create one
</Link>
</p>
</div>
);
}

View File

@ -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<string | null>(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<RegisterResponse>('/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 (
<div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border">
<div className="text-center">
<h1 className="text-3xl font-bold">IT0</h1>
<p className="text-muted-foreground mt-2">Create your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Full Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="John Doe"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="you@example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Organization Name
<span className="text-muted-foreground font-normal ml-1">(optional)</span>
</label>
<input
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="Acme Corp"
/>
<p className="text-xs text-muted-foreground mt-1">
Provide a name to create a new organization. Leave blank to join as a viewer.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="At least 6 characters"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="Re-enter your password"
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium"
>
{isLoading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</div>
);
}

View File

@ -60,6 +60,7 @@ export const queryKeys = {
all: ['tenants'] as const,
list: (params?: Record<string, string>) => [...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,

View File

@ -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<Tenant>,
@InjectRepository(TenantInvite)
private readonly inviteRepository: Repository<TenantInvite>,
private readonly tenantProvisioningService: TenantProvisioningService,
private readonly dataSource: DataSource,
) {
this.refreshSecret =
this.configService.get<string>('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<TenantInvite> {
// 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<TenantInvite[]> {
return this.inviteRepository.find({
where: { tenantId },
order: { createdAt: 'DESC' },
});
}
async revokeInvite(inviteId: string, tenantId: string): Promise<void> {
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(

View File

@ -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: () => ({

View File

@ -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;
}

View File

@ -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')

View File

@ -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<Tenant>,
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 };
}
}