Next.js Supabase V3 (#463)
Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
339
docs/recipes/team-accounts-only.mdoc
Normal file
339
docs/recipes/team-accounts-only.mdoc
Normal file
@@ -0,0 +1,339 @@
|
||||
---
|
||||
status: "published"
|
||||
title: 'Disabling Personal Accounts in Next.js Supabase'
|
||||
label: 'Disabling Personal Accounts'
|
||||
order: 1
|
||||
description: 'Learn how to disable personal accounts in the Next.js Supabase Turbo SaaS kit and only allow team accounts'
|
||||
---
|
||||
|
||||
{% alert type="warning" title="v2 Recipe" %}
|
||||
This recipe applies to **v2 only**. In v3, teams-only mode is built-in as a feature flag. Simply set `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true` in your environment variables. See the [Feature Flags Configuration](/docs/next-supabase-turbo/configuration/feature-flags-configuration) for details.
|
||||
{% /alert %}
|
||||
|
||||
The Next.js Supabase Turbo SaaS kit is designed to allow personal accounts by default. However, you can disable the personal account view, and only allow user to access team accounts.
|
||||
|
||||
Let's walk through the v2 steps to disable personal accounts in the Next.js Supabase Turbo SaaS kit:
|
||||
|
||||
1. **Store team slug in cookies**: When a user logs in, store the team slug in a cookie. If none is provided, redirect the user to the team selection page.
|
||||
2. **Set up Redirect**: Redirect customers to the latest selected team account
|
||||
3. **Create a Team Selection Page**: Create a page where users can select the team they want to log in to.
|
||||
4. **Duplicate the user settings page**: Duplicate the user settings page so they can access their own settings from within the team workspace
|
||||
|
||||
## Storing the User Cookie and Redirecting to the Team Selection Page
|
||||
|
||||
To make sure that users are always redirected to the team selection page, you need to store the team slug in a cookie. If the team slug is not found, redirect the user to the team selection page. We will do all of this in the middleware.
|
||||
|
||||
First, let's create these functions in the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16):
|
||||
|
||||
```tsx {% title="apps/web/proxy.ts" %}
|
||||
const createTeamCookie = (userId: string) => `${userId}-selected-team-slug`;
|
||||
|
||||
function handleTeamAccountsOnly(request: NextRequest, userId: string) {
|
||||
// always allow access to the teams page
|
||||
if (request.nextUrl.pathname === '/home/teams') {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (request.nextUrl.pathname === '/home') {
|
||||
return redirectToTeam(request, userId);
|
||||
}
|
||||
|
||||
if (isTeamAccountRoute(request) && !isUserRoute(request)) {
|
||||
return storeTeamSlug(request, userId);
|
||||
}
|
||||
|
||||
if (isUserRoute(request)) {
|
||||
return redirectToTeam(request, userId);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
function isUserRoute(request: NextRequest) {
|
||||
const pathName = request.nextUrl.pathname;
|
||||
return ['settings', 'billing', 'members'].includes(pathName.split('/')[2]!);
|
||||
}
|
||||
|
||||
function isTeamAccountRoute(request: NextRequest) {
|
||||
const pathName = request.nextUrl.pathname;
|
||||
|
||||
return pathName.startsWith('/home/');
|
||||
}
|
||||
|
||||
function storeTeamSlug(request: NextRequest, userId: string): NextResponse {
|
||||
const accountSlug = request.nextUrl.pathname.split('/')[2];
|
||||
|
||||
if (!accountSlug) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const cookieName = createTeamCookie(userId);
|
||||
const existing = request.cookies.get(cookieName);
|
||||
|
||||
if (existing?.value === accountSlug) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.cookies.set({
|
||||
name: createTeamCookie(userId),
|
||||
value: accountSlug,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function redirectToTeam(request: NextRequest, userId: string): NextResponse {
|
||||
const cookieName = createTeamCookie(userId);
|
||||
const lastTeamSlug = request.cookies.get(cookieName);
|
||||
|
||||
if (lastTeamSlug) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/home/${lastTeamSlug.value}`, request.url),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL('/home/teams', request.url));
|
||||
}
|
||||
```
|
||||
|
||||
We will now add the `handleTeamAccountsOnly` function to the middleware chain in the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16). This function will check if the user is on a team account route and store the team slug in a cookie. If the user is on the home route, it will redirect them to the team selection page.
|
||||
|
||||
```tsx {% title="apps/web/proxy.ts" %}
|
||||
{
|
||||
pattern: new URLPattern({ pathname: '/home/*?' }),
|
||||
handler: async (req: NextRequest, res: NextResponse) => {
|
||||
const { data } = await getUser(req, res);
|
||||
|
||||
const origin = req.nextUrl.origin;
|
||||
const next = req.nextUrl.pathname;
|
||||
|
||||
// If user is not logged in, redirect to sign in page.
|
||||
if (!data?.claims) {
|
||||
const signIn = pathsConfig.auth.signIn;
|
||||
const redirectPath = `${signIn}?next=${next}`;
|
||||
|
||||
return NextResponse.redirect(new URL(redirectPath, origin).href);
|
||||
}
|
||||
|
||||
const supabase = createMiddlewareClient(req, res);
|
||||
|
||||
const requiresMultiFactorAuthentication =
|
||||
await checkRequiresMultiFactorAuthentication(supabase);
|
||||
|
||||
// If user requires multi-factor authentication, redirect to MFA page.
|
||||
if (requiresMultiFactorAuthentication) {
|
||||
return NextResponse.redirect(
|
||||
new URL(pathsConfig.auth.verifyMfa, origin).href,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = data.claims.sub;
|
||||
|
||||
return handleTeamAccountsOnly(req, userId);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
In the above code snippet, we have added the `handleTeamAccountsOnly` function to the middleware chain.
|
||||
|
||||
## Creating the Team Selection Page
|
||||
|
||||
Next, we need to create a team selection page where users can select the team they want to log in to. We will create a new page at `apps/web/app/home/teams/page.tsx`:
|
||||
|
||||
```tsx {% title="apps/web/app/home/teams/page.tsx" %}
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { HomeAccountsList } from '~/home/(user)/_components/home-accounts-list';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('account');
|
||||
const title = t('homePage');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
function TeamsPage() {
|
||||
return (
|
||||
<div className={'container flex flex-col flex-1 h-screen'}>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'common.routes.home'} />}
|
||||
description={<Trans i18nKey={'common.homeTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<HomeAccountsList />
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamsPage;
|
||||
```
|
||||
|
||||
The page is extremely minimal, it just displays a list of teams that the user can select from. You can customize this page to fit your application's design.
|
||||
|
||||
## Duplicating the User Settings Page
|
||||
|
||||
Finally, we need to duplicate the user settings page so that users can access their settings from within the team workspace.
|
||||
|
||||
We will create a new page called `user-settings.tsx` in the `apps/web/app/home/[account]` directory.
|
||||
|
||||
```tsx {% title="apps/web/app/home/[account]/user-settings/page.tsx" %}
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
import UserSettingsPage, { generateMetadata } from '../../(user)/settings/page';
|
||||
|
||||
export { generateMetadata };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={'User Settings'} description={<AppBreadcrumbs />} />
|
||||
|
||||
<UserSettingsPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Feel free to customize the path or the content of the user settings page.
|
||||
|
||||
### Adding the page to the Navigation Menu
|
||||
|
||||
Finally, you can add the `Teams` page to the navigation menu.
|
||||
|
||||
You can do this by updating the `apps/web/config/team-account-navigation.config.tsx` file:
|
||||
|
||||
```tsx {% title="apps/web/config/team-account-navigation.config.tsx" %} {26-30}
|
||||
import { CreditCard, LayoutDashboard, Settings, User, Users } from 'lucide-react';
|
||||
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const iconClasses = 'w-4';
|
||||
|
||||
const getRoutes = (account: string) => [
|
||||
{
|
||||
label: 'common.routes.application',
|
||||
collapsible: false,
|
||||
children: [
|
||||
{
|
||||
label: 'common.routes.dashboard',
|
||||
path: pathsConfig.app.accountHome.replace('[account]', account),
|
||||
Icon: <LayoutDashboard className={iconClasses} />,
|
||||
end: true,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'common.routes.settings',
|
||||
collapsible: false,
|
||||
children: [
|
||||
{
|
||||
label: 'common.routes.settings',
|
||||
path: createPath(pathsConfig.app.accountSettings, account),
|
||||
Icon: <Settings className={iconClasses} />,
|
||||
},
|
||||
{
|
||||
label: 'common.routes.account',
|
||||
path: createPath('/home/[account]/user-settings', account),
|
||||
Icon: <User className={iconClasses} />,
|
||||
},
|
||||
{
|
||||
label: 'common.routes.members',
|
||||
path: createPath(pathsConfig.app.accountMembers, account),
|
||||
Icon: <Users className={iconClasses} />,
|
||||
},
|
||||
featureFlagsConfig.enableTeamAccountBilling
|
||||
? {
|
||||
label: 'common.routes.billing',
|
||||
path: createPath(pathsConfig.app.accountBilling, account),
|
||||
Icon: <CreditCard className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
].filter(Boolean),
|
||||
},
|
||||
];
|
||||
|
||||
export function getTeamAccountSidebarConfig(account: string) {
|
||||
return NavigationConfigSchema.parse({
|
||||
routes: getRoutes(account),
|
||||
style: process.env.NEXT_PUBLIC_TEAM_NAVIGATION_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
function createPath(path: string, account: string) {
|
||||
return path.replace('[account]', account);
|
||||
}
|
||||
```
|
||||
|
||||
In the above code snippet, we have added the `User Settings` page to the navigation menu.
|
||||
|
||||
## Removing Personal Account menu item
|
||||
|
||||
To remove the personal account menu item, you can remove the personal account menu item:
|
||||
|
||||
```tsx {% title="packages/features/accounts/src/components/account-selector.tsx" %}
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => onAccountChange(undefined)}
|
||||
value={PERSONAL_ACCOUNT_SLUG}
|
||||
>
|
||||
<PersonalAccountAvatar />
|
||||
|
||||
<span className={'ml-2'}>
|
||||
<Trans i18nKey={'teams.personalAccount'} />
|
||||
</span>
|
||||
|
||||
<Icon item={PERSONAL_ACCOUNT_SLUG} />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
```
|
||||
|
||||
Once you remove the personal account menu item, users will only see the team accounts in the navigation menu.
|
||||
|
||||
## Change Redirect in Layout
|
||||
|
||||
We now need to change the redirect (in case of errors) from `/home` to `/home/teams`. This is to avoid infinite redirects in case of errors.
|
||||
|
||||
```tsx {% title="apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts" %} {13}
|
||||
async function workspaceLoader(accountSlug: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
const [workspace, user] = await Promise.all([
|
||||
api.getAccountWorkspace(accountSlug),
|
||||
requireUserInServerComponent(),
|
||||
]);
|
||||
|
||||
// we cannot find any record for the selected account
|
||||
// so we redirect the user to the home page
|
||||
if (!workspace.data?.account) {
|
||||
return redirect('/home/teams');
|
||||
}
|
||||
|
||||
return {
|
||||
...workspace.data,
|
||||
user,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
By following these steps, you can disable personal accounts in the Next.js Supabase Turbo SaaS kit and only allow team accounts.
|
||||
|
||||
This can help you create a more focused and collaborative environment for your users. Feel free to customize the team selection page and user settings page to fit your application's design and requirements.
|
||||
Reference in New Issue
Block a user