Skip to content

/template recipe-team-only-mode

Commercial License Required

MakerKit requires a commercial license. While Orchestre can help you build with it, you must obtain a valid license from MakerKit for commercial use.

Access: /template recipe-team-only-mode or /t recipe-team-only-mode

Purpose

Transforms your SaaS into a team-only platform where individual accounts are disabled and all users must belong to a team. This recipe implements mandatory team membership, removes personal workspaces, and ensures all features operate within a team context.

How It Actually Works

The recipe:

  1. Analyzes your current user and team structure
  2. Removes individual account features
  3. Implements mandatory team membership
  4. Updates authentication flows
  5. Migrates existing solo users
  6. Enforces team-only access controls

Use Cases

  • Enterprise B2B SaaS: Company-focused products
  • Collaboration Tools: Team-centric workflows
  • Business Software: Department-based access
  • Project Management: Organization-only usage
  • Communication Platforms: Workspace-based systems
  • DevOps Tools: Team-based infrastructure

Examples

Standard Team-Only Mode

bash
/template recipe-team-only-mode
# Implements basic team-only with:
# - Mandatory team selection
# - No personal workspaces
# - Team-based onboarding
# - Simplified billing

Enterprise Team Mode

bash
/template recipe-team-only-mode enterprise
# Enterprise-focused setup with:
# - SSO requirement
# - Domain verification
# - Centralized billing
# - Admin approval flow

Flexible Team Mode

bash
/template recipe-team-only-mode flexible
# Hybrid approach with:
# - Team requirement
# - Personal team creation
# - Easy team switching
# - Self-service setup

What Gets Created

Database Schema Changes

sql
-- Modify users table (remove personal workspace fields)
alter table auth.users 
  drop column if exists personal_workspace_id,
  drop column if exists has_personal_account,
  add column if not exists must_select_team boolean default true;

-- Team membership enforcement
alter table team_members
  add constraint user_must_have_team 
  unique (user_id) deferrable initially deferred;

-- Team-only settings
create table team_only_config (
  id uuid primary key default gen_random_uuid(),
  allow_personal_teams boolean default false,
  require_domain_verification boolean default false,
  auto_assign_by_domain boolean default false,
  onboarding_flow text default 'team_selection',
  team_creation_restricted boolean default false,
  min_team_size integer default 1,
  created_at timestamp with time zone default now(),
  updated_at timestamp with time zone default now()
);

-- Orphaned user tracking
create table orphaned_users (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade,
  email text not null,
  previous_team_id uuid,
  removal_reason text,
  grace_period_ends timestamp with time zone,
  created_at timestamp with time zone default now()
);

-- Team domains for auto-assignment
create table team_domains (
  id uuid primary key default gen_random_uuid(),
  team_id uuid references teams(id) on delete cascade,
  domain text unique not null,
  is_verified boolean default false,
  verification_token text,
  verified_at timestamp with time zone,
  auto_assign_users boolean default true,
  default_role text default 'member',
  created_at timestamp with time zone default now()
);

-- Team join requests
create table team_join_requests (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade,
  team_id uuid references teams(id) on delete cascade,
  requested_by text not null, -- 'user' or 'domain'
  status text default 'pending',
  reviewed_by uuid references auth.users(id),
  reviewed_at timestamp with time zone,
  created_at timestamp with time zone default now(),
  unique(user_id, team_id)
);

Updated Authentication Flow

typescript
// app/api/auth/callback/route.ts
export async function GET(request: Request) {
  const { user } = await handleAuthCallback(request);
  
  // Check for existing team membership
  const teamMembership = await getUserTeamMembership(user.id);
  
  if (!teamMembership) {
    // Redirect to team selection/creation
    return redirect('/onboarding/team-selection');
  }
  
  // Set default team in session
  await setUserDefaultTeam(user.id, teamMembership.teamId);
  
  return redirect('/dashboard');
}

// app/api/auth/signup/route.ts
export async function POST(request: Request) {
  const { email, password, teamInviteToken } = await request.json();
  
  // Verify team invite if provided
  let teamId = null;
  if (teamInviteToken) {
    const invite = await verifyTeamInvite(teamInviteToken);
    teamId = invite.teamId;
  }
  
  // Create user
  const user = await createUser(email, password);
  
  // If domain auto-assignment enabled
  const domain = email.split('@')[1];
  const teamDomain = await getTeamByDomain(domain);
  
  if (teamDomain && teamDomain.autoAssignUsers) {
    teamId = teamDomain.teamId;
  }
  
  // Add to team if available
  if (teamId) {
    await addUserToTeam(user.id, teamId);
    return { success: true, requiresTeamSelection: false };
  }
  
  return { success: true, requiresTeamSelection: true };
}

React Components

typescript
// components/onboarding/team-selection.tsx
- Team finder by domain
- Join request UI
- Create team option
- Invitation code input

// components/onboarding/team-required-guard.tsx
- Blocks access without team
- Shows team selection
- Handles edge cases
- Grace period warnings

// components/teams/team-switcher-required.tsx
- Always visible switcher
- No "Personal" option
- Create team flow
- Join team flow

// components/auth/signup-with-team.tsx
- Team-focused signup
- Domain detection
- Invite code field
- Company info collection

// components/teams/domain-verification.tsx
- Add domain UI
- Verification steps
- DNS record display
- Auto-assignment toggle

// components/admin/orphaned-users-manager.tsx
- List users without teams
- Grace period management
- Reassignment tools
- Cleanup actions

Middleware and Guards

typescript
// middleware/team-required.ts
export async function teamRequiredMiddleware(req: Request) {
  const session = await getSession(req);
  
  if (!session?.user) {
    return redirectToLogin();
  }
  
  const hasTeam = await userHasTeam(session.user.id);
  
  if (!hasTeam) {
    // Allow access to team selection pages
    if (isTeamSelectionPath(req.url)) {
      return NextResponse.next();
    }
    
    return redirectToTeamSelection();
  }
  
  // Ensure team context is set
  const teamId = await getUserCurrentTeam(session.user.id);
  req.headers.set('x-team-id', teamId);
  
  return NextResponse.next();
}

// lib/guards/require-team.ts
export function requireTeam(handler: Handler) {
  return async (req: Request, res: Response) => {
    const teamId = req.headers.get('x-team-id');
    
    if (!teamId) {
      return res.status(403).json({
        error: 'Team membership required',
        code: 'TEAM_REQUIRED',
      });
    }
    
    req.team = await getTeam(teamId);
    return handler(req, res);
  };
}

Technical Details

Migration Strategy

typescript
// Migration for existing users
class TeamOnlyMigration {
  async migrateToTeamOnly() {
    // 1. Find users without teams
    const soloUsers = await getUsersWithoutTeams();
    
    for (const user of soloUsers) {
      // 2. Check if they have data
      const hasData = await userHasData(user.id);
      
      if (hasData) {
        // 3. Create personal team
        const team = await createPersonalTeam(user);
        
        // 4. Migrate user data to team
        await migrateUserDataToTeam(user.id, team.id);
        
        // 5. Add user as team owner
        await addTeamMember(team.id, user.id, 'owner');
      } else {
        // 6. Mark as orphaned
        await markUserAsOrphaned(user.id, 'no_data');
      }
    }
    
    // 7. Update system config
    await enableTeamOnlyMode();
  }
  
  async createPersonalTeam(user: User) {
    // Extract domain for naming
    const domain = user.email.split('@')[1];
    const companyName = domain.split('.')[0];
    
    return await createTeam({
      name: `${companyName} Team`,
      slug: generateSlug(companyName),
      created_by: user.id,
      is_personal_team: true,
    });
  }
}

Domain-Based Auto Assignment

typescript
// Automatic team assignment by email domain
class DomainTeamAssignment {
  async handleNewUser(email: string, userId: string) {
    const domain = this.extractDomain(email);
    
    // Check for verified team domain
    const teamDomain = await db.team_domains
      .where({ domain, is_verified: true, auto_assign_users: true })
      .first();
    
    if (teamDomain) {
      // Auto-assign to team
      await this.assignUserToTeam(
        userId,
        teamDomain.team_id,
        teamDomain.default_role
      );
      
      // Notify team admins
      await this.notifyTeamAdmins(teamDomain.team_id, {
        event: 'auto_assigned_user',
        user: { id: userId, email },
      });
      
      return { autoAssigned: true, teamId: teamDomain.team_id };
    }
    
    // Check for pending domain verification
    const pendingDomain = await this.checkPendingDomain(domain);
    if (pendingDomain) {
      await this.createJoinRequest(userId, pendingDomain.team_id, 'domain');
    }
    
    return { autoAssigned: false, requiresSelection: true };
  }
  
  async verifyDomain(teamId: string, domain: string) {
    // Generate verification token
    const token = generateToken();
    
    // Create DNS TXT record value
    const txtRecord = `orchestre-verify=${token}`;
    
    // Save pending verification
    await db.team_domains.create({
      team_id: teamId,
      domain,
      verification_token: token,
      is_verified: false,
    });
    
    return {
      domain,
      txtRecord,
      dnsRecordName: `_orchestre.${domain}`,
      status: 'pending_verification',
    };
  }
}

Team-Only Enforcement

typescript
// Enforce team context everywhere
class TeamOnlyEnforcer {
  // Wrap all database queries
  wrapQuery(query: QueryBuilder) {
    return query.where(function() {
      // Always require team context
      if (!this.context.teamId) {
        throw new Error('Team context required');
      }
      
      // Apply team filter if table has team_id
      if (this.hasColumn('team_id')) {
        this.where('team_id', this.context.teamId);
      }
    });
  }
  
  // Validate API requests
  async validateRequest(req: Request) {
    const user = await getUser(req);
    const teamId = req.headers.get('x-team-id') || 
                  req.query.teamId ||
                  req.body.teamId;
    
    if (!teamId) {
      // Try to get user's default team
      const defaultTeam = await getUserDefaultTeam(user.id);
      if (!defaultTeam) {
        throw new TeamRequiredError();
      }
      req.teamId = defaultTeam.id;
    }
    
    // Verify membership
    const isMember = await isTeamMember(teamId, user.id);
    if (!isMember) {
      throw new ForbiddenError('Not a team member');
    }
    
    return { user, teamId };
  }
}

Memory Evolution

The recipe creates team-only system memory:

markdown
## Team-Only Mode Configuration

### System Settings
- Mode: Team-Only Enforced
- Personal Teams: Disabled
- Domain Auto-Assignment: Enabled
- Team Creation: Admin approval required

### Migration Results
- Users migrated: 1,234
- Personal teams created: 456
- Orphaned users: 89
- Domain mappings: 23

### Domain Configuration
- Verified domains: 15
- Auto-assignment enabled: 12
- Default role: Member
- Approval required: Viewer role

### Enforcement Rules
- Login requires team selection
- No personal workspace option
- API calls require team context
- Billing is team-level only

### Grace Period Policy
- New users: Must join team within 24 hours
- Removed users: 30-day data retention
- Orphaned accounts: Weekly cleanup
- Invitation expiry: 7 days

Best Practices

Smooth Onboarding

  • Clear team selection UI
  • Domain-based suggestions
  • Easy team creation
  • Invitation code support
  • Help documentation

Team Discovery

  • Search by company name
  • Domain matching
  • Public team directory
  • Join request system
  • Admin notifications

Access Control

  • Enforce team context
  • No bypass options
  • Clear error messages
  • Audit all access
  • Regular reviews

Data Governance

  • Team owns all data
  • Clear data policies
  • Export capabilities
  • Deletion workflows
  • Compliance tools

Integration Points

With Authentication

typescript
// Team-only auth flow
const teamOnlyAuth = {
  async handleLogin(email: string, password: string) {
    const user = await authenticate(email, password);
    
    // Get user's teams
    const teams = await getUserTeams(user.id);
    
    if (teams.length === 0) {
      return {
        success: true,
        requiresTeamSelection: true,
        availableTeams: await getSuggestedTeams(user.email),
      };
    }
    
    if (teams.length === 1) {
      // Auto-select single team
      await setCurrentTeam(user.id, teams[0].id);
    }
    
    return {
      success: true,
      teams,
      currentTeam: teams[0].id,
    };
  },
};

With Billing

typescript
// Team-only billing
const teamBilling = {
  async createSubscription(teamId: string, plan: string) {
    const team = await getTeam(teamId);
    
    // All billing is team-level
    const subscription = await stripe.subscriptions.create({
      customer: team.stripeCustomerId,
      items: [{ price: plan }],
      metadata: {
        teamId,
        teamName: team.name,
      },
    });
    
    // No personal subscriptions allowed
    return subscription;
  },
};

With Invitations

typescript
// Enhanced team invitations
const teamInvitations = {
  async inviteUser(teamId: string, email: string, role: string) {
    // Check if user exists
    const existingUser = await getUserByEmail(email);
    
    if (existingUser) {
      // Check if already in a team
      const hasTeam = await userHasTeam(existingUser.id);
      if (hasTeam) {
        return {
          error: 'User already belongs to a team',
          code: 'USER_HAS_TEAM',
        };
      }
    }
    
    // Create invitation
    const invitation = await createInvitation({
      teamId,
      email,
      role,
      type: 'team_required',
    });
    
    // Send enhanced email
    await sendTeamOnlyInvitation(invitation);
    
    return invitation;
  },
};

Troubleshooting

Common Issues

"No Team Access" Errors

  • Verify team membership
  • Check session state
  • Review middleware
  • Validate team context

Orphaned Users

  • Review grace period
  • Check migration logs
  • Run cleanup jobs
  • Contact users

Domain Verification

  • Check DNS propagation
  • Verify TXT records
  • Review domain format
  • Test with dig/nslookup

Migration Rollback

typescript
// Rollback to personal accounts
const rollbackTeamOnly = {
  async execute() {
    // Re-enable personal workspaces
    await updateConfig({ allowPersonalAccounts: true });
    
    // Convert personal teams back
    const personalTeams = await getPersonalTeams();
    for (const team of personalTeams) {
      await convertToPersonalWorkspace(team);
    }
    
    // Remove team requirements
    await removeTeamMiddleware();
    
    // Update UI components
    await revertUIChanges();
    
    return { success: true, reverted: personalTeams.length };
  },
};

Advanced Features

Team Templates

typescript
// Pre-configured team setups
const teamTemplates = {
  startup: {
    name: 'Startup Template',
    settings: {
      maxMembers: 10,
      features: ['basic'],
    },
    roles: ['owner', 'member'],
  },
  enterprise: {
    name: 'Enterprise Template',
    settings: {
      sso: true,
      audit: true,
      compliance: true,
    },
    roles: ['owner', 'admin', 'manager', 'member', 'guest'],
  },
};

Automatic Team Provisioning

typescript
// API-based team creation
const autoProvisioning = {
  async provisionTeam(companyData: CompanyData) {
    // Create team
    const team = await createTeam({
      name: companyData.name,
      domain: companyData.domain,
      settings: this.getTemplateSettings(companyData.type),
    });
    
    // Verify domain
    await initiateDomainVerification(team.id, companyData.domain);
    
    // Add initial admins
    for (const admin of companyData.admins) {
      await addTeamAdmin(team.id, admin.email);
    }
    
    // Configure SSO if provided
    if (companyData.sso) {
      await configureSSOProvider(team.id, companyData.sso);
    }
    
    return team;
  },
};
  • /template add-team-management - Enhanced team features
  • /template add-sso - Single sign-on support
  • /template add-domain-verification - Domain claiming
  • /template add-onboarding - Team onboarding flows

Built with ❤️ for the AI Coding community, by Praney Behl