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
448
docs/api/account-api.mdoc
Normal file
448
docs/api/account-api.mdoc
Normal file
@@ -0,0 +1,448 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Account API"
|
||||
order: 0
|
||||
title: "Account API | Next.js Supabase SaaS Kit"
|
||||
description: "Complete reference for the Account API in MakerKit. Manage personal accounts, subscriptions, billing customer IDs, and workspace data with type-safe methods."
|
||||
---
|
||||
|
||||
The Account API is MakerKit's server-side service for managing personal user accounts. It provides methods to fetch subscription data, billing customer IDs, and account switcher information. Use it when building billing portals, feature gates, or account selection UIs. All methods are type-safe and respect Supabase RLS policies.
|
||||
|
||||
{% callout title="When to use Account API" %}
|
||||
Use the Account API for: checking subscription status for feature gating, loading data for account switchers, accessing billing customer IDs for direct provider API calls. Use the Team Account API instead for team-based operations.
|
||||
{% /callout %}
|
||||
|
||||
{% sequence title="Account API Reference" description="Learn how to use the Account API in MakerKit" %}
|
||||
|
||||
[Setup and initialization](#setup-and-initialization)
|
||||
|
||||
[getAccountWorkspace](#getaccountworkspace)
|
||||
|
||||
[loadUserAccounts](#loaduseraccounts)
|
||||
|
||||
[getSubscription](#getsubscription)
|
||||
|
||||
[getCustomerId](#getcustomerid)
|
||||
|
||||
[getOrder](#getorder)
|
||||
|
||||
[Real-world examples](#real-world-examples)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## Setup and initialization
|
||||
|
||||
Import `createAccountsApi` from `@kit/accounts/api` and pass a Supabase server client. The client handles authentication automatically through RLS.
|
||||
|
||||
```tsx
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function ServerComponent() {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
// Use API methods
|
||||
}
|
||||
```
|
||||
|
||||
In Server Actions:
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function myServerAction() {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
// Use API methods
|
||||
}
|
||||
```
|
||||
|
||||
{% callout title="Request-scoped clients" %}
|
||||
Always create the Supabase client and API instance inside your request handler, not at module scope. The client is tied to the current user's session.
|
||||
{% /callout %}
|
||||
|
||||
## API Methods
|
||||
|
||||
### getAccountWorkspace
|
||||
|
||||
Returns the personal workspace data for the authenticated user. This includes account details, subscription status, and profile information.
|
||||
|
||||
```tsx
|
||||
const workspace = await api.getAccountWorkspace();
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
picture_url: string | null;
|
||||
public_data: Json | null;
|
||||
subscription_status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused' | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage notes:**
|
||||
|
||||
- Called automatically in the `/home/(user)` layout
|
||||
- Cached per-request, so multiple calls are deduplicated
|
||||
- Returns `null` values if the user has no personal account
|
||||
|
||||
---
|
||||
|
||||
### loadUserAccounts
|
||||
|
||||
Loads all accounts the user belongs to, formatted for account switcher components.
|
||||
|
||||
```tsx
|
||||
const accounts = await api.loadUserAccounts();
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
Array<{
|
||||
label: string; // Account display name
|
||||
value: string; // Account ID or slug
|
||||
image: string | null; // Account picture URL
|
||||
}>
|
||||
```
|
||||
|
||||
**Example: Build an account switcher**
|
||||
|
||||
```tsx
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function AccountSwitcher() {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
const accounts = await api.loadUserAccounts();
|
||||
|
||||
return (
|
||||
<select>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.value} value={account.value}>
|
||||
{account.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### getSubscription
|
||||
|
||||
Returns the subscription data for a given account, including all subscription items (line items).
|
||||
|
||||
```tsx
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The account UUID |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: string;
|
||||
account_id: string;
|
||||
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
|
||||
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused';
|
||||
currency: string;
|
||||
cancel_at_period_end: boolean;
|
||||
period_starts_at: string;
|
||||
period_ends_at: string;
|
||||
trial_starts_at: string | null;
|
||||
trial_ends_at: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
subscription_id: string;
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
type: 'flat' | 'per_seat' | 'metered';
|
||||
quantity: number;
|
||||
price_amount: number;
|
||||
interval: 'month' | 'year';
|
||||
interval_count: number;
|
||||
}>;
|
||||
} | null
|
||||
```
|
||||
|
||||
**Example: Check subscription access**
|
||||
|
||||
```tsx
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function checkPlanAccess(accountId: string, requiredPlan: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
if (!subscription) {
|
||||
return { hasAccess: false, reason: 'no_subscription' };
|
||||
}
|
||||
|
||||
if (subscription.status !== 'active' && subscription.status !== 'trialing') {
|
||||
return { hasAccess: false, reason: 'inactive_subscription' };
|
||||
}
|
||||
|
||||
const hasRequiredPlan = subscription.items.some(
|
||||
(item) => item.product_id === requiredPlan
|
||||
);
|
||||
|
||||
if (!hasRequiredPlan) {
|
||||
return { hasAccess: false, reason: 'wrong_plan' };
|
||||
}
|
||||
|
||||
return { hasAccess: true };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### getCustomerId
|
||||
|
||||
Returns the billing provider customer ID for an account. Use this when integrating with Stripe, Paddle, or Lemon Squeezy APIs directly.
|
||||
|
||||
```tsx
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The account UUID |
|
||||
|
||||
**Returns:** `string | null`
|
||||
|
||||
**Example: Redirect to billing portal**
|
||||
|
||||
```tsx
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
async function createBillingPortalSession(accountId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('No billing customer found');
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||
});
|
||||
|
||||
return session.url;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### getOrder
|
||||
|
||||
Returns one-time purchase order data for accounts using lifetime deals or credit-based billing.
|
||||
|
||||
```tsx
|
||||
const order = await api.getOrder(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The account UUID |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: string;
|
||||
account_id: string;
|
||||
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
|
||||
status: 'pending' | 'completed' | 'refunded';
|
||||
currency: string;
|
||||
total_amount: number;
|
||||
items: Array<{
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
quantity: number;
|
||||
price_amount: number;
|
||||
}>;
|
||||
} | null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### Feature gating based on subscription
|
||||
|
||||
```tsx
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
type FeatureAccess = {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
upgradeUrl?: string;
|
||||
};
|
||||
|
||||
export async function canAccessFeature(
|
||||
accountId: string,
|
||||
feature: 'ai_assistant' | 'export' | 'api_access'
|
||||
): Promise<FeatureAccess> {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
// No subscription means free tier
|
||||
if (!subscription) {
|
||||
const freeFeatures = ['export'];
|
||||
if (freeFeatures.includes(feature)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'This feature requires a paid plan',
|
||||
upgradeUrl: '/pricing',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if subscription is active
|
||||
const activeStatuses = ['active', 'trialing'];
|
||||
if (!activeStatuses.includes(subscription.status)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Your subscription is not active',
|
||||
upgradeUrl: '/settings/billing',
|
||||
};
|
||||
}
|
||||
|
||||
// Map features to required product IDs
|
||||
const featureRequirements: Record<string, string[]> = {
|
||||
ai_assistant: ['pro', 'enterprise'],
|
||||
export: ['starter', 'pro', 'enterprise'],
|
||||
api_access: ['enterprise'],
|
||||
};
|
||||
|
||||
const requiredProducts = featureRequirements[feature] || [];
|
||||
const userProducts = subscription.items.map((item) => item.product_id);
|
||||
const hasAccess = requiredProducts.some((p) => userProducts.includes(p));
|
||||
|
||||
if (!hasAccess) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'This feature requires a higher plan',
|
||||
upgradeUrl: '/pricing',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action with subscription check
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const GenerateReportSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
reportType: z.enum(['summary', 'detailed', 'export']),
|
||||
});
|
||||
|
||||
export const generateReport = authActionClient
|
||||
.inputSchema(GenerateReportSchema)
|
||||
.action(async ({ parsedInput: data, ctx: { user } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
// Check subscription before expensive operation
|
||||
const subscription = await api.getSubscription(data.accountId);
|
||||
const isProUser = subscription?.items.some(
|
||||
(item) => item.product_id === 'pro' || item.product_id === 'enterprise'
|
||||
);
|
||||
|
||||
if (data.reportType === 'detailed' && !isProUser) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Detailed reports require a Pro subscription',
|
||||
};
|
||||
}
|
||||
|
||||
// Generate report...
|
||||
return { success: true, reportUrl: '/reports/123' };
|
||||
});
|
||||
```
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
### Creating client at module scope
|
||||
|
||||
```tsx
|
||||
// WRONG: Client created at module scope
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
export async function handler() {
|
||||
const subscription = await api.getSubscription(accountId); // Won't work
|
||||
}
|
||||
|
||||
// RIGHT: Client created in request context
|
||||
export async function handler() {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
}
|
||||
```
|
||||
|
||||
### Forgetting to handle null subscriptions
|
||||
|
||||
```tsx
|
||||
// WRONG: Assumes subscription exists
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
const plan = subscription.items[0].product_id; // Crashes if null
|
||||
|
||||
// RIGHT: Handle null case
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
if (!subscription) {
|
||||
return { plan: 'free' };
|
||||
}
|
||||
const plan = subscription.items[0]?.product_id ?? 'free';
|
||||
```
|
||||
|
||||
### Confusing account ID with user ID
|
||||
|
||||
The Account API expects account UUIDs, not user UUIDs. For personal accounts, the account ID is the same as the user ID, but for team accounts they differ.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team account management
|
||||
- [User Workspace API](/docs/next-supabase-turbo/api/user-workspace-api) - Workspace context for layouts
|
||||
- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Stripe and payment setup
|
||||
473
docs/api/account-workspace-api.mdoc
Normal file
473
docs/api/account-workspace-api.mdoc
Normal file
@@ -0,0 +1,473 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Team Workspace API"
|
||||
order: 4
|
||||
title: "Team Workspace API | Next.js Supabase SaaS Kit"
|
||||
description: "Access team account context in MakerKit layouts. Load team data, member permissions, subscription status, and role hierarchy with the Team Workspace API."
|
||||
---
|
||||
|
||||
The Team Workspace API provides team account context for pages under `/home/[account]`. It loads team data, the user's role and permissions, subscription status, and all accounts the user belongs to, making this information available to both server and client components.
|
||||
|
||||
{% sequence title="Team Workspace API Reference" description="Access team workspace data in layouts and components" %}
|
||||
|
||||
[loadTeamWorkspace (Server)](#loadteamworkspace-server)
|
||||
|
||||
[useTeamAccountWorkspace (Client)](#useteamaccountworkspace-client)
|
||||
|
||||
[Data structure](#data-structure)
|
||||
|
||||
[Usage patterns](#usage-patterns)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## loadTeamWorkspace (Server)
|
||||
|
||||
Loads the team workspace data for the specified team account. Use this in Server Components within the `/home/[account]` route group.
|
||||
|
||||
```tsx
|
||||
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
|
||||
|
||||
export default async function TeamDashboard({
|
||||
params,
|
||||
}: {
|
||||
params: { account: string };
|
||||
}) {
|
||||
const data = await loadTeamWorkspace();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.account.name}</h1>
|
||||
<p>Your role: {data.account.role}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Function signature
|
||||
|
||||
```tsx
|
||||
async function loadTeamWorkspace(): Promise<TeamWorkspaceData>
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
The loader reads the `account` parameter from the URL (the team slug) and fetches:
|
||||
|
||||
1. Team account details from the database
|
||||
2. Current user's role and permissions in this team
|
||||
3. All accounts the user belongs to (for the account switcher)
|
||||
|
||||
### Caching behavior
|
||||
|
||||
The function uses React's `cache()` to deduplicate calls within a single request. You can call it multiple times in nested components without additional database queries.
|
||||
|
||||
```tsx
|
||||
// Both calls use the same cached data
|
||||
const layout = await loadTeamWorkspace(); // First call: hits database
|
||||
const page = await loadTeamWorkspace(); // Second call: returns cached data
|
||||
```
|
||||
|
||||
{% callout title="Performance consideration" %}
|
||||
While calls are deduplicated within a request, the data is fetched on every navigation. For frequently accessed data, the caching prevents redundant queries within a single page render.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## useTeamAccountWorkspace (Client)
|
||||
|
||||
Access the team workspace data in client components using the `useTeamAccountWorkspace` hook. The data is provided through React Context from the layout.
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
export function TeamHeader() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{account.picture_url && (
|
||||
<img
|
||||
src={account.picture_url}
|
||||
alt={account.name}
|
||||
className="h-8 w-8 rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="font-semibold">{account.name}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{account.role} · {account.subscription_status || 'Free'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
{% callout type="warning" title="Context requirement" %}
|
||||
The `useTeamAccountWorkspace` hook only works within the `/home/[account]` route group where the context provider is set up. Using it outside this layout will throw an error.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## Data structure
|
||||
|
||||
### TeamWorkspaceData
|
||||
|
||||
```tsx
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
interface TeamWorkspaceData {
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
picture_url: string | null;
|
||||
role: string;
|
||||
role_hierarchy_level: number;
|
||||
primary_owner_user_id: string;
|
||||
subscription_status: SubscriptionStatus | null;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
user: User;
|
||||
|
||||
accounts: Array<{
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
picture_url: string | null;
|
||||
role: string | null;
|
||||
slug: string | null;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### account.role
|
||||
|
||||
The user's role in this team. Default roles:
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| `owner` | Full access, can delete team |
|
||||
| `admin` | Manage members and settings |
|
||||
| `member` | Standard access |
|
||||
|
||||
### account.role_hierarchy_level
|
||||
|
||||
A numeric value where lower numbers indicate higher privilege. Use this for role comparisons:
|
||||
|
||||
```tsx
|
||||
const { account } = useTeamAccountWorkspace();
|
||||
|
||||
// Check if user can manage someone with role_level 2
|
||||
const canManage = account.role_hierarchy_level < 2;
|
||||
```
|
||||
|
||||
### account.permissions
|
||||
|
||||
An array of permission strings the user has in this team:
|
||||
|
||||
```tsx
|
||||
[
|
||||
'billing.manage',
|
||||
'members.invite',
|
||||
'members.remove',
|
||||
'members.manage',
|
||||
'settings.manage',
|
||||
]
|
||||
```
|
||||
|
||||
### subscription_status values
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `active` | Active subscription |
|
||||
| `trialing` | In trial period |
|
||||
| `past_due` | Payment failed, grace period |
|
||||
| `canceled` | Subscription canceled |
|
||||
| `unpaid` | Payment required |
|
||||
| `incomplete` | Setup incomplete |
|
||||
| `incomplete_expired` | Setup expired |
|
||||
| `paused` | Subscription paused |
|
||||
|
||||
---
|
||||
|
||||
## Usage patterns
|
||||
|
||||
### Permission-based rendering
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
interface PermissionGateProps {
|
||||
children: React.ReactNode;
|
||||
permission: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PermissionGate({
|
||||
children,
|
||||
permission,
|
||||
fallback = null,
|
||||
}: PermissionGateProps) {
|
||||
const { account } = useTeamAccountWorkspace();
|
||||
|
||||
if (!account.permissions.includes(permission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
function TeamSettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Team Settings</h1>
|
||||
|
||||
<PermissionGate
|
||||
permission="settings.manage"
|
||||
fallback={<p>You don't have permission to manage settings.</p>}
|
||||
>
|
||||
<SettingsForm />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="billing.manage">
|
||||
<BillingSection />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Team dashboard with role checks
|
||||
|
||||
```tsx
|
||||
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export default async function TeamDashboardPage() {
|
||||
const { account, user } = await loadTeamWorkspace();
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const isOwner = account.primary_owner_user_id === user.id;
|
||||
const isAdmin = account.role === 'admin' || account.role === 'owner';
|
||||
|
||||
// Fetch team-specific data
|
||||
const { data: projects } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('account_id', account.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{account.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{account.subscription_status === 'active'
|
||||
? 'Pro Plan'
|
||||
: 'Free Plan'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<a
|
||||
href={`/home/${account.slug}/settings`}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Team Settings
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-medium">Recent Projects</h2>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{projects?.map((project) => (
|
||||
<li key={project.id}>
|
||||
<a href={`/home/${account.slug}/projects/${project.id}`}>
|
||||
{project.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{isOwner && (
|
||||
<section className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<h2 className="font-medium text-destructive">Danger Zone</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Only the team owner can delete this team.
|
||||
</p>
|
||||
<button className="mt-3 btn btn-destructive">Delete Team</button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Team members list with permissions
|
||||
|
||||
```tsx
|
||||
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export default async function TeamMembersPage() {
|
||||
const { account } = await loadTeamWorkspace();
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const canManageMembers = account.permissions.includes('members.manage');
|
||||
const canRemoveMembers = account.permissions.includes('members.remove');
|
||||
const canInviteMembers = account.permissions.includes('members.invite');
|
||||
|
||||
const { data: members } = await client
|
||||
.from('accounts_memberships')
|
||||
.select(`
|
||||
user_id,
|
||||
role,
|
||||
created_at,
|
||||
users:user_id (
|
||||
email,
|
||||
user_metadata
|
||||
)
|
||||
`)
|
||||
.eq('account_id', account.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="flex items-center justify-between">
|
||||
<h1>Team Members</h1>
|
||||
{canInviteMembers && (
|
||||
<a href={`/home/${account.slug}/settings/members/invite`}>
|
||||
Invite Member
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
{(canManageMembers || canRemoveMembers) && <th>Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members?.map((member) => (
|
||||
<tr key={member.user_id}>
|
||||
<td>{member.users?.email}</td>
|
||||
<td>{member.role}</td>
|
||||
<td>{new Date(member.created_at).toLocaleDateString()}</td>
|
||||
{(canManageMembers || canRemoveMembers) && (
|
||||
<td>
|
||||
{canManageMembers && member.user_id !== account.primary_owner_user_id && (
|
||||
<button>Change Role</button>
|
||||
)}
|
||||
{canRemoveMembers && member.user_id !== account.primary_owner_user_id && (
|
||||
<button>Remove</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Client-side permission hook
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
export function useTeamPermissions() {
|
||||
const { account } = useTeamAccountWorkspace();
|
||||
|
||||
return {
|
||||
canManageSettings: account.permissions.includes('settings.manage'),
|
||||
canManageBilling: account.permissions.includes('billing.manage'),
|
||||
canInviteMembers: account.permissions.includes('members.invite'),
|
||||
canRemoveMembers: account.permissions.includes('members.remove'),
|
||||
canManageMembers: account.permissions.includes('members.manage'),
|
||||
isOwner: account.role === 'owner',
|
||||
isAdmin: account.role === 'admin' || account.role === 'owner',
|
||||
role: account.role,
|
||||
roleLevel: account.role_hierarchy_level,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
function TeamActions() {
|
||||
const permissions = useTeamPermissions();
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{permissions.canInviteMembers && (
|
||||
<button>Invite Member</button>
|
||||
)}
|
||||
{permissions.canManageSettings && (
|
||||
<button>Settings</button>
|
||||
)}
|
||||
{permissions.canManageBilling && (
|
||||
<button>Billing</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription-gated features
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
export function PremiumFeature({ children }: { children: React.ReactNode }) {
|
||||
const { account } = useTeamAccountWorkspace();
|
||||
|
||||
const hasActiveSubscription =
|
||||
account.subscription_status === 'active' ||
|
||||
account.subscription_status === 'trialing';
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<h3 className="font-medium">Premium Feature</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Upgrade to access this feature
|
||||
</p>
|
||||
<a
|
||||
href={`/home/${account.slug}/settings/billing`}
|
||||
className="mt-3 inline-block btn btn-primary"
|
||||
>
|
||||
Upgrade Plan
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [User Workspace API](/docs/next-supabase-turbo/api/user-workspace-api) - Personal account context
|
||||
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team operations
|
||||
- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication
|
||||
- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
|
||||
531
docs/api/authentication-api.mdoc
Normal file
531
docs/api/authentication-api.mdoc
Normal file
@@ -0,0 +1,531 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Authentication API"
|
||||
order: 2
|
||||
title: "Authentication API | Next.js Supabase SaaS Kit"
|
||||
description: "Complete reference for authentication in MakerKit. Use requireUser for server-side auth checks, handle MFA verification, and access user data in client components."
|
||||
---
|
||||
|
||||
The Authentication API verifies user identity, handles MFA (Multi-Factor Authentication), and provides user data to your components. Use `requireUser` on the server for protected routes and `useUser` on the client for reactive user state.
|
||||
|
||||
{% sequence title="Authentication API Reference" description="Learn how to authenticate users in MakerKit" %}
|
||||
|
||||
[requireUser (Server)](#requireuser-server)
|
||||
|
||||
[useUser (Client)](#useuser-client)
|
||||
|
||||
[useSupabase (Client)](#usesupabase-client)
|
||||
|
||||
[MFA handling](#mfa-handling)
|
||||
|
||||
[Common patterns](#common-patterns)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## requireUser (Server)
|
||||
|
||||
The `requireUser` function checks authentication status in Server Components, Server Actions, and Route Handlers. It handles both standard auth and MFA verification in a single call.
|
||||
|
||||
```tsx
|
||||
import { redirect } from 'next/navigation';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function ProtectedPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
if (auth.error) {
|
||||
redirect(auth.redirectTo);
|
||||
}
|
||||
|
||||
const user = auth.data;
|
||||
|
||||
return <div>Welcome, {user.email}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Function signature
|
||||
|
||||
```tsx
|
||||
function requireUser(
|
||||
client: SupabaseClient,
|
||||
options?: {
|
||||
verifyMfa?: boolean; // Default: true
|
||||
}
|
||||
): Promise<RequireUserResponse>
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `client` | `SupabaseClient` | required | Supabase server client |
|
||||
| `options.verifyMfa` | `boolean` | `true` | Check MFA status |
|
||||
|
||||
### Response types
|
||||
|
||||
**Success response:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
data: {
|
||||
id: string; // User UUID
|
||||
email: string; // User email
|
||||
phone: string; // User phone (if set)
|
||||
is_anonymous: boolean; // Anonymous auth flag
|
||||
aal: 'aal1' | 'aal2'; // Auth Assurance Level
|
||||
app_metadata: Record<string, unknown>;
|
||||
user_metadata: Record<string, unknown>;
|
||||
amr: AMREntry[]; // Auth Methods Reference
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
```
|
||||
|
||||
**Error response:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
data: null;
|
||||
error: AuthenticationError | MultiFactorAuthError;
|
||||
redirectTo: string; // Where to redirect the user
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Assurance Levels (AAL)
|
||||
|
||||
| Level | Meaning |
|
||||
|-------|---------|
|
||||
| `aal1` | Basic authentication (password, magic link, OAuth) |
|
||||
| `aal2` | MFA verified (TOTP app, etc.) |
|
||||
|
||||
### Error types
|
||||
|
||||
| Error | Cause | Redirect |
|
||||
|-------|-------|----------|
|
||||
| `AuthenticationError` | User not logged in | Sign-in page |
|
||||
| `MultiFactorAuthError` | MFA required but not verified | MFA verification page |
|
||||
|
||||
### Usage in Server Components
|
||||
|
||||
```tsx
|
||||
import { redirect } from 'next/navigation';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
if (auth.error) {
|
||||
redirect(auth.redirectTo);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Logged in as: {auth.data.email}</p>
|
||||
<p>MFA status: {auth.data.aal === 'aal2' ? 'Verified' : 'Not verified'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Server Actions
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function updateProfile(formData: FormData) {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
if (auth.error) {
|
||||
redirect(auth.redirectTo);
|
||||
}
|
||||
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
await client
|
||||
.from('profiles')
|
||||
.update({ name })
|
||||
.eq('id', auth.data.id);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
### Skipping MFA verification
|
||||
|
||||
For pages that don't require full MFA verification:
|
||||
|
||||
```tsx
|
||||
const auth = await requireUser(client, { verifyMfa: false });
|
||||
```
|
||||
|
||||
{% callout type="warning" title="MFA security" %}
|
||||
Only disable MFA verification for non-sensitive pages. Always verify MFA for billing, account deletion, and other high-risk operations.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## useUser (Client)
|
||||
|
||||
The `useUser` hook provides reactive access to user data in client components. It reads from the auth context and updates automatically on auth state changes.
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
|
||||
function UserMenu() {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{user.email}</span>
|
||||
<img src={user.user_metadata.avatar_url} alt="Avatar" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Return type
|
||||
|
||||
```tsx
|
||||
User | null
|
||||
```
|
||||
|
||||
The `User` type from Supabase includes:
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
app_metadata: {
|
||||
provider: string;
|
||||
providers: string[];
|
||||
};
|
||||
user_metadata: {
|
||||
avatar_url?: string;
|
||||
full_name?: string;
|
||||
// Custom metadata fields
|
||||
};
|
||||
aal?: 'aal1' | 'aal2';
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional rendering
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
|
||||
function ConditionalContent() {
|
||||
const user = useUser();
|
||||
|
||||
// Show loading state
|
||||
if (user === undefined) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (!user) {
|
||||
return <LoginPrompt />;
|
||||
}
|
||||
|
||||
// Authenticated
|
||||
return <UserDashboard user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## useSupabase (Client)
|
||||
|
||||
The `useSupabase` hook provides the Supabase browser client for client-side operations.
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function TaskList() {
|
||||
const supabase = useSupabase();
|
||||
|
||||
const { data: tasks } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{tasks?.map((task) => (
|
||||
<li key={task.id}>{task.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MFA handling
|
||||
|
||||
MakerKit automatically handles MFA verification through the `requireUser` function.
|
||||
|
||||
### How it works
|
||||
|
||||
1. User logs in with password/OAuth (reaches `aal1`)
|
||||
2. If MFA is enabled, `requireUser` checks AAL
|
||||
3. If `aal1` but MFA required, redirects to MFA verification
|
||||
4. After TOTP verification, user reaches `aal2`
|
||||
5. Protected pages now accessible
|
||||
|
||||
### MFA flow diagram
|
||||
|
||||
```
|
||||
Login → aal1 → requireUser() → MFA enabled?
|
||||
↓
|
||||
Yes: redirect to /auth/verify
|
||||
↓
|
||||
User enters TOTP
|
||||
↓
|
||||
aal2 → Access granted
|
||||
```
|
||||
|
||||
### Checking MFA status
|
||||
|
||||
```tsx
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function checkMfaStatus() {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client, { verifyMfa: false });
|
||||
|
||||
if (auth.error) {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
mfaEnabled: auth.data.aal === 'aal2',
|
||||
authMethods: auth.data.amr.map((m) => m.method),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Protected API Route Handler
|
||||
|
||||
```tsx
|
||||
// app/api/user/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function GET() {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
if (auth.error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profile } = await client
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', auth.data.id)
|
||||
.single();
|
||||
|
||||
return NextResponse.json({ user: auth.data, profile });
|
||||
}
|
||||
```
|
||||
|
||||
### Using authActionClient (recommended)
|
||||
|
||||
The `authActionClient` utility handles authentication automatically:
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const UpdateProfileSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
});
|
||||
|
||||
export const updateProfile = authActionClient
|
||||
.inputSchema(UpdateProfileSchema)
|
||||
.action(async ({ parsedInput: data, ctx: { user } }) => {
|
||||
// user is automatically available and typed
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
await client
|
||||
.from('profiles')
|
||||
.update({ name: data.name })
|
||||
.eq('id', user.id);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
```
|
||||
|
||||
### Public actions (no auth)
|
||||
|
||||
```tsx
|
||||
import { publicActionClient } from '@kit/next/safe-action';
|
||||
|
||||
export const submitContactForm = publicActionClient
|
||||
.inputSchema(ContactFormSchema)
|
||||
.action(async ({ parsedInput: data }) => {
|
||||
// No user context in public actions
|
||||
await sendEmail(data);
|
||||
return { success: true };
|
||||
});
|
||||
```
|
||||
|
||||
### Role-based access control
|
||||
|
||||
Combine authentication with role checks:
|
||||
|
||||
```tsx
|
||||
import { redirect } from 'next/navigation';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
|
||||
async function AdminPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
if (auth.error) {
|
||||
redirect(auth.redirectTo);
|
||||
}
|
||||
|
||||
const isAdmin = await isSuperAdmin(client);
|
||||
|
||||
if (!isAdmin) {
|
||||
redirect('/home');
|
||||
}
|
||||
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
```
|
||||
|
||||
### Auth state listener (Client)
|
||||
|
||||
For real-time auth state changes:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function AuthStateListener({ onAuthChange }) {
|
||||
const supabase = useSupabase();
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_IN') {
|
||||
onAuthChange({ type: 'signed_in', user: session?.user });
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
onAuthChange({ type: 'signed_out' });
|
||||
} else if (event === 'TOKEN_REFRESHED') {
|
||||
onAuthChange({ type: 'token_refreshed' });
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [supabase, onAuthChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Common mistakes
|
||||
|
||||
### Creating client at module scope
|
||||
|
||||
```tsx
|
||||
// WRONG: Client created at module scope
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
export async function handler() {
|
||||
const auth = await requireUser(client); // Won't work
|
||||
}
|
||||
|
||||
// RIGHT: Client created in request context
|
||||
export async function handler() {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
}
|
||||
```
|
||||
|
||||
### Ignoring the redirectTo property
|
||||
|
||||
```tsx
|
||||
// WRONG: Not using redirectTo
|
||||
if (auth.error) {
|
||||
redirect('/login'); // MFA users sent to wrong page
|
||||
}
|
||||
|
||||
// RIGHT: Use the provided redirectTo
|
||||
if (auth.error) {
|
||||
redirect(auth.redirectTo); // Correct handling for auth + MFA
|
||||
}
|
||||
```
|
||||
|
||||
### Using useUser for server-side checks
|
||||
|
||||
```tsx
|
||||
// WRONG: useUser is client-only
|
||||
export async function ServerComponent() {
|
||||
const user = useUser(); // Won't work
|
||||
}
|
||||
|
||||
// RIGHT: Use requireUser on server
|
||||
export async function ServerComponent() {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
}
|
||||
```
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Account API](/docs/next-supabase-turbo/api/account-api) - Personal account operations
|
||||
- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Using authActionClient
|
||||
- [Route Handlers](/docs/next-supabase-turbo/data-fetching/route-handlers) - API authentication
|
||||
302
docs/api/otp-api.mdoc
Normal file
302
docs/api/otp-api.mdoc
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "OTP API"
|
||||
order: 5
|
||||
title: "OTP API | Next.js Supabase SaaS Kit"
|
||||
description: "Generate and verify one-time passwords for secure operations in MakerKit. Use the OTP API for account deletion, ownership transfers, and other high-risk actions."
|
||||
---
|
||||
|
||||
The OTP API generates and verifies one-time passwords for secure operations like account deletion, ownership transfers, and email verification. It uses Supabase for secure token storage with automatic expiration and verification tracking.
|
||||
|
||||
{% sequence title="How to use the OTP API" description="Learn how to use the OTP API in Makerkit" %}
|
||||
|
||||
[OTP API - What is it for?](#otp-api---what-is-it-for)
|
||||
|
||||
[Installation](#installation)
|
||||
|
||||
[Basic Usage](#basic-usage)
|
||||
|
||||
[Server Actions](#server-actions)
|
||||
|
||||
[Verification UI Component](#verification-ui-component)
|
||||
|
||||
[API Reference](#api-reference)
|
||||
|
||||
[Database Schema](#database-schema)
|
||||
|
||||
[Best Practices](#best-practices)
|
||||
|
||||
[Example Use Cases](#example-use-cases)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
It is used for various destructive actions in the SaaS Kit, such as deleting
|
||||
accounts, deleting teams, and deleting users. However, you can use it for a
|
||||
variety of other purposes as well, such as:
|
||||
|
||||
- Your custom destructive actions
|
||||
- oAuth account connections
|
||||
- etc.
|
||||
|
||||
## OTP API - What is it for?
|
||||
|
||||
The OTP package offers:
|
||||
|
||||
- **Secure Token Generation**: Create time-limited tokens with configurable expiration
|
||||
- **Email Delivery**: Send OTP codes via email with customizable templates
|
||||
- **Verification UI**: Ready-to-use verification form component
|
||||
- **Token Management**: Revoke, verify, and check token status
|
||||
|
||||
## Installation
|
||||
|
||||
If you're using Makerkit, this package is already included. For manual installation:
|
||||
|
||||
```bash
|
||||
pnpm add @kit/otp
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating and Sending an OTP
|
||||
|
||||
To create and send an OTP, you can use the `createToken` method:
|
||||
|
||||
```typescript
|
||||
import { createOtpApi } from '@kit/otp/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
// Create the API instance
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createOtpApi(client);
|
||||
|
||||
// Generate and send an OTP email
|
||||
await api.createToken({
|
||||
userId: user.id,
|
||||
purpose: 'email-verification',
|
||||
expiresInSeconds: 3600, // 1 hour
|
||||
metadata: { redirectTo: '/verify-email' }
|
||||
});
|
||||
|
||||
// Send the email with the OTP
|
||||
await api.sendOtpEmail({
|
||||
email: userEmail,
|
||||
otp: token.token
|
||||
});
|
||||
```
|
||||
|
||||
### Verifying an OTP
|
||||
|
||||
To verify an OTP, you can use the `verifyToken` method:
|
||||
|
||||
```typescript
|
||||
// Verify the token
|
||||
const result = await api.verifyToken({
|
||||
token: submittedToken,
|
||||
purpose: 'email-verification'
|
||||
});
|
||||
|
||||
if (result.valid) {
|
||||
// Token is valid, proceed with the operation
|
||||
const { userId, metadata } = result;
|
||||
// Handle successful verification
|
||||
} else {
|
||||
// Token is invalid or expired
|
||||
// Handle verification failure
|
||||
}
|
||||
```
|
||||
|
||||
## Server Actions
|
||||
|
||||
The package includes a ready-to-use server action for sending OTP emails:
|
||||
|
||||
```typescript
|
||||
import { sendOtpEmailAction } from '@kit/otp/server/server-actions';
|
||||
|
||||
// In a form submission handler
|
||||
const result = await sendOtpEmailAction({
|
||||
email: userEmail,
|
||||
purpose: 'password-reset',
|
||||
expiresInSeconds: 1800 // 30 minutes
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// OTP was sent successfully
|
||||
} else {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `email` parameter is only used as verification mechanism, the actual email address being used is the one associated with the user.
|
||||
|
||||
## Verification UI Component
|
||||
|
||||
The package includes a ready-to-use OTP verification form:
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
function MyVerificationPage() {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
purpose="password-reset"
|
||||
email={userEmail}
|
||||
onSuccess={(otp) => {
|
||||
// Handle successful verification
|
||||
// Use the OTP for verification on the server
|
||||
}}
|
||||
CancelButton={
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createOtpApi(client)`
|
||||
|
||||
Creates an instance of the OTP API.
|
||||
|
||||
**Parameters**:
|
||||
- `client`: A Supabase client instance
|
||||
- **Returns**: OTP API instance with the following methods:
|
||||
|
||||
### `api.createToken(params)`
|
||||
|
||||
Creates a new one-time token.
|
||||
|
||||
**Parameters**:
|
||||
- `params.userId` (optional): User ID to associate with the token
|
||||
- `params.purpose`: Purpose of the token (e.g., 'password-reset')
|
||||
- `params.expiresInSeconds` (optional): Token expiration time in seconds (default: 3600)
|
||||
- `params.metadata` (optional): Additional data to store with the token
|
||||
- `params.description` (optional): Description of the token
|
||||
- `params.tags` (optional): Array of string tags
|
||||
- `params.scopes` (optional): Array of permission scopes
|
||||
- `params.revokePrevious` (optional): Whether to revoke previous tokens with the same purpose (default: true)
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
id: string; // Database ID of the token
|
||||
token: string; // The actual token to send to the user
|
||||
expiresAt: string; // Expiration timestamp
|
||||
revokedPreviousCount: number; // Number of previously revoked tokens
|
||||
}
|
||||
```
|
||||
|
||||
### `api.verifyToken(params)`
|
||||
|
||||
Verifies a one-time token.
|
||||
|
||||
**Parameters**:
|
||||
- `params.token`: The token to verify
|
||||
- `params.purpose`: Purpose of the token (must match the purpose used when creating)
|
||||
- `params.userId` (optional): User ID for additional verification
|
||||
- `params.requiredScopes` (optional): Array of required permission scopes
|
||||
- `params.maxVerificationAttempts` (optional): Maximum allowed verification attempts
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
valid: boolean; // Whether the token is valid
|
||||
userId?: string; // User ID associated with the token (if valid)
|
||||
metadata?: object; // Metadata associated with the token (if valid)
|
||||
message?: string; // Error message (if invalid)
|
||||
scopes?: string[]; // Permission scopes (if valid)
|
||||
purpose?: string; // Token purpose (if valid)
|
||||
}
|
||||
```
|
||||
|
||||
### `api.revokeToken(params)`
|
||||
|
||||
Revokes a token to prevent its future use.
|
||||
|
||||
**Parameters**:
|
||||
- `params.id`: ID of the token to revoke
|
||||
- `params.reason` (optional): Reason for revocation
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
success: boolean; // Whether the token was successfully revoked
|
||||
}
|
||||
```
|
||||
|
||||
### `api.getTokenStatus(params)`
|
||||
|
||||
Gets the status of a token.
|
||||
|
||||
**Parameters**:
|
||||
- `params.id`: ID of the token
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
exists: boolean; // Whether the token exists
|
||||
purpose?: string; // Token purpose
|
||||
userId?: string; // User ID associated with the token
|
||||
createdAt?: string; // Creation timestamp
|
||||
expiresAt?: string; // Expiration timestamp
|
||||
usedAt?: string; // When the token was used (if used)
|
||||
revoked?: boolean; // Whether the token is revoked
|
||||
revokedReason?: string; // Reason for revocation (if revoked)
|
||||
verificationAttempts?: number; // Number of verification attempts
|
||||
lastVerificationAt?: string; // Last verification attempt timestamp
|
||||
lastVerificationIp?: string; // IP address of last verification attempt
|
||||
isValid?: boolean; // Whether the token is still valid
|
||||
}
|
||||
```
|
||||
|
||||
### `api.sendOtpEmail(params)`
|
||||
|
||||
Sends an email containing the OTP code.
|
||||
|
||||
**Parameters**:
|
||||
- `params.email`: Email address to send to
|
||||
- `params.otp`: OTP code to include in the email
|
||||
|
||||
**Returns**: Promise that resolves when the email is sent
|
||||
|
||||
## Database Schema
|
||||
|
||||
The package uses a `nonces` table in your Supabase database with the following structure:
|
||||
|
||||
- `id`: UUID primary key
|
||||
- `client_token`: Hashed token sent to client
|
||||
- `nonce`: Securely stored token hash
|
||||
- `user_id`: Optional reference to auth.users
|
||||
- `purpose`: Purpose identifier (e.g., 'password-reset')
|
||||
- Status fields: `expires_at`, `created_at`, `used_at`, etc.
|
||||
- Audit fields: `verification_attempts`, `last_verification_at`, etc.
|
||||
- Extensibility fields: `metadata`, `scopes`
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Specific Purposes**: Always use descriptive, specific purpose identifiers for your tokens.
|
||||
2. **Short Expiration Times**: Set token expiration times to the minimum necessary for your use case.
|
||||
3. **Handle Verification Failures**: Provide clear error messages when verification fails.
|
||||
4. **Secure Your Tokens**: Never log or expose tokens in client-side code or URLs.
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
- Email verification
|
||||
- Two-factor authentication
|
||||
- Account deletion confirmation
|
||||
- Important action verification
|
||||
|
||||
Each use case should use a distinct purpose identifier. The purpose will
|
||||
always need to match the one used when creating the token.
|
||||
|
||||
When you need to assign a specific data to a token, you can modify the
|
||||
purpose with a unique identifier, such as `email-verification-12345`.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication and session handling
|
||||
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team management for ownership transfers
|
||||
- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration) - Configure email delivery for OTP codes
|
||||
- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Use OTP verification in server actions
|
||||
551
docs/api/policies-api.mdoc
Normal file
551
docs/api/policies-api.mdoc
Normal file
@@ -0,0 +1,551 @@
|
||||
---
|
||||
label: "Feature Policies API"
|
||||
title: "Feature Policies API | Next.js Supabase SaaS Kit"
|
||||
order: 7
|
||||
status: "published"
|
||||
description: "Build declarative business rules with MakerKit's Feature Policies API. Validate team invitations, enforce subscription limits, and create custom authorization flows."
|
||||
---
|
||||
|
||||
The Feature Policy API isolates validation and authorization logic from application code so every feature can reuse consistent, auditable policies.
|
||||
|
||||
**Makerkit is built for extensibility**: customers should expand features without patching internals unless they are opting into special cases. The Feature Policy API delivers that promise by turning customization into additive policies instead of edits to core flows.
|
||||
|
||||
**Important**: Feature Policies operates at the API and application surface level. It orchestrates business logic, user experience flows, and feature access decisions. For data integrity and security enforcement, **continue using database constraints, Supabase RLS policies, and transactional safeguards as your source of truth**.
|
||||
|
||||
## What It's For
|
||||
|
||||
- **Application logic**: User flows, feature access, business rule validation
|
||||
- **API orchestration**: Request processing, workflow coordination, conditional routing
|
||||
- **User experience**: Dynamic UI behavior, progressive disclosure, personalization
|
||||
- **Integration patterns**: Third-party service coordination, webhook processing
|
||||
|
||||
## What It's NOT For
|
||||
|
||||
- **Data integrity**: Use database constraints and foreign keys
|
||||
- **Security enforcement**: Use Supabase RLS policies and authentication
|
||||
- **Performance-critical paths**: Use database indexes and query optimization
|
||||
- **Transactional consistency**: Use database transactions and ACID guarantees
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- Apply nuanced rules without coupling them to route handlers or services
|
||||
- Share policy logic across server actions, mutations, and background jobs
|
||||
- Test policies in isolation while keeping runtime orchestration predictable
|
||||
- Layer customer-specific extensions on top of Makerkit defaults
|
||||
|
||||
## How We Use It Today
|
||||
|
||||
Makerkit currently uses the Feature Policy API for team invitation flows to validate **when a team can send invitations**. While supporting customers implement various flows for invitations, it was clear that the SaaS Starter Kit could not assume what rules you want to apply to invitations.
|
||||
|
||||
- Some customers wanted to validate the email address of the invited user (ex. validate they all shared the same domain)
|
||||
- A set of customers wanted only users on a specific plan to be able to invite users (ex. only Pro users can invite users)
|
||||
- Others simply wanted to limit how many invitations can be sent on a per-plan basis (ex. only 5 invitations can be sent on on a free plan, 20 on a paid plan, etc.)
|
||||
|
||||
These rules required a more declarative approach - which is why we created the Policies API - so that users can layer their own requirements without the need to rewrite internals.
|
||||
|
||||
Additional features can opt in to the same registry pattern to unlock the shared orchestration and extension tooling.
|
||||
|
||||
## Why Feature Policies?
|
||||
|
||||
A SaaS starter kit must adapt to **diverse customer requirements** without creating divergent forks.
|
||||
|
||||
Imperative checks embedded in controllers quickly become brittle: every variation requires new conditionals, feature flags, or early returns scattered across files.
|
||||
|
||||
The Feature Policy API keeps the rule set declarative and centralized, **so product teams can swap, reorder, or extend policies without rewriting the baseline flow**.
|
||||
|
||||
Registries turn policy changes into configuration instead of refactors, making it safer for customers to customize logic while continuing to receive upstream updates from Makerkit.
|
||||
|
||||
## Overview
|
||||
|
||||
The Feature Policy API provides:
|
||||
|
||||
- **Feature-specific registries** for organized policy management per feature
|
||||
- **Configuration support** so policies can accept typed configuration objects
|
||||
- **Stage-aware evaluation** enabling policies to be filtered by execution stage
|
||||
- **Immutable contexts** that keep policy execution safe and predictable
|
||||
- **Perfect DX** through a unified API that just works
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a Feature-Specific Registry
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createPolicyRegistry,
|
||||
definePolicy,
|
||||
createPoliciesEvaluator,
|
||||
allow,
|
||||
deny,
|
||||
} from '@kit/policies';
|
||||
|
||||
// Create feature-specific registry
|
||||
const invitationPolicyRegistry = createPolicyRegistry();
|
||||
|
||||
// Register policies
|
||||
invitationPolicyRegistry.registerPolicy(
|
||||
definePolicy({
|
||||
id: 'email-validation',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async (context) => {
|
||||
if (!context.invitations.some((inv) => inv.email?.includes('@'))) {
|
||||
return deny({
|
||||
code: 'INVALID_EMAIL_FORMAT',
|
||||
message: 'Invalid email format',
|
||||
remediation: 'Please provide a valid email address',
|
||||
});
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Register configurable policy
|
||||
invitationPolicyRegistry.registerPolicy(
|
||||
definePolicy({
|
||||
id: 'max-invitations',
|
||||
stages: ['preliminary', 'submission'],
|
||||
configSchema: z.object({
|
||||
maxInvitations: z.number().positive(),
|
||||
}),
|
||||
evaluate: async (context, config = { maxInvitations: 5 }) => {
|
||||
if (context.invitations.length > config.maxInvitations) {
|
||||
return deny({
|
||||
code: 'MAX_INVITATIONS_EXCEEDED',
|
||||
message: `Cannot invite more than ${config.maxInvitations} members`,
|
||||
remediation: `Reduce invitations to ${config.maxInvitations} or fewer`,
|
||||
});
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Create a Feature Policy Evaluator
|
||||
|
||||
```typescript
|
||||
export function createInvitationsPolicyEvaluator() {
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
|
||||
return {
|
||||
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
|
||||
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
|
||||
},
|
||||
|
||||
async canInvite(context, stage: 'preliminary' | 'submission') {
|
||||
return evaluator.evaluate(invitationPolicyRegistry, context, 'ALL', stage);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use the Policy Evaluator
|
||||
|
||||
```typescript
|
||||
import { createInvitationsPolicyEvaluator } from './your-policies';
|
||||
|
||||
async function validateInvitations(context) {
|
||||
const evaluator = createInvitationsPolicyEvaluator();
|
||||
|
||||
// Performance optimization: only build context if policies exist
|
||||
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
|
||||
|
||||
if (!hasPolicies) {
|
||||
return; // No policies to evaluate
|
||||
}
|
||||
|
||||
const result = await evaluator.canInvite(context, 'submission');
|
||||
|
||||
if (!result.allowed) {
|
||||
throw new Error(result.reasons.join(', '));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The `deny()` helper supports both simple strings and structured errors.
|
||||
|
||||
### String Errors (Simple)
|
||||
|
||||
```typescript
|
||||
return deny('Email validation failed');
|
||||
```
|
||||
|
||||
### Structured Errors (Enhanced)
|
||||
|
||||
```typescript
|
||||
return deny({
|
||||
code: 'INVALID_EMAIL_FORMAT',
|
||||
message: 'Email validation failed',
|
||||
remediation: 'Please provide a valid email address',
|
||||
metadata: { fieldName: 'email' },
|
||||
});
|
||||
```
|
||||
|
||||
### Accessing Error Details
|
||||
|
||||
```typescript
|
||||
const result = await evaluator.canInvite(context, 'submission');
|
||||
|
||||
if (!result.allowed) {
|
||||
console.log('Reasons:', result.reasons);
|
||||
|
||||
result.results.forEach((policyResult) => {
|
||||
if (!policyResult.allowed && policyResult.metadata) {
|
||||
console.log('Error code:', policyResult.metadata.code);
|
||||
console.log('Remediation:', policyResult.metadata.remediation);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 1. Lazy Context Building
|
||||
|
||||
Only build expensive context when policies exist:
|
||||
|
||||
```typescript
|
||||
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
|
||||
|
||||
if (!hasPolicies) {
|
||||
return; // Skip expensive operations
|
||||
}
|
||||
|
||||
// Build context now that policies need to run
|
||||
const context = await buildExpensiveContext();
|
||||
const result = await evaluator.canInvite(context, 'submission');
|
||||
```
|
||||
|
||||
### 2. Stage-Aware Evaluation
|
||||
|
||||
Filter policies by execution stage:
|
||||
|
||||
```typescript
|
||||
// Fast preliminary checks
|
||||
const prelimResult = await evaluator.canInvite(context, 'preliminary');
|
||||
|
||||
// Full submission validation
|
||||
const submitResult = await evaluator.canInvite(context, 'submission');
|
||||
```
|
||||
|
||||
### 3. AND/OR Logic
|
||||
|
||||
Control evaluation behavior:
|
||||
|
||||
```typescript
|
||||
// ALL: Every policy must pass (default)
|
||||
const result = await evaluator.evaluate(registry, context, 'ALL', stage);
|
||||
|
||||
// ANY: At least one policy must pass
|
||||
const result = await evaluator.evaluate(registry, context, 'ANY', stage);
|
||||
```
|
||||
|
||||
## Real-World Example: Team Invitations
|
||||
|
||||
Makerkit uses the Feature Policy API to power team invitation rules.
|
||||
|
||||
```typescript
|
||||
// packages/features/team-accounts/src/server/policies/invitation-policies.ts
|
||||
import { allow, definePolicy, deny } from '@kit/policies';
|
||||
import { createPolicyRegistry } from '@kit/policies';
|
||||
|
||||
import { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
|
||||
/**
|
||||
* Feature-specific registry for invitation policies
|
||||
*/
|
||||
export const invitationPolicyRegistry = createPolicyRegistry();
|
||||
|
||||
/**
|
||||
* Subscription required policy
|
||||
* Checks if the account has an active subscription
|
||||
*/
|
||||
export const subscriptionRequiredInvitationsPolicy =
|
||||
definePolicy<FeaturePolicyInvitationContext>({
|
||||
id: 'subscription-required',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async ({ subscription }) => {
|
||||
if (!subscription || !subscription.active) {
|
||||
return deny({
|
||||
code: 'SUBSCRIPTION_REQUIRED',
|
||||
message: 'teams.policyErrors.subscriptionRequired',
|
||||
remediation: 'teams.policyRemediation.subscriptionRequired',
|
||||
});
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Paddle billing policy
|
||||
* Checks if the account has a paddle subscription and is in a trial period
|
||||
*/
|
||||
export const paddleBillingInvitationsPolicy =
|
||||
definePolicy<FeaturePolicyInvitationContext>({
|
||||
id: 'paddle-billing',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async ({ subscription }) => {
|
||||
// combine with subscriptionRequiredPolicy if subscription must be required
|
||||
if (!subscription) {
|
||||
return allow();
|
||||
}
|
||||
|
||||
// Paddle specific constraint: cannot update subscription items during trial
|
||||
if (
|
||||
subscription.provider === 'paddle' &&
|
||||
subscription.status === 'trialing'
|
||||
) {
|
||||
const hasPerSeatItems = subscription.items.some(
|
||||
(item) => item.type === 'per_seat',
|
||||
);
|
||||
|
||||
if (hasPerSeatItems) {
|
||||
return deny({
|
||||
code: 'PADDLE_TRIAL_RESTRICTION',
|
||||
message: 'teams.policyErrors.paddleTrialRestriction',
|
||||
remediation: 'teams.policyRemediation.paddleTrialRestriction',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
// Register policies to apply them
|
||||
invitationPolicyRegistry.registerPolicy(subscriptionRequiredInvitationsPolicy);
|
||||
invitationPolicyRegistry.registerPolicy(paddleBillingInvitationsPolicy);
|
||||
|
||||
export function createInvitationsPolicyEvaluator() {
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
|
||||
return {
|
||||
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
|
||||
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
|
||||
},
|
||||
|
||||
async canInvite(context, stage: 'preliminary' | 'submission') {
|
||||
return evaluator.evaluate(invitationPolicyRegistry, context, 'ALL', stage);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Customer Extension Pattern
|
||||
|
||||
Customers can extend policies by creating their own registries, adding to existing registries, or composing policy evaluators.
|
||||
|
||||
### Method 1: Own Registry
|
||||
|
||||
```typescript
|
||||
// customer-invitation-policies.ts
|
||||
import { createPolicyRegistry, definePolicy } from '@kit/policies';
|
||||
|
||||
const customerInvitationRegistry = createPolicyRegistry();
|
||||
|
||||
customerInvitationRegistry.registerPolicy(
|
||||
definePolicy({
|
||||
id: 'custom-domain-check',
|
||||
stages: ['preliminary'],
|
||||
evaluate: async (context) => {
|
||||
const allowedDomains = ['company.com', 'partner.com'];
|
||||
|
||||
for (const invitation of context.invitations) {
|
||||
const domain = invitation.email?.split('@')[1];
|
||||
if (!allowedDomains.includes(domain)) {
|
||||
return deny({
|
||||
code: 'DOMAIN_NOT_ALLOWED',
|
||||
message: `Email domain ${domain} is not allowed`,
|
||||
remediation: 'Use an email from an approved domain',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export function createCustomInvitationPolicyEvaluator() {
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
|
||||
return {
|
||||
async validateCustomRules(context, stage) {
|
||||
return evaluator.evaluate(customerInvitationRegistry, context, 'ALL', stage);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Compose Policy Evaluators
|
||||
|
||||
```typescript
|
||||
// Use both built-in and custom policies
|
||||
import { createInvitationsPolicyEvaluator } from '@kit/team-accounts/policies';
|
||||
import { createCustomInvitationPolicyEvaluator } from './customer-policies';
|
||||
|
||||
async function validateInvitations(context, stage) {
|
||||
const builtinEvaluator = createInvitationsPolicyEvaluator();
|
||||
const customEvaluator = createCustomInvitationPolicyEvaluator();
|
||||
|
||||
// Run built-in policies
|
||||
const builtinResult = await builtinEvaluator.canInvite(context, stage);
|
||||
|
||||
if (!builtinResult.allowed) {
|
||||
throw new Error(builtinResult.reasons.join(', '));
|
||||
}
|
||||
|
||||
// Run custom policies
|
||||
const customResult = await customEvaluator.validateCustomRules(context, stage);
|
||||
|
||||
if (!customResult.allowed) {
|
||||
throw new Error(customResult.reasons.join(', '));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complex Group Evaluation
|
||||
|
||||
For advanced scenarios requiring complex business logic with multiple decision paths:
|
||||
|
||||
### Example: Multi-Stage Enterprise Validation
|
||||
|
||||
```typescript
|
||||
// Complex scenario: (Authentication AND Email) AND (Subscription OR Trial) AND Final Validation
|
||||
async function validateEnterpriseFeatureAccess(context: FeatureContext) {
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
|
||||
// Stage 1: Authentication Requirements (ALL must pass)
|
||||
const authenticationGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.userId ? allow({ step: 'authenticated' }) : deny('Authentication required')
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.email?.includes('@') ? allow({ step: 'email-valid' }) : deny('Valid email required')
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.permissions.includes('enterprise-features')
|
||||
? allow({ step: 'permissions' })
|
||||
: deny('Enterprise permissions required')
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Stage 2: Billing Validation (ANY sufficient - flexible payment options)
|
||||
const billingGroup = {
|
||||
operator: 'ANY' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.subscription?.plan === 'enterprise' && ctx.subscription.active
|
||||
? allow({ billing: 'enterprise-subscription' })
|
||||
: deny('Enterprise subscription required')
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.trial?.type === 'enterprise' && ctx.trial.daysRemaining > 0
|
||||
? allow({ billing: 'enterprise-trial', daysLeft: ctx.trial.daysRemaining })
|
||||
: deny('Active enterprise trial required')
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.adminOverride?.enabled && ctx.user.role === 'super-admin'
|
||||
? allow({ billing: 'admin-override' })
|
||||
: deny('Admin override not available')
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Stage 3: Final Constraints (ALL must pass)
|
||||
const constraintsGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.team.memberCount <= ctx.maxMembers
|
||||
? allow({ constraint: 'team-size-valid' })
|
||||
: deny('Team size exceeds plan limits')
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.organization.complianceStatus === 'approved'
|
||||
? allow({ constraint: 'compliance-approved' })
|
||||
: deny('Organization compliance approval required')
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Execute all groups sequentially - ALL groups must pass
|
||||
const result = await evaluator.evaluateGroups([
|
||||
authenticationGroup,
|
||||
billingGroup,
|
||||
constraintsGroup
|
||||
], context);
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
reasons: result.reasons,
|
||||
metadata: {
|
||||
authenticationPassed: result.results.some(r => r.metadata?.step === 'authenticated'),
|
||||
billingMethod: result.results.find(r => r.metadata?.billing)?.metadata?.billing,
|
||||
constraintsValidated: result.results.some(r => r.metadata?.constraint),
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Group Evaluation Flow
|
||||
|
||||
1. **Sequential Group Processing**: Groups are evaluated in order
|
||||
2. **All Groups Must Pass**: If any group fails, entire evaluation fails
|
||||
3. **Short-Circuiting**: Stops on first group failure for performance
|
||||
4. **Metadata Preservation**: All policy results and metadata are collected
|
||||
|
||||
### Group Operators
|
||||
|
||||
- **`ALL` (AND logic)**: All policies in the group must pass
|
||||
- Short-circuits on first failure for performance
|
||||
- Use for mandatory requirements where every condition must be met
|
||||
|
||||
- **`ANY` (OR logic)**: At least one policy in the group must pass
|
||||
- Short-circuits on first success for performance
|
||||
- Use for flexible requirements where multiple options are acceptable
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Order groups by criticality**: Put fast, critical checks first
|
||||
- **Group by evaluation cost**: Separate expensive operations
|
||||
- **Monitor evaluation time**: Track performance for optimization
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Functions
|
||||
|
||||
- `createPolicyRegistry()` — Create a feature-specific registry
|
||||
- `definePolicy(config)` — Define a policy with metadata and configuration
|
||||
- `createPoliciesEvaluator()` — Create a policy evaluator instance
|
||||
- `allow(metadata?)` — Return a success result with optional metadata
|
||||
- `deny(reason | error)` — Return a failure result (supports strings and structured errors)
|
||||
|
||||
### Policy Evaluator Methods
|
||||
|
||||
- `evaluator.evaluate(registry, context, operator, stage?)` — Evaluate registry policies
|
||||
- `evaluator.evaluateGroups(groups, context)` — Evaluate complex group logic
|
||||
- `evaluator.hasPoliciesForStage(registry, stage?)` — Check if policies exist for a stage
|
||||
|
||||
### Types
|
||||
|
||||
- `PolicyContext` — Base context interface
|
||||
- `PolicyResult` — Policy evaluation result
|
||||
- `PolicyStage` — Execution stage (`'preliminary' | 'submission' | string`)
|
||||
- `EvaluationResult` — Contains `allowed`, `reasons`, and `results` arrays
|
||||
- `PolicyGroup` — Group configuration with `operator` and `policies`
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team management operations
|
||||
- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Payment provider setup
|
||||
- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) - Database-level security
|
||||
- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing with seat limits
|
||||
415
docs/api/registry-api.mdoc
Normal file
415
docs/api/registry-api.mdoc
Normal file
@@ -0,0 +1,415 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Registry API"
|
||||
title: "Registry API for Interchangeable Services | Next.js Supabase SaaS Kit"
|
||||
description: "Build pluggable infrastructure with MakerKit's Registry API. Swap billing providers, mailers, monitoring services, and CMS clients without changing application code."
|
||||
order: 6
|
||||
---
|
||||
|
||||
The Registry API provides a type-safe pattern for registering and resolving interchangeable service implementations. Use it to swap between billing providers (Stripe, Lemon Squeezy, Paddle), mailers (Resend, Mailgun), monitoring (Sentry, SignOz), and any other pluggable infrastructure based on environment variables.
|
||||
|
||||
{% sequence title="Registry API Reference" description="Build pluggable infrastructure with the Registry API" %}
|
||||
|
||||
[Why use a registry](#why-use-a-registry)
|
||||
|
||||
[Core API](#core-api)
|
||||
|
||||
[Creating a registry](#creating-a-registry)
|
||||
|
||||
[Registering implementations](#registering-implementations)
|
||||
|
||||
[Resolving implementations](#resolving-implementations)
|
||||
|
||||
[Setup hooks](#setup-hooks)
|
||||
|
||||
[Real-world examples](#real-world-examples)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## Why use a registry
|
||||
|
||||
MakerKit uses registries to decouple your application code from specific service implementations:
|
||||
|
||||
| Problem | Registry Solution |
|
||||
|---------|------------------|
|
||||
| Billing provider lock-in | Switch from Stripe to Paddle via env var |
|
||||
| Testing with different backends | Register mock implementations for tests |
|
||||
| Multi-tenant configurations | Different providers per tenant |
|
||||
| Lazy initialization | Services only load when first accessed |
|
||||
| Type safety | Full TypeScript support for implementations |
|
||||
|
||||
### How MakerKit uses registries
|
||||
|
||||
```
|
||||
Environment Variable Registry Your Code
|
||||
───────────────────── ──────── ─────────
|
||||
BILLING_PROVIDER=stripe → billingRegistry → getBillingGateway()
|
||||
MAILER_PROVIDER=resend → mailerRegistry → getMailer()
|
||||
CMS_PROVIDER=keystatic → cmsRegistry → getCmsClient()
|
||||
```
|
||||
|
||||
Your application code calls `getBillingGateway()` and receives the configured implementation without knowing which provider is active.
|
||||
|
||||
---
|
||||
|
||||
## Core API
|
||||
|
||||
The registry helper at `@kit/shared/registry` provides four methods:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `register(name, factory)` | Store an async factory for an implementation |
|
||||
| `get(...names)` | Resolve one or more implementations |
|
||||
| `addSetup(group, callback)` | Queue initialization tasks |
|
||||
| `setup(group?)` | Execute setup tasks (once per group) |
|
||||
|
||||
---
|
||||
|
||||
## Creating a registry
|
||||
|
||||
Use `createRegistry<T, N>()` to create a typed registry:
|
||||
|
||||
```tsx
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
// Define the interface implementations must follow
|
||||
interface EmailService {
|
||||
send(to: string, subject: string, body: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Define allowed provider names
|
||||
type EmailProvider = 'resend' | 'mailgun' | 'sendgrid';
|
||||
|
||||
// Create the registry
|
||||
const emailRegistry = createRegistry<EmailService, EmailProvider>();
|
||||
```
|
||||
|
||||
The generic parameters ensure:
|
||||
|
||||
- All registered implementations match `EmailService`
|
||||
- Only valid provider names can be used
|
||||
- `get()` returns correctly typed implementations
|
||||
|
||||
---
|
||||
|
||||
## Registering implementations
|
||||
|
||||
Use `register()` to add implementations. Factories can be sync or async:
|
||||
|
||||
```tsx
|
||||
// Async factory with dynamic import (recommended for code splitting)
|
||||
emailRegistry.register('resend', async () => {
|
||||
const { createResendMailer } = await import('./mailers/resend');
|
||||
return createResendMailer();
|
||||
});
|
||||
|
||||
// Sync factory
|
||||
emailRegistry.register('mailgun', () => {
|
||||
return new MailgunService(process.env.MAILGUN_API_KEY!);
|
||||
});
|
||||
|
||||
// Chaining
|
||||
emailRegistry
|
||||
.register('resend', async () => createResendMailer())
|
||||
.register('mailgun', async () => createMailgunMailer())
|
||||
.register('sendgrid', async () => createSendgridMailer());
|
||||
```
|
||||
|
||||
{% callout title="Lazy loading" %}
|
||||
Factories only execute when `get()` is called. This keeps your bundle small since unused providers aren't imported.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## Resolving implementations
|
||||
|
||||
Use `get()` to resolve implementations. Always await the result:
|
||||
|
||||
```tsx
|
||||
// Single implementation
|
||||
const mailer = await emailRegistry.get('resend');
|
||||
await mailer.send('user@example.com', 'Welcome', 'Hello!');
|
||||
|
||||
// Multiple implementations (returns tuple)
|
||||
const [primary, fallback] = await emailRegistry.get('resend', 'mailgun');
|
||||
|
||||
// Dynamic resolution from environment
|
||||
const provider = process.env.EMAIL_PROVIDER as EmailProvider;
|
||||
const mailer = await emailRegistry.get(provider);
|
||||
```
|
||||
|
||||
### Creating a helper function
|
||||
|
||||
Wrap the registry in a helper for cleaner usage:
|
||||
|
||||
```tsx
|
||||
export async function getEmailService(): Promise<EmailService> {
|
||||
const provider = (process.env.EMAIL_PROVIDER ?? 'resend') as EmailProvider;
|
||||
return emailRegistry.get(provider);
|
||||
}
|
||||
|
||||
// Usage
|
||||
const mailer = await getEmailService();
|
||||
await mailer.send('user@example.com', 'Welcome', 'Hello!');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup hooks
|
||||
|
||||
Use `addSetup()` and `setup()` for initialization tasks that should run once:
|
||||
|
||||
```tsx
|
||||
// Add setup tasks
|
||||
emailRegistry.addSetup('initialize', async () => {
|
||||
console.log('Initializing email service...');
|
||||
// Verify API keys, warm up connections, etc.
|
||||
});
|
||||
|
||||
emailRegistry.addSetup('initialize', async () => {
|
||||
console.log('Loading email templates...');
|
||||
});
|
||||
|
||||
// Run all setup tasks (idempotent)
|
||||
await emailRegistry.setup('initialize');
|
||||
await emailRegistry.setup('initialize'); // No-op, already ran
|
||||
```
|
||||
|
||||
### Setup groups
|
||||
|
||||
Use different groups to control when initialization happens:
|
||||
|
||||
```tsx
|
||||
emailRegistry.addSetup('verify-credentials', async () => {
|
||||
// Quick check at startup
|
||||
});
|
||||
|
||||
emailRegistry.addSetup('warm-cache', async () => {
|
||||
// Expensive operation, run later
|
||||
});
|
||||
|
||||
// At startup
|
||||
await emailRegistry.setup('verify-credentials');
|
||||
|
||||
// Before first email
|
||||
await emailRegistry.setup('warm-cache');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### Billing provider registry
|
||||
|
||||
```tsx
|
||||
// lib/billing/registry.ts
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
interface BillingGateway {
|
||||
createCheckoutSession(params: CheckoutParams): Promise<{ url: string }>;
|
||||
createBillingPortalSession(customerId: string): Promise<{ url: string }>;
|
||||
cancelSubscription(subscriptionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
type BillingProvider = 'stripe' | 'lemon-squeezy' | 'paddle';
|
||||
|
||||
const billingRegistry = createRegistry<BillingGateway, BillingProvider>();
|
||||
|
||||
billingRegistry
|
||||
.register('stripe', async () => {
|
||||
const { createStripeGateway } = await import('./gateways/stripe');
|
||||
return createStripeGateway();
|
||||
})
|
||||
.register('lemon-squeezy', async () => {
|
||||
const { createLemonSqueezyGateway } = await import('./gateways/lemon-squeezy');
|
||||
return createLemonSqueezyGateway();
|
||||
})
|
||||
.register('paddle', async () => {
|
||||
const { createPaddleGateway } = await import('./gateways/paddle');
|
||||
return createPaddleGateway();
|
||||
});
|
||||
|
||||
export async function getBillingGateway(): Promise<BillingGateway> {
|
||||
const provider = (process.env.BILLING_PROVIDER ?? 'stripe') as BillingProvider;
|
||||
return billingRegistry.get(provider);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { getBillingGateway } from '@/lib/billing/registry';
|
||||
|
||||
export async function createCheckout(priceId: string, userId: string) {
|
||||
const billing = await getBillingGateway();
|
||||
|
||||
const session = await billing.createCheckoutSession({
|
||||
priceId,
|
||||
userId,
|
||||
successUrl: '/checkout/success',
|
||||
cancelUrl: '/pricing',
|
||||
});
|
||||
|
||||
return session.url;
|
||||
}
|
||||
```
|
||||
|
||||
### CMS client registry
|
||||
|
||||
```tsx
|
||||
// lib/cms/registry.ts
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
interface CmsClient {
|
||||
getPosts(options?: { limit?: number }): Promise<Post[]>;
|
||||
getPost(slug: string): Promise<Post | null>;
|
||||
getPages(): Promise<Page[]>;
|
||||
}
|
||||
|
||||
type CmsProvider = 'keystatic' | 'wordpress' | 'supabase';
|
||||
|
||||
const cmsRegistry = createRegistry<CmsClient, CmsProvider>();
|
||||
|
||||
cmsRegistry
|
||||
.register('keystatic', async () => {
|
||||
const { createKeystaticClient } = await import('./clients/keystatic');
|
||||
return createKeystaticClient();
|
||||
})
|
||||
.register('wordpress', async () => {
|
||||
const { createWordPressClient } = await import('./clients/wordpress');
|
||||
return createWordPressClient(process.env.WORDPRESS_URL!);
|
||||
})
|
||||
.register('supabase', async () => {
|
||||
const { createSupabaseCmsClient } = await import('./clients/supabase');
|
||||
return createSupabaseCmsClient();
|
||||
});
|
||||
|
||||
export async function getCmsClient(): Promise<CmsClient> {
|
||||
const provider = (process.env.CMS_PROVIDER ?? 'keystatic') as CmsProvider;
|
||||
return cmsRegistry.get(provider);
|
||||
}
|
||||
```
|
||||
|
||||
### Logger registry
|
||||
|
||||
```tsx
|
||||
// lib/logger/registry.ts
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
interface Logger {
|
||||
info(context: object, message: string): void;
|
||||
error(context: object, message: string): void;
|
||||
warn(context: object, message: string): void;
|
||||
debug(context: object, message: string): void;
|
||||
}
|
||||
|
||||
type LoggerProvider = 'pino' | 'console';
|
||||
|
||||
const loggerRegistry = createRegistry<Logger, LoggerProvider>();
|
||||
|
||||
loggerRegistry
|
||||
.register('pino', async () => {
|
||||
const pino = await import('pino');
|
||||
return pino.default({
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
});
|
||||
})
|
||||
.register('console', () => ({
|
||||
info: (ctx, msg) => console.log('[INFO]', msg, ctx),
|
||||
error: (ctx, msg) => console.error('[ERROR]', msg, ctx),
|
||||
warn: (ctx, msg) => console.warn('[WARN]', msg, ctx),
|
||||
debug: (ctx, msg) => console.debug('[DEBUG]', msg, ctx),
|
||||
}));
|
||||
|
||||
export async function getLogger(): Promise<Logger> {
|
||||
const provider = (process.env.LOGGER ?? 'pino') as LoggerProvider;
|
||||
return loggerRegistry.get(provider);
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with mock implementations
|
||||
|
||||
```tsx
|
||||
// __tests__/billing.test.ts
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
const mockBillingRegistry = createRegistry<BillingGateway, 'mock'>();
|
||||
|
||||
mockBillingRegistry.register('mock', () => ({
|
||||
createCheckoutSession: jest.fn().mockResolvedValue({ url: 'https://mock.checkout' }),
|
||||
createBillingPortalSession: jest.fn().mockResolvedValue({ url: 'https://mock.portal' }),
|
||||
cancelSubscription: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
test('checkout creates session', async () => {
|
||||
const billing = await mockBillingRegistry.get('mock');
|
||||
|
||||
const result = await billing.createCheckoutSession({
|
||||
priceId: 'price_123',
|
||||
userId: 'user_456',
|
||||
});
|
||||
|
||||
expect(result.url).toBe('https://mock.checkout');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best practices
|
||||
|
||||
### 1. Use environment variables for provider selection
|
||||
|
||||
```tsx
|
||||
// Good: Configuration-driven
|
||||
const provider = process.env.BILLING_PROVIDER as BillingProvider;
|
||||
const billing = await registry.get(provider);
|
||||
|
||||
// Avoid: Hardcoded providers
|
||||
const billing = await registry.get('stripe');
|
||||
```
|
||||
|
||||
### 2. Create helper functions for common access
|
||||
|
||||
```tsx
|
||||
// Good: Encapsulated helper
|
||||
export async function getBillingGateway() {
|
||||
const provider = process.env.BILLING_PROVIDER ?? 'stripe';
|
||||
return billingRegistry.get(provider as BillingProvider);
|
||||
}
|
||||
|
||||
// Usage is clean
|
||||
const billing = await getBillingGateway();
|
||||
```
|
||||
|
||||
### 3. Use dynamic imports for code splitting
|
||||
|
||||
```tsx
|
||||
// Good: Lazy loaded
|
||||
registry.register('stripe', async () => {
|
||||
const { createStripeGateway } = await import('./stripe');
|
||||
return createStripeGateway();
|
||||
});
|
||||
|
||||
// Avoid: Eager imports
|
||||
import { createStripeGateway } from './stripe';
|
||||
registry.register('stripe', () => createStripeGateway());
|
||||
```
|
||||
|
||||
### 4. Define strict interfaces
|
||||
|
||||
```tsx
|
||||
// Good: Well-defined interface
|
||||
interface BillingGateway {
|
||||
createCheckoutSession(params: CheckoutParams): Promise<CheckoutResult>;
|
||||
createBillingPortalSession(customerId: string): Promise<PortalResult>;
|
||||
}
|
||||
|
||||
// Avoid: Loose typing
|
||||
type BillingGateway = Record<string, (...args: any[]) => any>;
|
||||
```
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Payment provider setup
|
||||
- [Monitoring Configuration](/docs/next-supabase-turbo/monitoring/overview) - Logger and APM setup
|
||||
- [CMS Configuration](/docs/next-supabase-turbo/content) - Content management setup
|
||||
650
docs/api/team-account-api.mdoc
Normal file
650
docs/api/team-account-api.mdoc
Normal file
@@ -0,0 +1,650 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Team Account API"
|
||||
order: 1
|
||||
title: "Team Account API | Next.js Supabase SaaS Kit"
|
||||
description: "Complete reference for the Team Account API in MakerKit. Manage teams, members, permissions, invitations, subscriptions, and workspace data with type-safe methods."
|
||||
---
|
||||
|
||||
The Team Account API manages team accounts, members, permissions, and invitations. Use it to check user permissions, manage team subscriptions, and handle team invitations in your multi-tenant SaaS application.
|
||||
|
||||
{% sequence title="Team Account API Reference" description="Learn how to use the Team Account API in MakerKit" %}
|
||||
|
||||
[Setup and initialization](#setup-and-initialization)
|
||||
|
||||
[getTeamAccountById](#getteamaccountbyid)
|
||||
|
||||
[getAccountWorkspace](#getaccountworkspace)
|
||||
|
||||
[getSubscription](#getsubscription)
|
||||
|
||||
[getOrder](#getorder)
|
||||
|
||||
[hasPermission](#haspermission)
|
||||
|
||||
[getMembersCount](#getmemberscount)
|
||||
|
||||
[getCustomerId](#getcustomerid)
|
||||
|
||||
[getInvitation](#getinvitation)
|
||||
|
||||
[Real-world examples](#real-world-examples)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## Setup and initialization
|
||||
|
||||
Import `createTeamAccountsApi` from `@kit/team-accounts/api` and pass a Supabase server client.
|
||||
|
||||
```tsx
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function ServerComponent() {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Use API methods
|
||||
}
|
||||
```
|
||||
|
||||
In Server Actions:
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function myServerAction() {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Use API methods
|
||||
}
|
||||
```
|
||||
|
||||
{% callout title="Request-scoped clients" %}
|
||||
Always create the Supabase client and API instance inside your request handler, not at module scope. The client is tied to the current user's session and RLS policies.
|
||||
{% /callout %}
|
||||
|
||||
## API Methods
|
||||
|
||||
### getTeamAccountById
|
||||
|
||||
Retrieves a team account by its UUID. Also verifies the current user has access to the team.
|
||||
|
||||
```tsx
|
||||
const account = await api.getTeamAccountById(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The team account UUID |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
picture_url: string | null;
|
||||
public_data: Json | null;
|
||||
primary_owner_user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
} | null
|
||||
```
|
||||
|
||||
**Usage notes:**
|
||||
|
||||
- Returns `null` if the account doesn't exist or user lacks access
|
||||
- RLS policies ensure users only see teams they belong to
|
||||
- Use this to verify team membership before operations
|
||||
|
||||
---
|
||||
|
||||
### getAccountWorkspace
|
||||
|
||||
Returns the team workspace data for a given team slug. This is the primary method for loading team context in layouts.
|
||||
|
||||
```tsx
|
||||
const workspace = await api.getAccountWorkspace(slug);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `slug` | `string` | The team URL slug |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
picture_url: string | null;
|
||||
role: string;
|
||||
role_hierarchy_level: number;
|
||||
primary_owner_user_id: string;
|
||||
subscription_status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused' | null;
|
||||
permissions: string[];
|
||||
};
|
||||
accounts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
picture_url: string | null;
|
||||
role: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage notes:**
|
||||
|
||||
- Called automatically in the `/home/[account]` layout
|
||||
- The `permissions` array contains all permissions for the current user in this team
|
||||
- Use `role_hierarchy_level` for role-based comparisons (lower = more permissions)
|
||||
|
||||
---
|
||||
|
||||
### getSubscription
|
||||
|
||||
Returns the subscription data for a team account, including all line items.
|
||||
|
||||
```tsx
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The team account UUID |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: string;
|
||||
account_id: string;
|
||||
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
|
||||
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused';
|
||||
currency: string;
|
||||
cancel_at_period_end: boolean;
|
||||
period_starts_at: string;
|
||||
period_ends_at: string;
|
||||
trial_starts_at: string | null;
|
||||
trial_ends_at: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
subscription_id: string;
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
type: 'flat' | 'per_seat' | 'metered';
|
||||
quantity: number;
|
||||
price_amount: number;
|
||||
interval: 'month' | 'year';
|
||||
interval_count: number;
|
||||
}>;
|
||||
} | null
|
||||
```
|
||||
|
||||
**Example: Check per-seat limits**
|
||||
|
||||
```tsx
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function canAddTeamMember(accountId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
const [subscription, membersCount] = await Promise.all([
|
||||
api.getSubscription(accountId),
|
||||
api.getMembersCount(accountId),
|
||||
]);
|
||||
|
||||
if (!subscription) {
|
||||
// Free tier: allow up to 3 members
|
||||
return membersCount < 3;
|
||||
}
|
||||
|
||||
const perSeatItem = subscription.items.find((item) => item.type === 'per_seat');
|
||||
|
||||
if (perSeatItem) {
|
||||
return membersCount < perSeatItem.quantity;
|
||||
}
|
||||
|
||||
// Flat-rate plan: no seat limit
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### getOrder
|
||||
|
||||
Returns one-time purchase order data for team accounts using lifetime deals.
|
||||
|
||||
```tsx
|
||||
const order = await api.getOrder(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The team account UUID |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: string;
|
||||
account_id: string;
|
||||
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
|
||||
status: 'pending' | 'completed' | 'refunded';
|
||||
currency: string;
|
||||
total_amount: number;
|
||||
items: Array<{
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
quantity: number;
|
||||
price_amount: number;
|
||||
}>;
|
||||
} | null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### hasPermission
|
||||
|
||||
Checks if a user has a specific permission within a team account. Use this for fine-grained authorization checks.
|
||||
|
||||
```tsx
|
||||
const canManage = await api.hasPermission({
|
||||
accountId: 'team-uuid',
|
||||
userId: 'user-uuid',
|
||||
permission: 'billing.manage',
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The team account UUID |
|
||||
| `userId` | `string` | The user UUID to check |
|
||||
| `permission` | `string` | The permission identifier |
|
||||
|
||||
**Returns:** `boolean`
|
||||
|
||||
**Built-in permissions:**
|
||||
|
||||
| Permission | Description |
|
||||
|------------|-------------|
|
||||
| `billing.manage` | Manage subscription and payment methods |
|
||||
| `members.invite` | Invite new team members |
|
||||
| `members.remove` | Remove team members |
|
||||
| `members.manage` | Update member roles |
|
||||
| `settings.manage` | Update team settings |
|
||||
|
||||
**Example: Permission-gated Server Action**
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const UpdateTeamSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(2).max(50),
|
||||
});
|
||||
|
||||
export const updateTeamSettings = authActionClient
|
||||
.inputSchema(UpdateTeamSchema)
|
||||
.action(async ({ parsedInput: data, ctx: { user } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
const canManage = await api.hasPermission({
|
||||
accountId: data.accountId,
|
||||
userId: user.id,
|
||||
permission: 'settings.manage',
|
||||
});
|
||||
|
||||
if (!canManage) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'You do not have permission to update team settings',
|
||||
};
|
||||
}
|
||||
|
||||
// Update team...
|
||||
return { success: true };
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### getMembersCount
|
||||
|
||||
Returns the total number of members in a team account.
|
||||
|
||||
```tsx
|
||||
const count = await api.getMembersCount(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The team account UUID |
|
||||
|
||||
**Returns:** `number | null`
|
||||
|
||||
**Example: Display team size**
|
||||
|
||||
```tsx
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function TeamStats({ accountId }: { accountId: string }) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
const membersCount = await api.getMembersCount(accountId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium">{membersCount}</span>
|
||||
<span className="text-muted-foreground"> team members</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### getCustomerId
|
||||
|
||||
Returns the billing provider customer ID for a team account.
|
||||
|
||||
```tsx
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `accountId` | `string` | The team account UUID |
|
||||
|
||||
**Returns:** `string | null`
|
||||
|
||||
---
|
||||
|
||||
### getInvitation
|
||||
|
||||
Retrieves invitation data from an invite token. Requires an admin client to bypass RLS for pending invitations.
|
||||
|
||||
```tsx
|
||||
const invitation = await api.getInvitation(adminClient, token);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `adminClient` | `SupabaseClient` | Admin client (bypasses RLS) |
|
||||
| `token` | `string` | The invitation token |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: number;
|
||||
email: string;
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
role: string;
|
||||
expires_at: string;
|
||||
} | null
|
||||
```
|
||||
|
||||
**Example: Accept invitation flow**
|
||||
|
||||
```tsx
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function getInvitationDetails(token: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
const invitation = await api.getInvitation(adminClient, token);
|
||||
|
||||
if (!invitation) {
|
||||
return { error: 'Invalid or expired invitation' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(invitation.expires_at);
|
||||
|
||||
if (now > expiresAt) {
|
||||
return { error: 'This invitation has expired' };
|
||||
}
|
||||
|
||||
return {
|
||||
teamName: invitation.account.name,
|
||||
role: invitation.role,
|
||||
email: invitation.email,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
{% callout type="warning" title="Admin client security" %}
|
||||
The admin client bypasses Row Level Security. Only use it for operations that require elevated privileges, and always validate authorization separately.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### Complete team management Server Actions
|
||||
|
||||
```tsx
|
||||
// lib/server/team-actions.ts
|
||||
'use server';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const InviteMemberSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member']),
|
||||
});
|
||||
|
||||
const RemoveMemberSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const inviteMember = authActionClient
|
||||
.inputSchema(InviteMemberSchema)
|
||||
.action(async ({ parsedInput: data, ctx: { user } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Check permission
|
||||
const canInvite = await api.hasPermission({
|
||||
accountId: data.accountId,
|
||||
userId: user.id,
|
||||
permission: 'members.invite',
|
||||
});
|
||||
|
||||
if (!canInvite) {
|
||||
return { success: false, error: 'Permission denied' };
|
||||
}
|
||||
|
||||
// Check seat limits
|
||||
const [subscription, membersCount] = await Promise.all([
|
||||
api.getSubscription(data.accountId),
|
||||
api.getMembersCount(data.accountId),
|
||||
]);
|
||||
|
||||
if (subscription) {
|
||||
const perSeatItem = subscription.items.find((i) => i.type === 'per_seat');
|
||||
if (perSeatItem && membersCount >= perSeatItem.quantity) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Team has reached maximum seats. Please upgrade your plan.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create invitation...
|
||||
const { error } = await client.from('invitations').insert({
|
||||
account_id: data.accountId,
|
||||
email: data.email,
|
||||
role: data.role,
|
||||
invited_by: user.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: 'Failed to send invitation' };
|
||||
}
|
||||
|
||||
revalidatePath(`/home/[account]/settings/members`, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const removeMember = authActionClient
|
||||
.inputSchema(RemoveMemberSchema)
|
||||
.action(async ({ parsedInput: data, ctx: { user } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Cannot remove yourself
|
||||
if (data.userId === user.id) {
|
||||
return { success: false, error: 'You cannot remove yourself' };
|
||||
}
|
||||
|
||||
// Check permission
|
||||
const canRemove = await api.hasPermission({
|
||||
accountId: data.accountId,
|
||||
userId: user.id,
|
||||
permission: 'members.remove',
|
||||
});
|
||||
|
||||
if (!canRemove) {
|
||||
return { success: false, error: 'Permission denied' };
|
||||
}
|
||||
|
||||
// Check if target is owner
|
||||
const account = await api.getTeamAccountById(data.accountId);
|
||||
if (account?.primary_owner_user_id === data.userId) {
|
||||
return { success: false, error: 'Cannot remove the team owner' };
|
||||
}
|
||||
|
||||
// Remove member...
|
||||
const { error } = await client
|
||||
.from('accounts_memberships')
|
||||
.delete()
|
||||
.eq('account_id', data.accountId)
|
||||
.eq('user_id', data.userId);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: 'Failed to remove member' };
|
||||
}
|
||||
|
||||
revalidatePath(`/home/[account]/settings/members`, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
```
|
||||
|
||||
### Permission-based UI rendering
|
||||
|
||||
```tsx
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
async function TeamSettingsPage({ params }: { params: { account: string } }) {
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
if (auth.error) {
|
||||
redirect(auth.redirectTo);
|
||||
}
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
const workspace = await api.getAccountWorkspace(params.account);
|
||||
|
||||
const permissions = {
|
||||
canManageSettings: workspace.account.permissions.includes('settings.manage'),
|
||||
canManageBilling: workspace.account.permissions.includes('billing.manage'),
|
||||
canInviteMembers: workspace.account.permissions.includes('members.invite'),
|
||||
canRemoveMembers: workspace.account.permissions.includes('members.remove'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Team Settings</h1>
|
||||
|
||||
{permissions.canManageSettings && (
|
||||
<section>
|
||||
<h2>General Settings</h2>
|
||||
{/* Settings form */}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{permissions.canManageBilling && (
|
||||
<section>
|
||||
<h2>Billing</h2>
|
||||
{/* Billing management */}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{permissions.canInviteMembers && (
|
||||
<section>
|
||||
<h2>Invite Members</h2>
|
||||
{/* Invitation form */}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!permissions.canManageSettings &&
|
||||
!permissions.canManageBilling &&
|
||||
!permissions.canInviteMembers && (
|
||||
<p>You don't have permission to manage this team.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Account API](/docs/next-supabase-turbo/api/account-api) - Personal account management
|
||||
- [Team Workspace API](/docs/next-supabase-turbo/api/account-workspace-api) - Workspace context for layouts
|
||||
- [Policies API](/docs/next-supabase-turbo/api/policies-api) - Business rule validation
|
||||
- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
|
||||
358
docs/api/user-workspace-api.mdoc
Normal file
358
docs/api/user-workspace-api.mdoc
Normal file
@@ -0,0 +1,358 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "User Workspace API"
|
||||
order: 3
|
||||
title: "User Workspace API | Next.js Supabase SaaS Kit"
|
||||
description: "Access personal workspace data in MakerKit layouts. Load user account information, subscription status, and account switcher data with the User Workspace API."
|
||||
---
|
||||
|
||||
The User Workspace API provides personal account context for pages under `/home/(user)`. It loads user data, subscription status, and all accounts the user belongs to, making this information available to both server and client components.
|
||||
|
||||
{% sequence title="User Workspace API Reference" description="Access personal workspace data in layouts and components" %}
|
||||
|
||||
[loadUserWorkspace (Server)](#loaduserworkspace-server)
|
||||
|
||||
[useUserWorkspace (Client)](#useuserworkspace-client)
|
||||
|
||||
[Data structure](#data-structure)
|
||||
|
||||
[Usage patterns](#usage-patterns)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## loadUserWorkspace (Server)
|
||||
|
||||
Loads the personal workspace data for the authenticated user. Use this in Server Components within the `/home/(user)` route group.
|
||||
|
||||
```tsx
|
||||
import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace';
|
||||
|
||||
export default async function PersonalDashboard() {
|
||||
const data = await loadUserWorkspace();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome, {data.user.email}</h1>
|
||||
<p>Account: {data.workspace.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Function signature
|
||||
|
||||
```tsx
|
||||
async function loadUserWorkspace(): Promise<UserWorkspaceData>
|
||||
```
|
||||
|
||||
### Caching behavior
|
||||
|
||||
The function uses React's `cache()` to deduplicate calls within a single request. You can call it multiple times in nested components without additional database queries.
|
||||
|
||||
```tsx
|
||||
// Both calls use the same cached data
|
||||
const layout = await loadUserWorkspace(); // First call: hits database
|
||||
const page = await loadUserWorkspace(); // Second call: returns cached data
|
||||
```
|
||||
|
||||
{% callout title="Performance consideration" %}
|
||||
While calls are deduplicated within a request, the data is fetched on every navigation. If you only need a subset of the data (like subscription status), consider making a more targeted query.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## useUserWorkspace (Client)
|
||||
|
||||
Access the workspace data in client components using the `useUserWorkspace` hook. The data is provided through React Context from the layout.
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
export function ProfileCard() {
|
||||
const { workspace, user, accounts } = useUserWorkspace();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{workspace.picture_url && (
|
||||
<img
|
||||
src={workspace.picture_url}
|
||||
alt={workspace.name ?? 'Profile'}
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{workspace.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workspace.subscription_status && (
|
||||
<div className="mt-3">
|
||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Plan: {workspace.subscription_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
{% callout type="warning" title="Context requirement" %}
|
||||
The `useUserWorkspace` hook only works within the `/home/(user)` route group where the context provider is set up. Using it outside this layout will throw an error.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## Data structure
|
||||
|
||||
### UserWorkspaceData
|
||||
|
||||
```tsx
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
interface UserWorkspaceData {
|
||||
workspace: {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
picture_url: string | null;
|
||||
public_data: Json | null;
|
||||
subscription_status: SubscriptionStatus | null;
|
||||
};
|
||||
|
||||
user: User;
|
||||
|
||||
accounts: Array<{
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
picture_url: string | null;
|
||||
role: string | null;
|
||||
slug: string | null;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### subscription_status values
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `active` | Active subscription |
|
||||
| `trialing` | In trial period |
|
||||
| `past_due` | Payment failed, grace period |
|
||||
| `canceled` | Subscription canceled |
|
||||
| `unpaid` | Payment required |
|
||||
| `incomplete` | Setup incomplete |
|
||||
| `incomplete_expired` | Setup expired |
|
||||
| `paused` | Subscription paused |
|
||||
|
||||
### accounts array
|
||||
|
||||
The `accounts` array contains all accounts the user belongs to, including:
|
||||
|
||||
- Their personal account
|
||||
- Team accounts where they're a member
|
||||
- The user's role in each account
|
||||
|
||||
This data powers the account switcher component.
|
||||
|
||||
---
|
||||
|
||||
## Usage patterns
|
||||
|
||||
### Personal dashboard page
|
||||
|
||||
```tsx
|
||||
import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const { workspace, user, accounts } = await loadUserWorkspace();
|
||||
|
||||
const hasActiveSubscription =
|
||||
workspace.subscription_status === 'active' ||
|
||||
workspace.subscription_status === 'trialing';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome back, {user.user_metadata.full_name || user.email}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{!hasActiveSubscription && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<p>Upgrade to unlock premium features</p>
|
||||
<a href="/pricing" className="text-primary underline">
|
||||
View plans
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-medium">Your teams</h2>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{accounts
|
||||
.filter((a) => a.slug !== null)
|
||||
.map((account) => (
|
||||
<li key={account.id}>
|
||||
<a
|
||||
href={`/home/${account.slug}`}
|
||||
className="flex items-center gap-2 rounded-lg p-2 hover:bg-muted"
|
||||
>
|
||||
{account.picture_url && (
|
||||
<img
|
||||
src={account.picture_url}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded"
|
||||
/>
|
||||
)}
|
||||
<span>{account.name}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{account.role}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Account switcher component
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
|
||||
export function AccountSwitcher() {
|
||||
const { workspace, accounts } = useUserWorkspace();
|
||||
const router = useRouter();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (value === 'personal') {
|
||||
router.push('/home');
|
||||
} else {
|
||||
router.push(`/home/${value}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
defaultValue={workspace.id ?? 'personal'}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="personal">
|
||||
Personal Account
|
||||
</SelectItem>
|
||||
{accounts
|
||||
.filter((a) => a.slug)
|
||||
.map((account) => (
|
||||
<SelectItem key={account.id} value={account.slug!}>
|
||||
{account.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Feature gating with subscription status
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
interface FeatureGateProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
requiredStatus?: string[];
|
||||
}
|
||||
|
||||
export function FeatureGate({
|
||||
children,
|
||||
fallback,
|
||||
requiredStatus = ['active', 'trialing'],
|
||||
}: FeatureGateProps) {
|
||||
const { workspace } = useUserWorkspace();
|
||||
|
||||
const hasAccess = requiredStatus.includes(
|
||||
workspace.subscription_status ?? ''
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
function PremiumFeature() {
|
||||
return (
|
||||
<FeatureGate
|
||||
fallback={
|
||||
<div className="text-center p-4">
|
||||
<p>This feature requires a paid plan</p>
|
||||
<a href="/pricing">Upgrade now</a>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ExpensiveComponent />
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Combining with server data
|
||||
|
||||
```tsx
|
||||
import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export default async function TasksPage() {
|
||||
const { workspace, user } = await loadUserWorkspace();
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Fetch additional data using the workspace context
|
||||
const { data: tasks } = await client
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('account_id', workspace.id)
|
||||
.eq('created_by', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>My Tasks</h1>
|
||||
<TaskList tasks={tasks ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Team Workspace API](/docs/next-supabase-turbo/api/account-workspace-api) - Team account context
|
||||
- [Account API](/docs/next-supabase-turbo/api/account-api) - Account operations
|
||||
- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication
|
||||
Reference in New Issue
Block a user