migrate-to-teams
Access: /template migrate-to-teams or /t migrate-to-teams
Transforms a single-user MakerKit application into a multi-tenant team-based application, enabling collaboration features and team management.
What It Does
The migrate-to-teams command helps you:
- Convert user-scoped features to team-scoped
- Add team creation and management UI
- Implement team member invitations
- Set up role-based permissions
- Migrate existing user data to team structure
Usage
/template migrate-to-teams "Description"
# or
/t migrate-to-teams "Description"When prompted, provide:
- Default team name pattern (e.g., "{userName}'s Team")
- Whether to migrate existing data
- Team size limits
- Default team roles
Prerequisites
- A MakerKit project with user authentication
- Understanding of current data model
- Commercial MakerKit license from MakerKit
What Gets Created
1. Team Database Structure
Creates team-related tables:
-- Teams table (if not exists)
create table if not exists public.accounts (
id uuid default gen_random_uuid() primary key,
name text not null,
slug text unique not null,
picture_url text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Team members junction table
create table if not exists public.accounts_account_members (
account_id uuid not null references public.accounts(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role account_role not null default 'member',
joined_at timestamptz default now(),
primary key (account_id, user_id)
);
-- Team invitations
create table if not exists public.invitations (
id uuid default gen_random_uuid() primary key,
account_id uuid not null references public.accounts(id) on delete cascade,
email text not null,
role account_role not null default 'member',
invited_by uuid references auth.users(id),
token text unique not null,
expires_at timestamptz not null,
accepted_at timestamptz,
created_at timestamptz default now()
);
-- Enable RLS
alter table public.accounts enable row level security;
alter table public.accounts_account_members enable row level security;
alter table public.invitations enable row level security;2. Data Migration Script
Migrates existing user data to teams:
// migrations/migrate-to-teams.ts
export async function migrateUsersToTeams() {
const supabase = createClient();
// Get all users
const { data: users } = await supabase
.from('users')
.select('*');
for (const user of users) {
// Create a personal team for each user
const { data: team } = await supabase
.from('accounts')
.insert({
name: `${user.display_name}'s Team`,
slug: generateSlug(user.display_name),
})
.select()
.single();
// Add user as team owner
await supabase
.from('accounts_account_members')
.insert({
account_id: team.id,
user_id: user.id,
role: 'owner',
});
// Migrate user's data to team
await migrateUserDataToTeam(user.id, team.id);
}
}
async function migrateUserDataToTeam(userId: string, teamId: string) {
// Update user-scoped tables to team-scoped
await supabase
.from('projects')
.update({ account_id: teamId })
.eq('user_id', userId);
await supabase
.from('documents')
.update({ account_id: teamId })
.eq('user_id', userId);
// Add other tables as needed
}3. Team Management UI
Team creation component:
// components/teams/create-team-form.tsx
export function CreateTeamForm() {
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const form = useForm({
resolver: zodResolver(CreateTeamSchema),
defaultValues: {
name: '',
slug: '',
},
});
const onSubmit = async (data: CreateTeamInput) => {
setIsCreating(true);
try {
const team = await createTeamAction(data);
toast.success('Team created successfully');
router.push(`/home/${team.slug}`);
} catch (error) {
toast.error('Failed to create team');
} finally {
setIsCreating(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Team Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Acme Inc" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Team URL</FormLabel>
<FormControl>
<div className="flex items-center">
<span className="text-muted-foreground">
app.com/teams/
</span>
<Input {...field} placeholder="acme-inc" />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isCreating}>
{isCreating ? 'Creating...' : 'Create Team'}
</Button>
</form>
</Form>
);
}4. Team Switcher Component
UI for switching between teams:
// components/teams/team-switcher.tsx
export function TeamSwitcher() {
const pathname = usePathname();
const router = useRouter();
const { data: teams } = useUserTeams();
const currentTeam = getCurrentTeam(pathname);
const switchTeam = (teamSlug: string) => {
const newPath = pathname.replace(
`/home/${currentTeam.slug}`,
`/home/${teamSlug}`
);
router.push(newPath);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Avatar className="h-5 w-5">
<AvatarImage src={currentTeam.picture_url} />
<AvatarFallback>
{currentTeam.name[0]}
</AvatarFallback>
</Avatar>
{currentTeam.name}
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Switch Team</DropdownMenuLabel>
<DropdownMenuSeparator />
{teams?.map((team) => (
<DropdownMenuItem
key={team.id}
onClick={() => switchTeam(team.slug)}
>
<Avatar className="h-5 w-5 mr-2">
<AvatarImage src={team.picture_url} />
<AvatarFallback>{team.name[0]}</AvatarFallback>
</Avatar>
{team.name}
{team.id === currentTeam.id && (
<Check className="h-4 w-4 ml-auto" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push('/teams/new')}>
<Plus className="h-4 w-4 mr-2" />
Create Team
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}5. Team Invitation System
Invite team members:
// components/teams/invite-member-form.tsx
export function InviteMemberForm({ teamId }: { teamId: string }) {
const [isInviting, setIsInviting] = useState(false);
const form = useForm({
resolver: zodResolver(InviteMemberSchema),
defaultValues: {
email: '',
role: 'member' as const,
},
});
const onSubmit = async (data: InviteMemberInput) => {
setIsInviting(true);
try {
await inviteTeamMemberAction({
...data,
accountId: teamId,
});
toast.success('Invitation sent successfully');
form.reset();
} catch (error) {
toast.error('Failed to send invitation');
} finally {
setIsInviting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="flex gap-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...field}
type="email"
placeholder="colleague@company.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isInviting}>
{isInviting ? 'Sending...' : 'Send Invite'}
</Button>
</div>
</form>
</Form>
);
}6. Updated Navigation
Modified to include team context:
// config/team-navigation.config.tsx
export const teamNavigationConfig = [
{
label: 'Dashboard',
path: '/home/[account]',
Icon: <Home className="w-4" />,
},
{
label: 'Projects',
path: '/home/[account]/projects',
Icon: <FolderOpen className="w-4" />,
},
{
label: 'Team',
path: '/home/[account]/members',
Icon: <Users className="w-4" />,
},
{
label: 'Settings',
path: '/home/[account]/settings',
Icon: <Settings className="w-4" />,
},
];Route Updates
Update routing to include team context:
// Before: /dashboard/projects
// After: /home/[account]/projects
// app/home/[account]/layout.tsx
export default async function TeamLayout({
children,
params,
}: {
children: React.ReactNode;
params: { account: string };
}) {
const team = await getTeamBySlug(params.account);
if (!team) {
notFound();
}
return (
<TeamProvider team={team}>
<div className="flex">
<TeamSidebar />
<main className="flex-1">{children}</main>
</div>
</TeamProvider>
);
}Server Action Updates
Update server actions to be team-aware:
// Before
export const createProjectAction = enhanceAction(
async (data, user) => {
return createProject({ ...data, userId: user.id });
},
{ auth: true, schema: CreateProjectSchema }
);
// After
export const createProjectAction = enhanceAction(
async (data, user) => {
// Verify team membership
const hasAccess = await verifyTeamMembership(
user.id,
data.accountId
);
if (!hasAccess) {
throw new Error('Access denied');
}
return createProject({ ...data, accountId: data.accountId });
},
{ auth: true, schema: CreateProjectSchema }
);Testing the Migration
Verify Data Migration
describe('Team Migration', () => {
it('creates personal teams for existing users', async () => {
await migrateUsersToTeams();
const { data: teams } = await supabase
.from('accounts')
.select('*, members:accounts_account_members(*)');
expect(teams).toHaveLength(existingUsers.length);
teams.forEach((team) => {
expect(team.members).toHaveLength(1);
expect(team.members[0].role).toBe('owner');
});
});
it('migrates user data to teams', async () => {
await migrateUsersToTeams();
const { data: projects } = await supabase
.from('projects')
.select('account_id')
.not('account_id', 'is', null);
expect(projects).toHaveLength(existingProjects.length);
});
});Post-Migration Steps
Update environment variables:
bashNEXT_PUBLIC_ENABLE_TEAMS=true NEXT_PUBLIC_DEFAULT_TEAM_ROLE=memberRun migration:
bashpnpm run migrate:teamsUpdate documentation:
- Team management guides
- Permission documentation
- API updates
Test thoroughly:
- Team creation flow
- Member invitation
- Data access with teams
- Permission enforcement
License Requirement
Important: This command requires a commercial MakerKit license from https://makerkit.dev?atp=MqaGgc MakerKit is a premium SaaS starter kit and requires proper licensing for commercial use.
