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;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TenantInvite {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
status: 'pending' | 'accepted' | 'expired' | 'revoked';
|
||||||
|
expiresAt: string;
|
||||||
|
invitedBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TenantFormData {
|
interface TenantFormData {
|
||||||
name: string;
|
name: string;
|
||||||
plan: TenantDetail['plan'];
|
plan: TenantDetail['plan'];
|
||||||
|
|
@ -210,6 +220,9 @@ export default function TenantDetailPage() {
|
||||||
// State ----------------------------------------------------------------
|
// State ----------------------------------------------------------------
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = 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>({
|
const [form, setForm] = useState<TenantFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
plan: 'free',
|
plan: 'free',
|
||||||
|
|
@ -239,6 +252,12 @@ export default function TenantDetailPage() {
|
||||||
|
|
||||||
const members = membersData?.data ?? [];
|
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 ------------------------------------------------------------
|
// Mutations ------------------------------------------------------------
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
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 --------------------------------------------------------------
|
// Helpers --------------------------------------------------------------
|
||||||
const startEditing = useCallback(() => {
|
const startEditing = useCallback(() => {
|
||||||
if (!tenant) return;
|
if (!tenant) return;
|
||||||
|
|
@ -594,6 +632,116 @@ export default function TenantDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Right column */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Quick Actions */}
|
{/* 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 { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import { apiClient } from '@/infrastructure/api/api-client';
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
|
|
@ -87,6 +88,13 @@ export default function LoginPage() {
|
||||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</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,
|
all: ['tenants'] as const,
|
||||||
list: (params?: Record<string, string>) => [...queryKeys.tenants.all, 'list', params] as const,
|
list: (params?: Record<string, string>) => [...queryKeys.tenants.all, 'list', params] as const,
|
||||||
detail: (id: string) => [...queryKeys.tenants.all, 'detail', id] as const,
|
detail: (id: string) => [...queryKeys.tenants.all, 'detail', id] as const,
|
||||||
|
invites: (id: string) => [...queryKeys.tenants.all, 'invites', id] as const,
|
||||||
},
|
},
|
||||||
channels: {
|
channels: {
|
||||||
all: ['channels'] as const,
|
all: ['channels'] as const,
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,29 @@ import {
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { TenantProvisioningService } from '@it0/database';
|
||||||
import { UserRepository } from '../../infrastructure/repositories/user.repository';
|
import { UserRepository } from '../../infrastructure/repositories/user.repository';
|
||||||
import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository';
|
import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository';
|
||||||
import { User } from '../../domain/entities/user.entity';
|
import { User } from '../../domain/entities/user.entity';
|
||||||
import { ApiKey } from '../../domain/entities/api-key.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 { RoleType } from '../../domain/value-objects/role-type.vo';
|
||||||
import { JwtPayload } from '../../infrastructure/strategies/jwt.strategy';
|
import { JwtPayload } from '../../infrastructure/strategies/jwt.strategy';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
private readonly refreshSecret: string;
|
private readonly refreshSecret: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -23,6 +32,12 @@ export class AuthService {
|
||||||
private readonly apiKeyRepository: ApiKeyRepository,
|
private readonly apiKeyRepository: ApiKeyRepository,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
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.refreshSecret =
|
||||||
this.configService.get<string>('JWT_REFRESH_SECRET') ||
|
this.configService.get<string>('JWT_REFRESH_SECRET') ||
|
||||||
|
|
@ -35,7 +50,7 @@ export class AuthService {
|
||||||
): Promise<{
|
): Promise<{
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: 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);
|
const user = await this.userRepository.findByEmail(email);
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
|
|
@ -59,16 +74,28 @@ export class AuthService {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
roles: user.roles,
|
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(
|
async register(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
name: string,
|
name: string,
|
||||||
tenantId?: string,
|
companyName?: string,
|
||||||
): Promise<{ id: string; email: string; name: 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);
|
const existing = await this.userRepository.findByEmail(email);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException('Email already registered');
|
throw new ConflictException('Email already registered');
|
||||||
|
|
@ -76,9 +103,15 @@ export class AuthService {
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
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();
|
const user = new User();
|
||||||
user.id = crypto.randomUUID();
|
user.id = crypto.randomUUID();
|
||||||
user.tenantId = tenantId || 'default';
|
user.tenantId = 'default';
|
||||||
user.email = email;
|
user.email = email;
|
||||||
user.passwordHash = passwordHash;
|
user.passwordHash = passwordHash;
|
||||||
user.name = name;
|
user.name = name;
|
||||||
|
|
@ -89,7 +122,266 @@ export class AuthService {
|
||||||
|
|
||||||
await this.userRepository.save(user);
|
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(
|
async refreshToken(
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,13 @@ import { User } from './domain/entities/user.entity';
|
||||||
import { Role } from './domain/entities/role.entity';
|
import { Role } from './domain/entities/role.entity';
|
||||||
import { ApiKey } from './domain/entities/api-key.entity';
|
import { ApiKey } from './domain/entities/api-key.entity';
|
||||||
import { Tenant } from './domain/entities/tenant.entity';
|
import { Tenant } from './domain/entities/tenant.entity';
|
||||||
|
import { TenantInvite } from './domain/entities/tenant-invite.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
DatabaseModule.forRoot(),
|
DatabaseModule.forRoot(),
|
||||||
TypeOrmModule.forFeature([User, Role, ApiKey, Tenant]),
|
TypeOrmModule.forFeature([User, Role, ApiKey, Tenant, TenantInvite]),
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
useFactory: () => ({
|
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 { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthService } from '../../../application/services/auth.service';
|
import { AuthService } from '../../../application/services/auth.service';
|
||||||
|
|
||||||
|
|
@ -11,11 +11,39 @@ export class AuthController {
|
||||||
return this.authService.login(body.email, body.password);
|
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')
|
@Post('register')
|
||||||
async 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')
|
@Get('profile')
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Delete,
|
||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Request,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
@ -14,6 +16,7 @@ import { Repository } from 'typeorm';
|
||||||
import { RolesGuard, Roles } from '@it0/common';
|
import { RolesGuard, Roles } from '@it0/common';
|
||||||
import { TenantProvisioningService } from '@it0/database';
|
import { TenantProvisioningService } from '@it0/database';
|
||||||
import { Tenant } from '../../../domain/entities/tenant.entity';
|
import { Tenant } from '../../../domain/entities/tenant.entity';
|
||||||
|
import { AuthService } from '../../../application/services/auth.service';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Controller('api/v1/admin/tenants')
|
@Controller('api/v1/admin/tenants')
|
||||||
|
|
@ -26,6 +29,7 @@ export class TenantController {
|
||||||
@InjectRepository(Tenant)
|
@InjectRepository(Tenant)
|
||||||
private readonly tenantRepository: Repository<Tenant>,
|
private readonly tenantRepository: Repository<Tenant>,
|
||||||
private readonly tenantProvisioningService: TenantProvisioningService,
|
private readonly tenantProvisioningService: TenantProvisioningService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private toDto(t: Tenant) {
|
private toDto(t: Tenant) {
|
||||||
|
|
@ -152,4 +156,64 @@ export class TenantController {
|
||||||
const saved = await this.tenantRepository.save(tenant);
|
const saved = await this.tenantRepository.save(tenant);
|
||||||
return this.toDto(saved);
|
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