Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

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.

AttributeValue
Steps01-10
Estimated Time12-14 hours
DependenciesPhase 01 (auth), Phase 01.5 (design system)
Completion GatePlatform 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:

MethodPathRequestResponse
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/tenants shows list of all tenants with pagination
  • /admin/tenants/new creates 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 createUser event
  • 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

MethodPathGuardDescription
POST/api/v1/admin/tenantsPlatformAdminOnlyCreate tenant
GET/api/v1/admin/tenantsPlatformAdminOnlyList tenants
GET/api/v1/admin/tenants/:idPlatformAdminOnlyGet tenant
PATCH/api/v1/admin/tenants/:idPlatformAdminOnlyUpdate tenant
POST/api/v1/admin/tenants/:id/assign-adminPlatformAdminOnlyAssign admin

Pages

PathComponentDescription
/admin/tenantsTenantsPageList all tenants
/admin/tenants/newCreateTenantPageCreate tenant form
/admin/tenants/[id]TenantDetailPageView/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      # New

End-to-End User Flow

After completing this phase, the full flow works:

  1. Platform Admin logs in (existing auth from Phase 01)
  2. Platform Admin navigates to /admin/tenants (sees admin sidebar section)
  3. Platform Admin clicks "Create Tenant" → fills form → tenant created
  4. Platform Admin clicks "Assign Admin" → enters email/name → user created with HR_ADMIN role
  5. Tenant Admin receives notification (manual for now, tell them to reset password)
  6. Tenant Admin uses "Forgot Password" → sets password → logs in
  7. 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 entry

3. Update WHAT_EXISTS.md

## API Endpoints
- /api/v1/admin/tenants/*
- /api/v1/admin/domains/*

## Frontend Routes
- /admin/*

## Established Patterns
- PlatformAdminOnly guard
- Admin service pattern

4. 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-admin

Next Phase

After verification, proceed to Phase 10.5: Pre-Launch


Last Updated: 2025-11-30