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:
parent
9a33cef951
commit
5d81667ddd
|
|
@ -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.
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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't have an account?{' '}
|
||||
<Link href="/register" className="text-primary hover:underline">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue