/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:
- Analyzes your current user and team structure
- Removes individual account features
- Implements mandatory team membership
- Updates authentication flows
- Migrates existing solo users
- 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 billingEnterprise Team Mode
bash
/template recipe-team-only-mode enterprise
# Enterprise-focused setup with:
# - SSO requirement
# - Domain verification
# - Centralized billing
# - Admin approval flowFlexible Team Mode
bash
/template recipe-team-only-mode flexible
# Hybrid approach with:
# - Team requirement
# - Personal team creation
# - Easy team switching
# - Self-service setupWhat 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 actionsMiddleware 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 daysBest 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;
},
};Related Commands
/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
