Phase 10: Platform Admin
Platform Admin tenant management for SaaS operators
Phase 10: Platform Admin
Goal: Enable Platform Admins (SaaS operators) to create and manage tenants.
| Attribute | Value |
|---|---|
| Steps | 01-10 |
| Estimated Time | 12-14 hours |
| Dependencies | Phase 01 (auth), Phase 01.5 (design system) |
| Completion Gate | Platform Admin can create tenants, manage domains, and enable domain-based registration |
Phase Context (READ FIRST)
What This Phase Accomplishes
- Platform Admin dashboard UI at
/admin/* - Tenant CRUD API endpoints at
/api/v1/admin/tenants - Direct Tenant Admin assignment (no email service required)
- Tenant status management (TRIAL/ACTIVE/SUSPENDED)
- Domain-based registration control (enterprise-only mode)
- Multiple domains per tenant (e.g., capptoo.com, capptoo.ch)
- TenantDomain model for domain management
What This Phase Does NOT Include
- Email-based invitations (future enhancement)
- Billing integration
- Multi-region tenant placement
- Self-service signup (REJECTED - enterprise-only registration)
- Domain verification via DNS (optional future enhancement)
Target User Stories
PA-01: "As a Platform Admin, I want to create a new Tenant, so that the Tenant Admin can create users and employees."
PA-06: "As a Platform Admin, I want to configure allowed email domains for a tenant so only employees with company emails can register."
PA-07: "As a User with a company email, I want to automatically join my company's tenant when I register so I don't need an invitation."
Why This Phase Exists
This phase implements the enterprise-only registration model:
- Tenants are created by Platform Admins (not auto-provisioned)
- Users can only register with pre-configured email domains
- Supports enterprise sales, white-label deployments, and B2B scenarios
This completes the Platform Admin → Tenant Admin → Employee creation flow for MVP.
Step 01: Platform Admin Guard
Input
- Phase 01 complete (auth working)
Constraints
- Separate guard from tenant-scoped auth
- SYSTEM_ADMIN role only
- No tenant context required
Task
Create @PlatformAdminOnly() guard:
// apps/api/src/common/guards/platform-admin.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { UseGuards } from '@nestjs/common';
@Injectable()
export class PlatformAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Authentication required');
}
if (user.systemRole !== 'SYSTEM_ADMIN') {
throw new ForbiddenException('Platform Admin access required');
}
return true;
}
}
// Decorator for cleaner usage
export const PlatformAdminOnly = () => UseGuards(PlatformAdminGuard);Completion Gate
- Guard exists at
apps/api/src/common/guards/platform-admin.guard.ts - Guard rejects users without SYSTEM_ADMIN role with 403
- Guard is exported from common module
Step 02: Admin Tenant API
Input
- Step 01 complete
Constraints
- Use
@PlatformAdminOnly()guard on all endpoints - No X-Tenant-ID header required (cross-tenant access)
- Follow existing API patterns from Phase 01
Task
Create admin tenant controller and service:
File: apps/api/src/modules/admin/admin-tenant.controller.ts
import { Controller, Get, Post, Patch, Param, Query, Body } from '@nestjs/common';
import { PlatformAdminOnly } from '@/common/guards/platform-admin.guard';
import { AdminTenantService } from './admin-tenant.service';
import { CreateTenantDto, UpdateTenantDto, PaginationDto } from './dto';
@Controller('api/v1/admin/tenants')
@PlatformAdminOnly()
export class AdminTenantController {
constructor(private readonly tenantService: AdminTenantService) {}
@Post()
async createTenant(@Body() dto: CreateTenantDto) {
return this.tenantService.create(dto);
}
@Get()
async listTenants(@Query() query: PaginationDto) {
return this.tenantService.findAll(query);
}
@Get(':id')
async getTenant(@Param('id') id: string) {
return this.tenantService.findById(id);
}
@Patch(':id')
async updateTenant(
@Param('id') id: string,
@Body() dto: UpdateTenantDto
) {
return this.tenantService.update(id, dto);
}
}Endpoints:
| Method | Path | Request | Response |
|---|---|---|---|
| POST | /api/v1/admin/tenants | { name, status? } | { id, name, status, createdAt } |
| GET | /api/v1/admin/tenants | ?page&pageSize&search | { data: Tenant[], total } |
| GET | /api/v1/admin/tenants/:id | - | { id, name, status, userCount, createdAt } |
| PATCH | /api/v1/admin/tenants/:id | { name?, status? } | { id, name, status, updatedAt } |
Completion Gate
- All 4 endpoints work with Postman/curl
- Endpoints return 403 for non-SYSTEM_ADMIN users
- Tenant list supports pagination and search
Step 03: Assign Tenant Admin API
Input
- Step 02 complete
Constraints
- Direct database creation (no email service)
- User sets password via forgot-password flow on first login
- Creates user with HR_ADMIN role in the specified tenant
Task
Add assign-admin endpoint to admin tenant controller:
@Post(':id/assign-admin')
async assignAdmin(
@Param('id') tenantId: string,
@Body() dto: AssignAdminDto
) {
return this.tenantService.assignAdmin(tenantId, dto);
}Service method:
async assignAdmin(tenantId: string, dto: AssignAdminDto) {
// Verify tenant exists
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
throw new NotFoundException('Tenant not found');
}
// Check if email already exists
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Create user with HR_ADMIN role (no password - forces password reset)
const user = await this.prisma.user.create({
data: {
email: dto.email,
name: dto.name,
tenantId: tenantId,
systemRole: 'HR_ADMIN',
// password is null - user must use forgot-password flow
},
});
return {
id: user.id,
email: user.email,
name: user.name,
role: user.systemRole,
tenantId: user.tenantId,
};
}DTO:
// assign-admin.dto.ts
import { z } from 'zod';
export const assignAdminSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
export type AssignAdminDto = z.infer<typeof assignAdminSchema>;Completion Gate
- Endpoint creates user in correct tenant with HR_ADMIN role
- Returns 404 if tenant doesn't exist
- Returns 409 if email already exists
- Created user has no password (null)
Step 04: Platform Admin Sidebar
Input
- Step 03 complete
- Phase 01.5 design system in place
Constraints
- Only visible to SYSTEM_ADMIN users
- Separate section from tenant navigation
- Use design system components
Task
Update sidebar to show admin section conditionally:
// apps/web/src/components/layout/Sidebar.tsx
// Add admin navigation items
const adminNavItems = [
{ href: '/admin/tenants', label: 'Tenants', icon: Building2 },
// Future: { href: '/admin/analytics', label: 'Analytics', icon: BarChart },
// Future: { href: '/admin/settings', label: 'Settings', icon: Settings },
];
// In sidebar component
{user?.systemRole === 'SYSTEM_ADMIN' && (
<div className="mt-6 pt-6 border-t border-border">
<p className="px-3 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Platform Admin
</p>
<nav className="space-y-1">
{adminNavItems.map((item) => (
<SidebarLink
key={item.href}
href={item.href}
icon={item.icon}
>
{item.label}
</SidebarLink>
))}
</nav>
</div>
)}Completion Gate
- Admin section visible only to SYSTEM_ADMIN users
- Admin section hidden for HR_ADMIN, MANAGER, EMPLOYEE roles
- Styling matches existing sidebar design
Step 05: Tenant Management UI
Input
- Step 04 complete
Constraints
- Use design system components (ContentCard, Table, Badge, etc.)
- Follow existing page patterns from Phase 02 (Employee pages)
- Use React Query for data fetching
Task
Create pages at /admin/*:
1. Tenant List Page (/admin/tenants)
// apps/web/src/app/(dashboard)/admin/tenants/page.tsx
export default function TenantsPage() {
const { data, isLoading } = useQuery({
queryKey: ['admin', 'tenants'],
queryFn: () => fetchAdminTenants(),
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Tenants</h1>
<Button asChild>
<Link href="/admin/tenants/new">Create Tenant</Link>
</Button>
</div>
<ContentCard>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Users</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell>{tenant.name}</TableCell>
<TableCell>
<Badge variant={statusVariant(tenant.status)}>
{tenant.status}
</Badge>
</TableCell>
<TableCell>{tenant.userCount}</TableCell>
<TableCell>{formatDate(tenant.createdAt)}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/tenants/${tenant.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ContentCard>
</div>
);
}2. Create Tenant Page (/admin/tenants/new)
// apps/web/src/app/(dashboard)/admin/tenants/new/page.tsx
export default function CreateTenantPage() {
const router = useRouter();
const mutation = useMutation({
mutationFn: createTenant,
onSuccess: (data) => {
toast.success('Tenant created successfully');
router.push(`/admin/tenants/${data.id}`);
},
});
return (
<div className="max-w-2xl space-y-6">
<h1 className="text-2xl font-bold">Create Tenant</h1>
<ContentCard>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<Label htmlFor="name">Organization Name</Label>
<Input id="name" {...register('name')} />
</div>
<div>
<Label htmlFor="status">Status</Label>
<Select {...register('status')}>
<SelectItem value="TRIAL">Trial</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
</Select>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline" type="button" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
Create Tenant
</Button>
</div>
</form>
</ContentCard>
</div>
);
}3. Tenant Detail Page (/admin/tenants/[id])
// apps/web/src/app/(dashboard)/admin/tenants/[id]/page.tsx
'use client';
import { use } from 'react';
export default function TenantDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Next.js 15: params is a Promise, use React.use() to unwrap
const { id } = use(params);
const { data: tenant } = useQuery({
queryKey: ['admin', 'tenants', id],
queryFn: () => fetchAdminTenant(id),
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{tenant?.name}</h1>
<AssignAdminDialog tenantId={id} />
</div>
{/* Tenant info card */}
<ContentCard>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Status</dt>
<dd><Badge>{tenant?.status}</Badge></dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Created</dt>
<dd>{formatDate(tenant?.createdAt)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Users</dt>
<dd>{tenant?.userCount}</dd>
</div>
</dl>
</ContentCard>
{/* Users in tenant */}
<ContentCard>
<h2 className="text-lg font-semibold mb-4">Users</h2>
<TenantUsersList tenantId={id} />
</ContentCard>
</div>
);
}Completion Gate
-
/admin/tenantsshows list of all tenants with pagination -
/admin/tenants/newcreates a new tenant -
/admin/tenants/[id]shows tenant details and users - All pages use design system components
- Loading and error states handled
Step 06: Assign Admin UI
Input
- Step 05 complete
Constraints
- Simple dialog form (no email preview)
- Show success message with password reset instructions
- Validate email format
Task
Create AssignAdminDialog component:
// apps/web/src/components/admin/AssignAdminDialog.tsx
export function AssignAdminDialog({ tenantId }: { tenantId: string }) {
const [open, setOpen] = useState(false);
const mutation = useMutation({
mutationFn: (data: AssignAdminDto) => assignAdmin(tenantId, data),
onSuccess: () => {
toast.success(
'Admin created successfully',
{ description: 'They should use "Forgot Password" to set their password.' }
);
setOpen(false);
},
onError: (error) => {
toast.error('Failed to assign admin', { description: error.message });
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Assign Admin</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Tenant Admin</DialogTitle>
<DialogDescription>
Create a new admin user for this tenant. They will need to use
"Forgot Password" to set their password on first login.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@company.com"
{...register('email')}
/>
</div>
<div>
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="John Doe"
{...register('name')}
/>
</div>
</div>
<DialogFooter className="mt-6">
<Button variant="outline" type="button" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Admin'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}Completion Gate
- Dialog opens from tenant detail page
- Form validates email and name
- Success toast shows password reset instructions
- User appears in tenant users list after creation
- Full user story flow works end-to-end
Step 07: TenantDomain Model
Input
- Step 06 complete
Constraints
- One domain can only belong to one tenant (unique)
- Primary domain used for display/identification
- Domain format: lowercase, no protocol (e.g.,
capptoo.com)
Task
Add TenantDomain model to Prisma schema:
model TenantDomain {
id String @id @default(cuid())
tenantId String
domain String @unique // e.g., "capptoo.com"
verified Boolean @default(false)
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("tenant_domains")
}Update Tenant model to include relation:
model Tenant {
// ... existing fields
domains TenantDomain[]
}Run migration: npx prisma migrate dev --name add_tenant_domains
Completion Gate
- TenantDomain model exists in schema
- Migration runs successfully
- Index on tenantId created
- Cascade delete works (deleting tenant removes domains)
Step 08: Domain Management API
Input
- Step 07 complete
Constraints
- Use
@PlatformAdminOnly()guard - Validate domain format (lowercase, no protocol, valid TLD)
- Cannot delete last domain if tenant has users
Task
Add domain management endpoints to AdminTenantController:
// GET /api/v1/admin/tenants/:id/domains
@Get(':id/domains')
async listDomains(@Param('id') tenantId: string) {
return this.tenantService.listDomains(tenantId);
}
// POST /api/v1/admin/tenants/:id/domains
@Post(':id/domains')
async addDomain(
@Param('id') tenantId: string,
@Body() dto: AddDomainDto
) {
return this.tenantService.addDomain(tenantId, dto);
}
// DELETE /api/v1/admin/tenants/:id/domains/:domainId
@Delete(':id/domains/:domainId')
async removeDomain(
@Param('id') tenantId: string,
@Param('domainId') domainId: string
) {
return this.tenantService.removeDomain(tenantId, domainId);
}
// PATCH /api/v1/admin/tenants/:id/domains/:domainId
@Patch(':id/domains/:domainId')
async updateDomain(
@Param('id') tenantId: string,
@Param('domainId') domainId: string,
@Body() dto: UpdateDomainDto
) {
return this.tenantService.updateDomain(tenantId, domainId, dto);
}DTOs:
// add-domain.dto.ts
export class AddDomainDto {
@IsString()
@Matches(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)
domain: string;
@IsOptional()
@IsBoolean()
isPrimary?: boolean;
}
// update-domain.dto.ts
export class UpdateDomainDto {
@IsOptional()
@IsBoolean()
isPrimary?: boolean;
}Service methods:
async addDomain(tenantId: string, dto: AddDomainDto) {
// Verify tenant exists
await this.findById(tenantId);
// Normalize domain (lowercase)
const normalizedDomain = dto.domain.toLowerCase();
// Check if domain already claimed
const existing = await this.prisma.tenantDomain.findUnique({
where: { domain: normalizedDomain },
});
if (existing) {
throw new ConflictException(`Domain ${normalizedDomain} is already registered`);
}
// If setting as primary, unset other primary domains
if (dto.isPrimary) {
await this.prisma.tenantDomain.updateMany({
where: { tenantId, isPrimary: true },
data: { isPrimary: false },
});
}
return this.prisma.tenantDomain.create({
data: {
tenantId,
domain: normalizedDomain,
isPrimary: dto.isPrimary ?? false,
},
});
}
async removeDomain(tenantId: string, domainId: string) {
const domain = await this.prisma.tenantDomain.findFirst({
where: { id: domainId, tenantId },
});
if (!domain) {
throw new NotFoundException('Domain not found');
}
// Check if this is the last domain and tenant has users
const [domainCount, userCount] = await Promise.all([
this.prisma.tenantDomain.count({ where: { tenantId } }),
this.prisma.user.count({ where: { tenantId } }),
]);
if (domainCount === 1 && userCount > 0) {
throw new BadRequestException('Cannot remove the last domain when tenant has users');
}
return this.prisma.tenantDomain.delete({
where: { id: domainId },
});
}Completion Gate
- All 4 domain endpoints work
- Domain uniqueness enforced across all tenants
- Proper error messages for conflicts
- Cannot remove last domain if tenant has users
Step 09: Domain-Based Registration Logic
Input
- Step 08 complete
Constraints
- Modify Auth.js
createUserevent - REJECT unrecognized domains (enterprise-only mode)
- New users joining via domain get EMPLOYEE role (not admin)
Task
Update apps/web/auth.ts createUser event:
events: {
async createUser({ user }) {
const emailDomain = user.email?.split('@')[1]?.toLowerCase();
if (!emailDomain) {
// Delete the user record that was just created
await prisma.user.delete({ where: { id: user.id } });
throw new Error('Invalid email address');
}
// Check if domain is linked to an existing tenant
const tenantDomain = await prisma.tenantDomain.findUnique({
where: { domain: emailDomain },
include: { tenant: true },
});
if (!tenantDomain) {
// REJECT - Enterprise only mode
// Delete the user record that was just created
await prisma.user.delete({ where: { id: user.id } });
throw new Error(
'Registration not allowed. Your email domain is not registered with any organization. ' +
'Please contact your administrator.'
);
}
// Join existing tenant as EMPLOYEE
await prisma.user.update({
where: { id: user.id },
data: {
tenantId: tenantDomain.tenantId,
systemRole: 'EMPLOYEE',
},
});
},
}Error handling in sign-in page:
// apps/web/src/app/auth/signin/page.tsx
// Handle the error message from registration rejection
const searchParams = useSearchParams();
const error = searchParams.get('error');
{error === 'Registration not allowed' && (
<Alert variant="destructive">
<AlertTitle>Registration Not Allowed</AlertTitle>
<AlertDescription>
Your email domain is not registered with any organization.
Please contact your administrator.
</AlertDescription>
</Alert>
)}Completion Gate
- Users with recognized domains join correct tenant automatically
- Users with unrecognized domains see clear error message
- New users get EMPLOYEE role (not SYSTEM_ADMIN or HR_ADMIN)
- Email domain matching is case-insensitive
Step 10: Domain Management UI
Input
- Step 09 complete
Constraints
- Add to existing tenant detail page
- Follow design system
- Real-time validation of domain format
Task
Add domain management section to /admin/tenants/[id]:
1. Domains Card on Tenant Detail Page:
// Add to TenantDetailPage (id comes from use(params) - see Step 03)
<ContentCard>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Domains</h2>
<AddDomainDialog tenantId={id} />
</div>
<DomainsList tenantId={id} />
</ContentCard>2. DomainsList Component:
// apps/web/src/components/admin/DomainsList.tsx
export function DomainsList({ tenantId }: { tenantId: string }) {
const { data: domains } = useQuery({
queryKey: ['admin', 'tenants', tenantId, 'domains'],
queryFn: () => fetchTenantDomains(tenantId),
});
return (
<div className="space-y-2">
{domains?.map((domain) => (
<div
key={domain.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm">{domain.domain}</span>
{domain.isPrimary && (
<Badge variant="secondary">Primary</Badge>
)}
</div>
<DomainActions domain={domain} tenantId={tenantId} />
</div>
))}
{domains?.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No domains configured. Add a domain to enable registration.
</p>
)}
</div>
);
}3. AddDomainDialog Component:
// apps/web/src/components/admin/AddDomainDialog.tsx
export function AddDomainDialog({ tenantId }: { tenantId: string }) {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: AddDomainDto) => addTenantDomain(tenantId, data),
onSuccess: () => {
toast.success('Domain added successfully');
queryClient.invalidateQueries(['admin', 'tenants', tenantId, 'domains']);
setOpen(false);
},
onError: (error) => {
toast.error('Failed to add domain', { description: error.message });
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">Add Domain</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Domain</DialogTitle>
<DialogDescription>
Add a domain to allow users with this email domain to register
and automatically join this tenant.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
placeholder="company.com"
{...register('domain')}
/>
<p className="text-xs text-muted-foreground mt-1">
Enter the email domain without @ (e.g., capptoo.com)
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox id="isPrimary" {...register('isPrimary')} />
<Label htmlFor="isPrimary" className="text-sm">
Set as primary domain
</Label>
</div>
</div>
<DialogFooter className="mt-6">
<Button variant="outline" type="button" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add Domain'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}4. DomainActions Component:
// apps/web/src/components/admin/DomainActions.tsx
export function DomainActions({ domain, tenantId }) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: () => removeTenantDomain(tenantId, domain.id),
onSuccess: () => {
toast.success('Domain removed');
queryClient.invalidateQueries(['admin', 'tenants', tenantId, 'domains']);
},
});
const setPrimaryMutation = useMutation({
mutationFn: () => updateTenantDomain(tenantId, domain.id, { isPrimary: true }),
onSuccess: () => {
toast.success('Primary domain updated');
queryClient.invalidateQueries(['admin', 'tenants', tenantId, 'domains']);
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{!domain.isPrimary && (
<DropdownMenuItem onClick={() => setPrimaryMutation.mutate()}>
Set as Primary
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive"
onClick={() => {
if (confirm('Remove this domain?')) {
deleteMutation.mutate();
}
}}
>
Remove Domain
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}Completion Gate
- Domains card shows on tenant detail page
- Can add new domain via dialog
- Can remove domain (with confirmation)
- Can set domain as primary
- Empty state shown when no domains
- Error handling for domain conflicts
Quick Reference
API Endpoints
| Method | Path | Guard | Description |
|---|---|---|---|
| POST | /api/v1/admin/tenants | PlatformAdminOnly | Create tenant |
| GET | /api/v1/admin/tenants | PlatformAdminOnly | List tenants |
| GET | /api/v1/admin/tenants/:id | PlatformAdminOnly | Get tenant |
| PATCH | /api/v1/admin/tenants/:id | PlatformAdminOnly | Update tenant |
| POST | /api/v1/admin/tenants/:id/assign-admin | PlatformAdminOnly | Assign admin |
Pages
| Path | Component | Description |
|---|---|---|
| /admin/tenants | TenantsPage | List all tenants |
| /admin/tenants/new | CreateTenantPage | Create tenant form |
| /admin/tenants/[id] | TenantDetailPage | View/edit tenant |
File Structure
apps/
├── api/src/
│ ├── common/guards/
│ │ └── platform-admin.guard.ts # New
│ └── modules/admin/
│ ├── admin.module.ts # New
│ ├── admin-tenant.controller.ts # New
│ ├── admin-tenant.service.ts # New
│ └── dto/
│ ├── create-tenant.dto.ts # New
│ ├── update-tenant.dto.ts # New
│ └── assign-admin.dto.ts # New
└── web/src/
├── app/(dashboard)/admin/
│ └── tenants/
│ ├── page.tsx # New
│ ├── new/page.tsx # New
│ └── [id]/page.tsx # New
└── components/admin/
└── AssignAdminDialog.tsx # NewEnd-to-End User Flow
After completing this phase, the full flow works:
- Platform Admin logs in (existing auth from Phase 01)
- Platform Admin navigates to /admin/tenants (sees admin sidebar section)
- Platform Admin clicks "Create Tenant" → fills form → tenant created
- Platform Admin clicks "Assign Admin" → enters email/name → user created with HR_ADMIN role
- Tenant Admin receives notification (manual for now, tell them to reset password)
- Tenant Admin uses "Forgot Password" → sets password → logs in
- Tenant Admin can now create employees (Phase 02 functionality)
User story achieved: "As a Platform Admin I want to create a new Tenant, so that the Tenant admin can create his users and employees."
Next Phase
After Phase 10, optional enhancements:
- Phase 11: Email-based invitations (requires email service like SendGrid/Resend)
- Phase 12: Billing integration (Stripe/Paddle)
- Phase 13: Platform analytics dashboard
Phase Completion Checklist (MANDATORY)
BEFORE MOVING TO NEXT PHASE
Complete ALL items before proceeding. Do NOT skip any step.
1. Gate Verification
- All step gates passed
- Tenant management working
- Domain registration working
- Platform admin access control working
2. Update PROJECT_STATE.md
- Mark Phase 10 as COMPLETED with timestamp
- Update "Current Phase" to Phase 10.5
- Add session log entry3. Update WHAT_EXISTS.md
## API Endpoints
- /api/v1/admin/tenants/*
- /api/v1/admin/domains/*
## Frontend Routes
- /admin/*
## Established Patterns
- PlatformAdminOnly guard
- Admin service pattern4. Git Tag & Commit
git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 10 - Platform Admin"
git tag phase-10-platform-adminNext Phase
After verification, proceed to Phase 10.5: Pre-Launch
Last Updated: 2025-11-30