Changelog (#399)
* feat: add changelog feature and update site navigation - Introduced a new Changelog page with pagination and detailed entry views. - Added components for displaying changelog entries, pagination, and entry details. - Updated site navigation to include a link to the new Changelog page. - Enhanced localization for changelog-related texts in marketing.json. * refactor: enhance Changelog page layout and entry display - Increased the number of changelog entries displayed per page from 2 to 20 for better visibility. - Improved the layout of the Changelog page by adjusting the container styles and removing unnecessary divs. - Updated the ChangelogEntry component to enhance the visual presentation of entries, including a new date badge with an icon. - Refined the CSS styles for Markdoc headings to improve typography and spacing. * refactor: enhance Changelog page functionality and layout - Increased the number of changelog entries displayed per page from 20 to 50 for improved user experience. - Updated ChangelogEntry component to make the highlight prop optional and refined the layout for better visual clarity. - Adjusted styles in ChangelogHeader and ChangelogPagination components for a more cohesive design. - Removed unnecessary order fields from changelog markdown files to streamline content management. * feat: enhance Changelog entry navigation and data loading - Refactored ChangelogEntry page to load previous and next entries for improved navigation. - Introduced ChangelogNavigation component to facilitate navigation between changelog entries. - Updated ChangelogDetail component to display navigation links and entry details. - Enhanced data fetching logic to retrieve all changelog entries alongside the current entry. - Added localization keys for navigation text in marketing.json. * Update package dependencies and enhance documentation layout - Upgraded various packages including @turbo/gen and turbo to version 2.6.0, and react-hook-form to version 7.66.0. - Updated lucide-react to version 0.552.0 across multiple packages. - Refactored documentation layout components for improved styling and structure. - Removed deprecated loading components and adjusted navigation elements for better user experience. - Added placeholder notes in changelog entries for clarity.
This commit is contained in:
committed by
GitHub
parent
a920dea2b3
commit
116d41a284
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Authentication"
|
||||
title: "Authentication Overview"
|
||||
description: "Learn how to set up authentication in your MakerKit application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 1
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
---
|
||||
title: "Email & Password"
|
||||
description: "Traditional email and password authentication."
|
||||
publishedAt: 2024-04-11
|
||||
order: 2
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Email and password authentication is the traditional way users sign up and sign in.
|
||||
|
||||
## Overview
|
||||
|
||||
Email/password authentication provides:
|
||||
- User registration with email verification
|
||||
- Secure password storage
|
||||
- Password reset functionality
|
||||
- Session management
|
||||
|
||||
## Sign Up Flow
|
||||
|
||||
### User Registration
|
||||
|
||||
```typescript
|
||||
import { signUpAction } from '~/lib/auth/actions';
|
||||
|
||||
const result = await signUpAction({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
```
|
||||
|
||||
### Server Action Implementation
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { z } from 'zod';
|
||||
|
||||
const SignUpSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const signUpAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: authData, error } = await client.auth.signUp({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
options: {
|
||||
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return { success: true, data: authData };
|
||||
},
|
||||
{ schema: SignUpSchema }
|
||||
);
|
||||
```
|
||||
|
||||
### Sign Up Component
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { signUpAction } from '../_lib/actions';
|
||||
|
||||
export function SignUpForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm();
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await signUpAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Check your email to confirm your account');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email', { required: true })}
|
||||
/>
|
||||
{errors.email && <span>Email is required</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password', { required: true, minLength: 8 })}
|
||||
/>
|
||||
{errors.password && <span>Password must be 8+ characters</span>}
|
||||
</div>
|
||||
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Sign In Flow
|
||||
|
||||
### User Login
|
||||
|
||||
```typescript
|
||||
export const signInAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client.auth.signInWithPassword({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
redirect('/home');
|
||||
},
|
||||
{ schema: SignInSchema }
|
||||
);
|
||||
```
|
||||
|
||||
### Sign In Component
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
export function SignInForm() {
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
try {
|
||||
await signInAction(data);
|
||||
} catch (error) {
|
||||
toast.error('Invalid email or password');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password')}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Email Verification
|
||||
|
||||
### Requiring Email Confirmation
|
||||
|
||||
Configure in Supabase dashboard or config:
|
||||
|
||||
```typescript
|
||||
// config/auth.config.ts
|
||||
export const authConfig = {
|
||||
requireEmailConfirmation: true,
|
||||
};
|
||||
```
|
||||
|
||||
### Handling Unconfirmed Emails
|
||||
|
||||
```typescript
|
||||
export const signInAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: authData, error } = await client.auth.signInWithPassword({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.message.includes('Email not confirmed')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Please confirm your email before signing in',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
redirect('/home');
|
||||
},
|
||||
{ schema: SignInSchema }
|
||||
);
|
||||
```
|
||||
|
||||
## Password Reset
|
||||
|
||||
### Request Password Reset
|
||||
|
||||
```typescript
|
||||
export const requestPasswordResetAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client.auth.resetPasswordForEmail(data.email, {
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Check your email for reset instructions',
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
email: z.string().email(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Reset Password Form
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
export function PasswordResetRequestForm() {
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await requestPasswordResetAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<button type="submit">Send Reset Link</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Update Password
|
||||
|
||||
```typescript
|
||||
export const updatePasswordAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client.auth.updateUser({
|
||||
password: data.newPassword,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
redirect('/home');
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
newPassword: z.string().min(8),
|
||||
}),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Password Requirements
|
||||
|
||||
### Validation Schema
|
||||
|
||||
```typescript
|
||||
const PasswordSchema = z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain a special character');
|
||||
```
|
||||
|
||||
### Password Strength Indicator
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function PasswordInput() {
|
||||
const [password, setPassword] = useState('');
|
||||
const strength = calculatePasswordStrength(password);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={cn(
|
||||
'h-1 flex-1 rounded',
|
||||
strength >= level ? 'bg-green-500' : 'bg-gray-200'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{strength === 4 && 'Strong password'}
|
||||
{strength === 3 && 'Good password'}
|
||||
{strength === 2 && 'Fair password'}
|
||||
{strength === 1 && 'Weak password'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Checking Authentication Status
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function requireAuth() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/sign-in');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### Sign Out
|
||||
|
||||
```typescript
|
||||
export const signOutAction = enhanceAction(
|
||||
async () => {
|
||||
const client = getSupabaseServerClient();
|
||||
await client.auth.signOut();
|
||||
redirect('/auth/sign-in');
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Enforce strong passwords** - Minimum 8 characters, mixed case, numbers, symbols
|
||||
2. **Rate limit login attempts** - Prevent brute force attacks
|
||||
3. **Use HTTPS only** - Encrypt data in transit
|
||||
4. **Enable email verification** - Confirm email ownership
|
||||
5. **Implement account lockout** - After failed attempts
|
||||
6. **Log authentication events** - Track sign-ins and failures
|
||||
7. **Support 2FA** - Add extra security layer
|
||||
392
apps/web/content/documentation/authentication/magic-links.mdoc
Normal file
392
apps/web/content/documentation/authentication/magic-links.mdoc
Normal file
@@ -0,0 +1,392 @@
|
||||
---
|
||||
title: "Magic Links"
|
||||
description: "Passwordless authentication with email magic links."
|
||||
publishedAt: 2024-04-11
|
||||
order: 4
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Magic links provide passwordless authentication by sending a one-time link to the user's email.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User enters their email address
|
||||
2. System sends an email with a unique link
|
||||
3. User clicks the link in their email
|
||||
4. User is automatically signed in
|
||||
|
||||
## Benefits
|
||||
|
||||
- **No password to remember** - Better UX
|
||||
- **More secure** - No password to steal
|
||||
- **Lower friction** - Faster sign-up process
|
||||
- **Email verification** - Confirms email ownership
|
||||
|
||||
## Implementation
|
||||
|
||||
### Magic Link Form
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { sendMagicLinkAction } from '../_lib/actions';
|
||||
|
||||
export function MagicLinkForm() {
|
||||
const { register, handleSubmit, formState: { isSubmitting } } = useForm();
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await sendMagicLinkAction(data);
|
||||
|
||||
if (result.success) {
|
||||
setSent(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h2>Check your email</h2>
|
||||
<p>We've sent you a magic link to sign in.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label>Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email', { required: true })}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Sending...' : 'Send magic link'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const sendMagicLinkAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||
|
||||
const { error } = await client.auth.signInWithOtp({
|
||||
email: data.email,
|
||||
options: {
|
||||
emailRedirectTo: `${origin}/auth/callback`,
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Check your email for the magic link',
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
email: z.string().email(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable in Supabase
|
||||
|
||||
1. Go to **Authentication** → **Providers** → **Email**
|
||||
2. Enable "Enable Email Provider"
|
||||
3. Enable "Enable Email Confirmations"
|
||||
|
||||
### Configure Email Template
|
||||
|
||||
Customize the magic link email in Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** → **Email Templates**
|
||||
2. Select "Magic Link"
|
||||
3. Customize the template:
|
||||
|
||||
```html
|
||||
<h2>Sign in to {{ .SiteURL }}</h2>
|
||||
<p>Click the link below to sign in:</p>
|
||||
<p><a href="{{ .ConfirmationURL }}">Sign in</a></p>
|
||||
<p>This link expires in {{ .TokenExpiryHours }} hours.</p>
|
||||
```
|
||||
|
||||
## Callback Handler
|
||||
|
||||
Handle the magic link callback:
|
||||
|
||||
```typescript
|
||||
// app/auth/callback/route.ts
|
||||
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const token_hash = requestUrl.searchParams.get('token_hash');
|
||||
const type = requestUrl.searchParams.get('type');
|
||||
|
||||
if (token_hash && type === 'magiclink') {
|
||||
const cookieStore = cookies();
|
||||
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
|
||||
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
token_hash,
|
||||
type: 'magiclink',
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
return NextResponse.redirect(new URL('/home', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
// Return error if verification failed
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/sign-in?error=invalid_link', request.url)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Redirect
|
||||
|
||||
Specify where users go after clicking the link:
|
||||
|
||||
```typescript
|
||||
await client.auth.signInWithOtp({
|
||||
email: data.email,
|
||||
options: {
|
||||
emailRedirectTo: `${origin}/onboarding`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Disable Auto Sign-Up
|
||||
|
||||
Require users to sign up first:
|
||||
|
||||
```typescript
|
||||
await client.auth.signInWithOtp({
|
||||
email: data.email,
|
||||
options: {
|
||||
shouldCreateUser: false, // Don't create new users
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Token Expiry
|
||||
|
||||
Configure link expiration (default: 1 hour):
|
||||
|
||||
```sql
|
||||
-- In Supabase SQL Editor
|
||||
ALTER TABLE auth.users
|
||||
SET default_token_lifetime = '15 minutes';
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Prevent abuse by rate limiting magic link requests:
|
||||
|
||||
```typescript
|
||||
import { ratelimit } from '~/lib/rate-limit';
|
||||
|
||||
export const sendMagicLinkAction = enhanceAction(
|
||||
async (data, user, request) => {
|
||||
// Rate limit by IP
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown';
|
||||
const { success } = await ratelimit.limit(ip);
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Too many requests. Please try again later.');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
await client.auth.signInWithOtp({
|
||||
email: data.email,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{ schema: EmailSchema }
|
||||
);
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Link Expiration
|
||||
|
||||
Magic links should expire quickly:
|
||||
- Default: 1 hour
|
||||
- Recommended: 15-30 minutes for production
|
||||
- Shorter for sensitive actions
|
||||
|
||||
### One-Time Use
|
||||
|
||||
Links should be invalidated after use:
|
||||
|
||||
```typescript
|
||||
// Supabase handles this automatically
|
||||
// Each link can only be used once
|
||||
```
|
||||
|
||||
### Email Verification
|
||||
|
||||
Ensure emails are verified:
|
||||
|
||||
```typescript
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
|
||||
if (!user.email_confirmed_at) {
|
||||
redirect('/verify-email');
|
||||
}
|
||||
```
|
||||
|
||||
## User Experience
|
||||
|
||||
### Loading State
|
||||
|
||||
Show feedback while sending:
|
||||
|
||||
```tsx
|
||||
export function MagicLinkForm() {
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
setStatus('sending');
|
||||
await sendMagicLinkAction(data);
|
||||
setStatus('sent');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{status === 'idle' && <EmailForm onSubmit={onSubmit} />}
|
||||
{status === 'sending' && <SendingMessage />}
|
||||
{status === 'sent' && <CheckEmailMessage />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Resend Link
|
||||
|
||||
Allow users to request a new link:
|
||||
|
||||
```tsx
|
||||
export function ResendMagicLink({ email }: { email: string }) {
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setCanResend(true);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleResend = async () => {
|
||||
await sendMagicLinkAction({ email });
|
||||
setCountdown(60);
|
||||
setCanResend(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleResend} disabled={!canResend}>
|
||||
{canResend ? 'Resend link' : `Resend in ${countdown}s`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Email Deliverability
|
||||
|
||||
### SPF, DKIM, DMARC
|
||||
|
||||
Configure email authentication:
|
||||
1. Add SPF record to DNS
|
||||
2. Enable DKIM signing
|
||||
3. Set up DMARC policy
|
||||
|
||||
### Custom Email Domain
|
||||
|
||||
Use your own domain for better deliverability:
|
||||
|
||||
1. Go to **Project Settings** → **Auth**
|
||||
2. Configure custom SMTP
|
||||
3. Verify domain ownership
|
||||
|
||||
### Monitor Bounces
|
||||
|
||||
Track email delivery issues:
|
||||
|
||||
```typescript
|
||||
// Handle email bounces
|
||||
export async function handleEmailBounce(email: string) {
|
||||
await client.from('email_bounces').insert({
|
||||
email,
|
||||
bounced_at: new Date(),
|
||||
});
|
||||
|
||||
// Notify user via other channel
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Development
|
||||
|
||||
In development, emails go to InBucket:
|
||||
|
||||
```
|
||||
http://localhost:54324
|
||||
```
|
||||
|
||||
Check this URL to see magic link emails during testing.
|
||||
|
||||
### Test Mode
|
||||
|
||||
Create a test link without sending email:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Magic link URL:', confirmationUrl);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Clear communication** - Tell users to check spam
|
||||
2. **Short expiry** - 15-30 minutes for security
|
||||
3. **Rate limiting** - Prevent abuse
|
||||
4. **Fallback option** - Offer password auth as backup
|
||||
5. **Custom domain** - Better deliverability
|
||||
6. **Monitor delivery** - Track bounces and failures
|
||||
7. **Resend option** - Let users request new link
|
||||
8. **Mobile-friendly** - Ensure links work on mobile
|
||||
@@ -0,0 +1,395 @@
|
||||
---
|
||||
title: "OAuth"
|
||||
description: "Sign in with Google, GitHub, and other OAuth providers."
|
||||
publishedAt: 2024-04-11
|
||||
order: 3
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Allow users to sign in with their existing accounts from Google, GitHub, and other providers.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
Supabase supports many OAuth providers:
|
||||
- Google
|
||||
- GitHub
|
||||
- GitLab
|
||||
- Bitbucket
|
||||
- Azure
|
||||
- Facebook
|
||||
- Twitter
|
||||
- Discord
|
||||
- Slack
|
||||
- And more...
|
||||
|
||||
## Setting Up OAuth
|
||||
|
||||
### Configure in Supabase Dashboard
|
||||
|
||||
1. Go to **Authentication** → **Providers**
|
||||
2. Enable your desired provider (e.g., Google)
|
||||
3. Add your OAuth credentials:
|
||||
- **Client ID**
|
||||
- **Client Secret**
|
||||
- **Redirect URL**: `https://your-project.supabase.co/auth/v1/callback`
|
||||
|
||||
### Google OAuth Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Create a new project or select existing
|
||||
3. Enable Google+ API
|
||||
4. Create OAuth 2.0 credentials
|
||||
5. Add authorized redirect URIs:
|
||||
- Production: `https://your-project.supabase.co/auth/v1/callback`
|
||||
- Development: `http://localhost:54321/auth/v1/callback`
|
||||
|
||||
### GitHub OAuth Setup
|
||||
|
||||
1. Go to GitHub Settings → Developer Settings → OAuth Apps
|
||||
2. Click "New OAuth App"
|
||||
3. Fill in details:
|
||||
- **Application name**: Your App
|
||||
- **Homepage URL**: `https://yourapp.com`
|
||||
- **Authorization callback URL**: `https://your-project.supabase.co/auth/v1/callback`
|
||||
4. Copy Client ID and Client Secret to Supabase
|
||||
|
||||
## Implementation
|
||||
|
||||
### OAuth Sign In Button
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { signInWithOAuthAction } from '../_lib/actions';
|
||||
|
||||
export function OAuthButtons() {
|
||||
const handleGoogleSignIn = async () => {
|
||||
await signInWithOAuthAction('google');
|
||||
};
|
||||
|
||||
const handleGitHubSignIn = async () => {
|
||||
await signInWithOAuthAction('github');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={handleGoogleSignIn}
|
||||
className="w-full flex items-center justify-center gap-2 border rounded-lg p-2"
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleGitHubSignIn}
|
||||
className="w-full flex items-center justify-center gap-2 border rounded-lg p-2"
|
||||
>
|
||||
<GitHubIcon />
|
||||
Continue with GitHub
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { z } from 'zod';
|
||||
|
||||
const OAuthProviderSchema = z.enum([
|
||||
'google',
|
||||
'github',
|
||||
'gitlab',
|
||||
'azure',
|
||||
'facebook',
|
||||
]);
|
||||
|
||||
export const signInWithOAuthAction = enhanceAction(
|
||||
async (provider) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||
|
||||
const { data, error } = await client.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo: `${origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Redirect to OAuth provider
|
||||
redirect(data.url);
|
||||
},
|
||||
{
|
||||
schema: OAuthProviderSchema,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### OAuth Callback Handler
|
||||
|
||||
```typescript
|
||||
// app/auth/callback/route.ts
|
||||
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const code = requestUrl.searchParams.get('code');
|
||||
|
||||
if (code) {
|
||||
const cookieStore = cookies();
|
||||
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
|
||||
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
|
||||
// Redirect to home page
|
||||
return NextResponse.redirect(new URL('/home', request.url));
|
||||
}
|
||||
```
|
||||
|
||||
## Customizing OAuth Flow
|
||||
|
||||
### Scopes
|
||||
|
||||
Request specific permissions:
|
||||
|
||||
```typescript
|
||||
await client.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
scopes: 'email profile https://www.googleapis.com/auth/calendar',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
Pass custom parameters:
|
||||
|
||||
```typescript
|
||||
await client.auth.signInWithOAuth({
|
||||
provider: 'azure',
|
||||
options: {
|
||||
queryParams: {
|
||||
prompt: 'consent',
|
||||
access_type: 'offline',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Skip Browser Redirect
|
||||
|
||||
For mobile apps or custom flows:
|
||||
|
||||
```typescript
|
||||
const { data } = await client.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
skipBrowserRedirect: true,
|
||||
},
|
||||
});
|
||||
|
||||
// data.url contains the OAuth URL
|
||||
// Handle redirect manually
|
||||
```
|
||||
|
||||
## Account Linking
|
||||
|
||||
### Linking Additional Providers
|
||||
|
||||
Allow users to link multiple OAuth accounts:
|
||||
|
||||
```typescript
|
||||
export const linkOAuthProviderAction = enhanceAction(
|
||||
async (provider) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireAuth();
|
||||
|
||||
const { data, error } = await client.auth.linkIdentity({
|
||||
provider,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
redirect(data.url);
|
||||
},
|
||||
{ schema: OAuthProviderSchema, auth: true }
|
||||
);
|
||||
```
|
||||
|
||||
### Unlinking Providers
|
||||
|
||||
```typescript
|
||||
export const unlinkOAuthProviderAction = enhanceAction(
|
||||
async ({ provider, identityId }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client.auth.unlinkIdentity({
|
||||
identity_id: identityId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
revalidatePath('/settings/security');
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
provider: z.string(),
|
||||
identityId: z.string(),
|
||||
}),
|
||||
auth: true,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Viewing Linked Identities
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function getLinkedIdentities() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
|
||||
return user?.identities || [];
|
||||
}
|
||||
```
|
||||
|
||||
## User Data from OAuth
|
||||
|
||||
### Accessing Provider Data
|
||||
|
||||
```typescript
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
|
||||
// User metadata from provider
|
||||
const {
|
||||
full_name,
|
||||
avatar_url,
|
||||
email,
|
||||
} = user.user_metadata;
|
||||
|
||||
// Provider-specific data
|
||||
const identities = user.identities || [];
|
||||
const googleIdentity = identities.find(i => i.provider === 'google');
|
||||
|
||||
console.log(googleIdentity?.identity_data);
|
||||
```
|
||||
|
||||
### Storing Additional Data
|
||||
|
||||
```typescript
|
||||
export const completeOAuthProfileAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireAuth();
|
||||
|
||||
// Update user metadata
|
||||
await client.auth.updateUser({
|
||||
data: {
|
||||
username: data.username,
|
||||
bio: data.bio,
|
||||
},
|
||||
});
|
||||
|
||||
// Update profile in database
|
||||
await client.from('profiles').upsert({
|
||||
id: user.id,
|
||||
username: data.username,
|
||||
bio: data.bio,
|
||||
avatar_url: user.user_metadata.avatar_url,
|
||||
});
|
||||
|
||||
redirect('/home');
|
||||
},
|
||||
{ schema: ProfileSchema, auth: true }
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable OAuth in Config
|
||||
|
||||
```typescript
|
||||
// config/auth.config.ts
|
||||
export const authConfig = {
|
||||
providers: {
|
||||
emailPassword: true,
|
||||
oAuth: ['google', 'github'],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
```tsx
|
||||
import { authConfig } from '~/config/auth.config';
|
||||
|
||||
export function AuthProviders() {
|
||||
return (
|
||||
<>
|
||||
{authConfig.providers.emailPassword && <EmailPasswordForm />}
|
||||
|
||||
{authConfig.providers.oAuth?.includes('google') && (
|
||||
<GoogleSignInButton />
|
||||
)}
|
||||
|
||||
{authConfig.providers.oAuth?.includes('github') && (
|
||||
<GitHubSignInButton />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Redirect URI Mismatch
|
||||
|
||||
Ensure redirect URIs match exactly:
|
||||
- Check Supabase Dashboard → Authentication → URL Configuration
|
||||
- Verify OAuth app settings in provider console
|
||||
- Use exact URLs (including http/https)
|
||||
|
||||
### Missing Email
|
||||
|
||||
Some providers don't share email by default:
|
||||
|
||||
```typescript
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
|
||||
if (!user.email) {
|
||||
// Request email separately or prompt user
|
||||
redirect('/auth/complete-profile');
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
OAuth providers may rate limit requests:
|
||||
- Cache OAuth tokens appropriately
|
||||
- Don't make excessive authorization requests
|
||||
- Handle rate limit errors gracefully
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Request minimum scopes** - Only ask for what you need
|
||||
2. **Handle errors gracefully** - OAuth can fail for many reasons
|
||||
3. **Verify email addresses** - Some providers don't verify emails
|
||||
4. **Support account linking** - Let users connect multiple providers
|
||||
5. **Provide fallback** - Always offer email/password as backup
|
||||
6. **Log OAuth events** - Track sign-ins and linking attempts
|
||||
7. **Test thoroughly** - Test with real provider accounts
|
||||
15
apps/web/content/documentation/billing/billing.mdoc
Normal file
15
apps/web/content/documentation/billing/billing.mdoc
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: "Billing & Payments"
|
||||
description: "Learn how to set up billing and payment processing in your MakerKit application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 5
|
||||
status: "published"
|
||||
---
|
||||
|
||||
MakerKit integrates with popular payment providers to handle subscriptions and billing.
|
||||
|
||||
This section covers:
|
||||
- Setting up payment providers
|
||||
- Managing subscriptions
|
||||
- Handling webhooks
|
||||
- Pricing plans and tiers
|
||||
186
apps/web/content/documentation/billing/pricing-plans.mdoc
Normal file
186
apps/web/content/documentation/billing/pricing-plans.mdoc
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: "Pricing Plans"
|
||||
description: "How to configure and customize pricing plans for your SaaS application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 1
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Configure your pricing structure to match your business model.
|
||||
|
||||
## Plan Structure
|
||||
|
||||
Each pricing plan consists of:
|
||||
- **ID** - Unique identifier
|
||||
- **Name** - Display name
|
||||
- **Price** - Amount in your currency
|
||||
- **Interval** - Billing frequency (month, year)
|
||||
- **Features** - List of included features
|
||||
- **Limits** - Usage constraints
|
||||
|
||||
## Example Configuration
|
||||
|
||||
```typescript
|
||||
// config/billing.config.ts
|
||||
export const billingConfig = {
|
||||
provider: 'stripe', // or 'paddle'
|
||||
currency: 'usd',
|
||||
plans: [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
description: 'Perfect for getting started',
|
||||
price: 0,
|
||||
features: [
|
||||
'5 projects',
|
||||
'Basic analytics',
|
||||
'Community support',
|
||||
],
|
||||
limits: {
|
||||
projects: 5,
|
||||
members: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
description: 'For small teams',
|
||||
price: 19,
|
||||
interval: 'month',
|
||||
features: [
|
||||
'25 projects',
|
||||
'Advanced analytics',
|
||||
'Email support',
|
||||
'API access',
|
||||
],
|
||||
limits: {
|
||||
projects: 25,
|
||||
members: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Professional',
|
||||
description: 'For growing businesses',
|
||||
price: 49,
|
||||
interval: 'month',
|
||||
popular: true,
|
||||
features: [
|
||||
'Unlimited projects',
|
||||
'Advanced analytics',
|
||||
'Priority support',
|
||||
'API access',
|
||||
'Custom integrations',
|
||||
],
|
||||
limits: {
|
||||
projects: -1, // unlimited
|
||||
members: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Feature Gating
|
||||
|
||||
Restrict features based on subscription plan:
|
||||
|
||||
```typescript
|
||||
import { hasFeature } from '~/lib/billing/features';
|
||||
|
||||
async function createProject() {
|
||||
const subscription = await getSubscription(accountId);
|
||||
|
||||
if (!hasFeature(subscription, 'api_access')) {
|
||||
throw new Error('API access requires Pro plan');
|
||||
}
|
||||
|
||||
// Create project
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Limits
|
||||
|
||||
Enforce usage limits per plan:
|
||||
|
||||
```typescript
|
||||
import { checkLimit } from '~/lib/billing/limits';
|
||||
|
||||
async function addTeamMember() {
|
||||
const canAdd = await checkLimit(accountId, 'members');
|
||||
|
||||
if (!canAdd) {
|
||||
throw new Error('Member limit reached. Upgrade to add more members.');
|
||||
}
|
||||
|
||||
// Add member
|
||||
}
|
||||
```
|
||||
|
||||
## Annual Billing
|
||||
|
||||
Offer discounted annual plans:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'pro-annual',
|
||||
name: 'Professional Annual',
|
||||
price: 470, // ~20% discount
|
||||
interval: 'year',
|
||||
discount: '20% off',
|
||||
features: [ /* same as monthly */ ],
|
||||
}
|
||||
```
|
||||
|
||||
## Trial Periods
|
||||
|
||||
Configure free trial periods:
|
||||
|
||||
```typescript
|
||||
export const trialConfig = {
|
||||
enabled: true,
|
||||
duration: 14, // days
|
||||
plans: ['starter', 'pro'], // plans eligible for trial
|
||||
requirePaymentMethod: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Customizing the Pricing Page
|
||||
|
||||
The pricing page automatically generates from your configuration:
|
||||
|
||||
```tsx
|
||||
import { billingConfig } from '~/config/billing.config';
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<PricingTable
|
||||
plans={billingConfig.plans}
|
||||
currency={billingConfig.currency}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Adding Custom Features
|
||||
|
||||
Extend plan features with custom attributes:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: null, // Contact for pricing
|
||||
custom: true,
|
||||
features: [
|
||||
'Everything in Pro',
|
||||
'Dedicated support',
|
||||
'Custom SLA',
|
||||
'On-premise deployment',
|
||||
],
|
||||
ctaText: 'Contact Sales',
|
||||
ctaUrl: '/contact',
|
||||
}
|
||||
```
|
||||
143
apps/web/content/documentation/billing/subscriptions.mdoc
Normal file
143
apps/web/content/documentation/billing/subscriptions.mdoc
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: "Billing Overview"
|
||||
description: "Learn how to manage subscriptions and billing in your application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 0
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
The billing system supports subscription-based pricing with multiple tiers and payment providers.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Stripe
|
||||
Industry-standard payment processing with comprehensive features:
|
||||
- Credit card payments
|
||||
- Subscription management
|
||||
- Invoice generation
|
||||
- Tax calculation
|
||||
- Customer portal
|
||||
|
||||
### Paddle
|
||||
Merchant of record solution that handles:
|
||||
- Global tax compliance
|
||||
- Payment processing
|
||||
- Subscription billing
|
||||
- Revenue recovery
|
||||
|
||||
## Subscription Tiers
|
||||
|
||||
Define your subscription tiers in the billing configuration:
|
||||
|
||||
```typescript
|
||||
export const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Professional',
|
||||
price: 29,
|
||||
interval: 'month',
|
||||
features: ['All Free features', 'Feature 3', 'Feature 4'],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: 99,
|
||||
interval: 'month',
|
||||
features: ['All Pro features', 'Feature 5', 'Priority support'],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Subscription Lifecycle
|
||||
|
||||
1. **Customer selects plan** - User chooses subscription tier
|
||||
2. **Payment processed** - Provider handles payment collection
|
||||
3. **Webhook received** - Your app receives confirmation
|
||||
4. **Subscription activated** - User gains access to features
|
||||
5. **Recurring billing** - Automatic renewal each period
|
||||
6. **Cancellation** - User can cancel anytime
|
||||
|
||||
## Managing Subscriptions
|
||||
|
||||
### Creating a Subscription
|
||||
|
||||
```typescript
|
||||
import { createCheckoutSession } from '~/lib/billing/checkout';
|
||||
|
||||
const session = await createCheckoutSession({
|
||||
accountId: user.accountId,
|
||||
planId: 'pro',
|
||||
returnUrl: '/dashboard',
|
||||
});
|
||||
|
||||
// Redirect user to payment page
|
||||
redirect(session.url);
|
||||
```
|
||||
|
||||
### Checking Subscription Status
|
||||
|
||||
```typescript
|
||||
import { getSubscription } from '~/lib/billing/subscription';
|
||||
|
||||
const subscription = await getSubscription(accountId);
|
||||
|
||||
if (subscription.status === 'active') {
|
||||
// User has active subscription
|
||||
}
|
||||
```
|
||||
|
||||
### Canceling a Subscription
|
||||
|
||||
```typescript
|
||||
import { cancelSubscription } from '~/lib/billing/subscription';
|
||||
|
||||
await cancelSubscription(subscriptionId);
|
||||
```
|
||||
|
||||
## Webhook Handling
|
||||
|
||||
Webhooks notify your application of billing events:
|
||||
|
||||
```typescript
|
||||
export async function POST(request: Request) {
|
||||
const signature = request.headers.get('stripe-signature');
|
||||
const payload = await request.text();
|
||||
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET
|
||||
);
|
||||
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.created':
|
||||
await handleSubscriptionCreated(event.data.object);
|
||||
break;
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdated(event.data.object);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await handleSubscriptionCanceled(event.data.object);
|
||||
break;
|
||||
}
|
||||
|
||||
return new Response('OK');
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use test mode credentials for development:
|
||||
- Test card: 4242 4242 4242 4242
|
||||
- Any future expiry date
|
||||
- Any CVC
|
||||
|
||||
All test transactions will appear in your provider's test dashboard.
|
||||
194
apps/web/content/documentation/billing/webhooks.mdoc
Normal file
194
apps/web/content/documentation/billing/webhooks.mdoc
Normal file
@@ -0,0 +1,194 @@
|
||||
---
|
||||
title: "Webhook Integration"
|
||||
description: "Setting up and handling payment provider webhooks for subscription events."
|
||||
publishedAt: 2024-04-11
|
||||
order: 2
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Webhooks notify your application when billing events occur, ensuring your app stays synchronized with your payment provider.
|
||||
|
||||
## Why Webhooks?
|
||||
|
||||
Webhooks are essential for:
|
||||
- **Real-time updates** - Instant notification of payment events
|
||||
- **Reliability** - Handles events even if users close their browser
|
||||
- **Security** - Server-to-server communication
|
||||
- **Automation** - Automatic subscription status updates
|
||||
|
||||
## Webhook Endpoint
|
||||
|
||||
Your webhook endpoint receives events from the payment provider:
|
||||
|
||||
```typescript
|
||||
// app/api/billing/webhook/route.ts
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text();
|
||||
const signature = request.headers.get('stripe-signature');
|
||||
|
||||
// Verify webhook signature
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET
|
||||
);
|
||||
|
||||
// Handle the event
|
||||
await handleBillingEvent(event);
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
}
|
||||
```
|
||||
|
||||
## Common Events
|
||||
|
||||
### Subscription Created
|
||||
```typescript
|
||||
case 'customer.subscription.created':
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
id: event.data.object.id,
|
||||
accountId: event.data.object.metadata.accountId,
|
||||
status: 'active',
|
||||
planId: event.data.object.items.data[0].price.id,
|
||||
currentPeriodEnd: new Date(event.data.object.current_period_end * 1000),
|
||||
},
|
||||
});
|
||||
break;
|
||||
```
|
||||
|
||||
### Subscription Updated
|
||||
```typescript
|
||||
case 'customer.subscription.updated':
|
||||
await prisma.subscription.update({
|
||||
where: { id: event.data.object.id },
|
||||
data: {
|
||||
status: event.data.object.status,
|
||||
planId: event.data.object.items.data[0].price.id,
|
||||
currentPeriodEnd: new Date(event.data.object.current_period_end * 1000),
|
||||
},
|
||||
});
|
||||
break;
|
||||
```
|
||||
|
||||
### Subscription Deleted
|
||||
```typescript
|
||||
case 'customer.subscription.deleted':
|
||||
await prisma.subscription.update({
|
||||
where: { id: event.data.object.id },
|
||||
data: {
|
||||
status: 'canceled',
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
```
|
||||
|
||||
### Payment Failed
|
||||
```typescript
|
||||
case 'invoice.payment_failed':
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { id: event.data.object.subscription },
|
||||
});
|
||||
|
||||
// Send payment failure notification
|
||||
await sendPaymentFailureEmail(subscription.accountId);
|
||||
break;
|
||||
```
|
||||
|
||||
## Setting Up Webhooks
|
||||
|
||||
### Stripe
|
||||
|
||||
1. **Local Development** (using Stripe CLI):
|
||||
```bash
|
||||
stripe listen --forward-to localhost:3000/api/billing/webhook
|
||||
```
|
||||
|
||||
2. **Production**:
|
||||
- Go to Stripe Dashboard → Developers → Webhooks
|
||||
- Add endpoint: `https://yourdomain.com/api/billing/webhook`
|
||||
- Select events to listen to
|
||||
- Copy webhook signing secret to your `.env`
|
||||
|
||||
### Paddle
|
||||
|
||||
1. **Configure webhook URL** in Paddle dashboard
|
||||
2. **Add webhook secret** to environment variables
|
||||
3. **Verify webhook signature**:
|
||||
|
||||
```typescript
|
||||
const signature = request.headers.get('paddle-signature');
|
||||
const verified = paddle.webhooks.verify(body, signature);
|
||||
|
||||
if (!verified) {
|
||||
return new Response('Invalid signature', { status: 401 });
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Always verify signatures** - Prevents unauthorized requests
|
||||
2. **Use HTTPS** - Encrypts webhook data in transit
|
||||
3. **Validate event data** - Check for required fields
|
||||
4. **Handle idempotently** - Process duplicate events safely
|
||||
5. **Return 200 quickly** - Acknowledge receipt, process async
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
async function handleBillingEvent(event: Event) {
|
||||
try {
|
||||
await processEvent(event);
|
||||
} catch (error) {
|
||||
// Log error for debugging
|
||||
console.error('Webhook error:', error);
|
||||
|
||||
// Store failed event for retry
|
||||
await prisma.failedWebhook.create({
|
||||
data: {
|
||||
eventId: event.id,
|
||||
type: event.type,
|
||||
payload: event,
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
|
||||
// Throw to trigger provider retry
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Webhooks
|
||||
|
||||
### Using Provider's CLI Tools
|
||||
|
||||
```bash
|
||||
# Stripe
|
||||
stripe trigger customer.subscription.created
|
||||
|
||||
# Test specific scenarios
|
||||
stripe trigger payment_intent.payment_failed
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-app.com/api/billing/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "stripe-signature: test_signature" \
|
||||
-d @test-event.json
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
Track webhook delivery:
|
||||
- Response times
|
||||
- Success/failure rates
|
||||
- Event processing duration
|
||||
- Failed events requiring manual intervention
|
||||
|
||||
Most providers offer webhook monitoring dashboards showing delivery attempts and failures.
|
||||
16
apps/web/content/documentation/database/database.mdoc
Normal file
16
apps/web/content/documentation/database/database.mdoc
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Database"
|
||||
description: "Learn how to work with the Supabase database in your MakerKit application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 2
|
||||
status: "published"
|
||||
---
|
||||
|
||||
MakerKit uses Supabase Postgres for database management with built-in security and performance.
|
||||
|
||||
This section covers:
|
||||
- Database schema and structure
|
||||
- Running migrations
|
||||
- Row Level Security (RLS)
|
||||
- Querying data
|
||||
- Functions and triggers
|
||||
446
apps/web/content/documentation/database/functions-triggers.mdoc
Normal file
446
apps/web/content/documentation/database/functions-triggers.mdoc
Normal file
@@ -0,0 +1,446 @@
|
||||
---
|
||||
title: "Functions & Triggers"
|
||||
description: "Create database functions and triggers for automated logic."
|
||||
publishedAt: 2024-04-11
|
||||
order: 4
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Database functions and triggers enable server-side logic and automation.
|
||||
|
||||
## Database Functions
|
||||
|
||||
### Creating a Function
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION get_user_projects(user_id UUID)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
name TEXT,
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT p.id, p.name, p.created_at
|
||||
FROM projects p
|
||||
INNER JOIN accounts_memberships am ON am.account_id = p.account_id
|
||||
WHERE am.user_id = get_user_projects.user_id;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### Calling from TypeScript
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client.rpc('get_user_projects', {
|
||||
user_id: userId,
|
||||
});
|
||||
```
|
||||
|
||||
## Common Function Patterns
|
||||
|
||||
### Get User Accounts
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION get_user_accounts(user_id UUID)
|
||||
RETURNS TABLE (account_id UUID)
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT account_id
|
||||
FROM accounts_memberships
|
||||
WHERE user_id = $1;
|
||||
$$;
|
||||
```
|
||||
|
||||
### Check Permission
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION has_permission(
|
||||
user_id UUID,
|
||||
account_id UUID,
|
||||
required_role TEXT
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
user_role TEXT;
|
||||
BEGIN
|
||||
SELECT role INTO user_role
|
||||
FROM accounts_memberships
|
||||
WHERE user_id = has_permission.user_id
|
||||
AND account_id = has_permission.account_id;
|
||||
|
||||
RETURN user_role = required_role OR user_role = 'owner';
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### Search Function
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION search_projects(
|
||||
search_term TEXT,
|
||||
account_id UUID
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
relevance REAL
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
ts_rank(
|
||||
to_tsvector('english', p.name || ' ' || COALESCE(p.description, '')),
|
||||
plainto_tsquery('english', search_term)
|
||||
) AS relevance
|
||||
FROM projects p
|
||||
WHERE p.account_id = search_projects.account_id
|
||||
AND (
|
||||
to_tsvector('english', p.name || ' ' || COALESCE(p.description, ''))
|
||||
@@ plainto_tsquery('english', search_term)
|
||||
)
|
||||
ORDER BY relevance DESC;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
### Auto-Update Timestamp
|
||||
|
||||
```sql
|
||||
-- Create trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Attach to table
|
||||
CREATE TRIGGER update_projects_updated_at
|
||||
BEFORE UPDATE ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
```
|
||||
|
||||
### Audit Log Trigger
|
||||
|
||||
```sql
|
||||
-- Create audit log table
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
table_name TEXT NOT NULL,
|
||||
record_id UUID NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
old_data JSONB,
|
||||
new_data JSONB,
|
||||
user_id UUID,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create trigger function
|
||||
CREATE OR REPLACE FUNCTION log_changes()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO audit_log (table_name, record_id, action, new_data, user_id)
|
||||
VALUES (TG_TABLE_NAME, NEW.id, 'INSERT', to_jsonb(NEW), auth.uid());
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, user_id)
|
||||
VALUES (TG_TABLE_NAME, NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), auth.uid());
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
INSERT INTO audit_log (table_name, record_id, action, old_data, user_id)
|
||||
VALUES (TG_TABLE_NAME, OLD.id, 'DELETE', to_jsonb(OLD), auth.uid());
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Attach to table
|
||||
CREATE TRIGGER audit_projects
|
||||
AFTER INSERT OR UPDATE OR DELETE ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_changes();
|
||||
```
|
||||
|
||||
### Cascade Soft Delete
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION soft_delete_cascade()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Soft delete related tasks
|
||||
UPDATE tasks
|
||||
SET deleted_at = NOW()
|
||||
WHERE project_id = OLD.id
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER soft_delete_project_tasks
|
||||
BEFORE DELETE ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION soft_delete_cascade();
|
||||
```
|
||||
|
||||
## Validation Triggers
|
||||
|
||||
### Enforce Business Rules
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION validate_project_budget()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.budget < 0 THEN
|
||||
RAISE EXCEPTION 'Budget cannot be negative';
|
||||
END IF;
|
||||
|
||||
IF NEW.budget > 1000000 THEN
|
||||
RAISE EXCEPTION 'Budget cannot exceed 1,000,000';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER check_project_budget
|
||||
BEFORE INSERT OR UPDATE ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_project_budget();
|
||||
```
|
||||
|
||||
### Prevent Orphaned Records
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION prevent_owner_removal()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
owner_count INTEGER;
|
||||
BEGIN
|
||||
IF OLD.role = 'owner' THEN
|
||||
SELECT COUNT(*) INTO owner_count
|
||||
FROM accounts_memberships
|
||||
WHERE account_id = OLD.account_id
|
||||
AND role = 'owner'
|
||||
AND id != OLD.id;
|
||||
|
||||
IF owner_count = 0 THEN
|
||||
RAISE EXCEPTION 'Cannot remove the last owner of an account';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER check_owner_before_delete
|
||||
BEFORE DELETE ON accounts_memberships
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_owner_removal();
|
||||
```
|
||||
|
||||
## Computed Columns
|
||||
|
||||
### Virtual Column with Function
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION project_task_count(project_id UUID)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COUNT(*)::INTEGER
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND deleted_at IS NULL;
|
||||
$$;
|
||||
|
||||
-- Use in queries
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
project_task_count(id) as task_count
|
||||
FROM projects;
|
||||
```
|
||||
|
||||
## Event Notifications
|
||||
|
||||
### Notify on Changes
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION notify_project_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify(
|
||||
'project_changes',
|
||||
json_build_object(
|
||||
'operation', TG_OP,
|
||||
'record', NEW
|
||||
)::text
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER project_change_notification
|
||||
AFTER INSERT OR UPDATE ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_project_change();
|
||||
```
|
||||
|
||||
### Listen in TypeScript
|
||||
|
||||
```typescript
|
||||
const channel = client
|
||||
.channel('project_changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'projects',
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Project changed:', payload);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
```
|
||||
|
||||
## Security Functions
|
||||
|
||||
### Row Level Security Helper
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION is_account_member(account_id UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM accounts_memberships
|
||||
WHERE account_id = $1
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Use in RLS policy
|
||||
CREATE POLICY "Users can access their account's projects"
|
||||
ON projects FOR ALL
|
||||
USING (is_account_member(account_id));
|
||||
```
|
||||
|
||||
## Scheduled Functions
|
||||
|
||||
### Using pg_cron Extension
|
||||
|
||||
```sql
|
||||
-- Enable pg_cron extension
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
|
||||
-- Schedule cleanup job
|
||||
SELECT cron.schedule(
|
||||
'cleanup-old-sessions',
|
||||
'0 2 * * *', -- Every day at 2 AM
|
||||
$$
|
||||
DELETE FROM sessions
|
||||
WHERE expires_at < NOW();
|
||||
$$
|
||||
);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use SECURITY DEFINER carefully** - Can bypass RLS
|
||||
2. **Add error handling** - Use EXCEPTION blocks
|
||||
3. **Keep functions simple** - One responsibility per function
|
||||
4. **Document functions** - Add comments
|
||||
5. **Test thoroughly** - Unit test database functions
|
||||
6. **Use STABLE/IMMUTABLE** - Performance optimization
|
||||
7. **Avoid side effects** - Make functions predictable
|
||||
8. **Return proper types** - Use RETURNS TABLE for clarity
|
||||
|
||||
## Testing Functions
|
||||
|
||||
```sql
|
||||
-- Test function
|
||||
DO $$
|
||||
DECLARE
|
||||
result INTEGER;
|
||||
BEGIN
|
||||
SELECT project_task_count('some-uuid') INTO result;
|
||||
|
||||
ASSERT result >= 0, 'Task count should not be negative';
|
||||
|
||||
RAISE NOTICE 'Test passed: task count = %', result;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Function Logging
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION debug_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Debug: Processing started';
|
||||
RAISE NOTICE 'Debug: Current user is %', auth.uid();
|
||||
-- Your function logic
|
||||
RAISE NOTICE 'Debug: Processing completed';
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### Check Function Execution
|
||||
|
||||
```sql
|
||||
-- View function execution stats
|
||||
SELECT
|
||||
schemaname,
|
||||
funcname,
|
||||
calls,
|
||||
total_time,
|
||||
self_time
|
||||
FROM pg_stat_user_functions
|
||||
ORDER BY total_time DESC;
|
||||
```
|
||||
68
apps/web/content/documentation/database/migrations.mdoc
Normal file
68
apps/web/content/documentation/database/migrations.mdoc
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: "Migrations"
|
||||
description: "Learn how to create and manage database migrations in your application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 1
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Database migrations allow you to version control your database schema changes and apply them consistently across environments.
|
||||
|
||||
## Creating a Migration
|
||||
|
||||
To create a new migration, use the following command:
|
||||
|
||||
```bash
|
||||
pnpm --filter web supabase:db:diff
|
||||
```
|
||||
|
||||
This will generate a new migration file in the `apps/web/supabase/migrations` directory based on the differences between your local database and the schema files.
|
||||
|
||||
## Applying Migrations
|
||||
|
||||
To apply migrations to your local database:
|
||||
|
||||
```bash
|
||||
pnpm --filter web supabase migrations up
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
1. **Always test migrations locally first** before applying to production
|
||||
2. **Make migrations reversible** when possible by including DOWN statements
|
||||
3. **Use transactions** to ensure atomic operations
|
||||
4. **Add indexes** for foreign keys and frequently queried columns
|
||||
5. **Include RLS policies** in the same migration as table creation
|
||||
|
||||
## Example Migration
|
||||
|
||||
```sql
|
||||
-- Create a new table
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
completed BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add RLS
|
||||
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create policies
|
||||
CREATE POLICY "Users can view their account tasks"
|
||||
ON tasks FOR SELECT
|
||||
USING (account_id IN (SELECT get_user_accounts(auth.uid())));
|
||||
```
|
||||
|
||||
## Resetting the Database
|
||||
|
||||
To completely reset your local database with the latest schema:
|
||||
|
||||
```bash
|
||||
pnpm supabase:web:reset
|
||||
```
|
||||
|
||||
This will drop all tables and reapply all migrations from scratch.
|
||||
430
apps/web/content/documentation/database/querying-data.mdoc
Normal file
430
apps/web/content/documentation/database/querying-data.mdoc
Normal file
@@ -0,0 +1,430 @@
|
||||
---
|
||||
title: "Querying Data"
|
||||
description: "Learn how to query and filter data from your database."
|
||||
publishedAt: 2024-04-11
|
||||
order: 3
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Efficiently query and filter data using Supabase's query builder.
|
||||
|
||||
## Basic Queries
|
||||
|
||||
### Select All
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.select('*');
|
||||
```
|
||||
|
||||
### Select Specific Columns
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.select('id, name, created_at');
|
||||
```
|
||||
|
||||
### Select with Related Data
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
account:accounts(id, name),
|
||||
tasks(id, title, completed)
|
||||
`);
|
||||
```
|
||||
|
||||
## Filtering
|
||||
|
||||
### Equal
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('status', 'active');
|
||||
```
|
||||
|
||||
### Not Equal
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.neq('status', 'deleted');
|
||||
```
|
||||
|
||||
### Greater Than / Less Than
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.gt('created_at', '2024-01-01')
|
||||
.lt('budget', 10000);
|
||||
```
|
||||
|
||||
### In Array
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.in('status', ['active', 'pending']);
|
||||
```
|
||||
|
||||
### Like (Pattern Matching)
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.like('name', '%website%');
|
||||
```
|
||||
|
||||
### Full-Text Search
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.textSearch('description', 'design & development');
|
||||
```
|
||||
|
||||
## Ordering
|
||||
|
||||
### Order By
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
```
|
||||
|
||||
### Multiple Order By
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.order('status')
|
||||
.order('created_at', { ascending: false });
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Limit
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.limit(10);
|
||||
```
|
||||
|
||||
### Range (Offset)
|
||||
|
||||
```typescript
|
||||
const page = 2;
|
||||
const pageSize = 10;
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data, count } = await client
|
||||
.from('projects')
|
||||
.select('*', { count: 'exact' })
|
||||
.range(from, to);
|
||||
```
|
||||
|
||||
## Aggregations
|
||||
|
||||
### Count
|
||||
|
||||
```typescript
|
||||
const { count } = await client
|
||||
.from('projects')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
```
|
||||
|
||||
### Count with Filters
|
||||
|
||||
```typescript
|
||||
const { count } = await client
|
||||
.from('projects')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('status', 'active');
|
||||
```
|
||||
|
||||
## Advanced Queries
|
||||
|
||||
### Multiple Filters
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.gte('created_at', startDate)
|
||||
.lte('created_at', endDate)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
```
|
||||
|
||||
### OR Conditions
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.or('status.eq.active,status.eq.pending');
|
||||
```
|
||||
|
||||
### Nested OR
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.or('and(status.eq.active,priority.eq.high),status.eq.urgent');
|
||||
```
|
||||
|
||||
## Joins
|
||||
|
||||
### Inner Join
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select(`
|
||||
*,
|
||||
account:accounts!inner(
|
||||
id,
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('account.name', 'Acme Corp');
|
||||
```
|
||||
|
||||
### Left Join
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select(`
|
||||
*,
|
||||
tasks(*)
|
||||
`);
|
||||
```
|
||||
|
||||
## Null Handling
|
||||
|
||||
### Is Null
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.is('completed_at', null);
|
||||
```
|
||||
|
||||
### Not Null
|
||||
|
||||
```typescript
|
||||
const { data} = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.not('completed_at', 'is', null);
|
||||
```
|
||||
|
||||
## Insert Data
|
||||
|
||||
### Single Insert
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.insert({
|
||||
name: 'New Project',
|
||||
account_id: accountId,
|
||||
status: 'active',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
```
|
||||
|
||||
### Multiple Insert
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.insert([
|
||||
{ name: 'Project 1', account_id: accountId },
|
||||
{ name: 'Project 2', account_id: accountId },
|
||||
])
|
||||
.select();
|
||||
```
|
||||
|
||||
## Update Data
|
||||
|
||||
### Update with Filter
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.update({ status: 'completed' })
|
||||
.eq('id', projectId)
|
||||
.select()
|
||||
.single();
|
||||
```
|
||||
|
||||
### Update Multiple Rows
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.update({ status: 'archived' })
|
||||
.eq('account_id', accountId)
|
||||
.lt('updated_at', oldDate);
|
||||
```
|
||||
|
||||
## Delete Data
|
||||
|
||||
### Delete with Filter
|
||||
|
||||
```typescript
|
||||
const { error } = await client
|
||||
.from('projects')
|
||||
.delete()
|
||||
.eq('id', projectId);
|
||||
```
|
||||
|
||||
### Delete Multiple
|
||||
|
||||
```typescript
|
||||
const { error } = await client
|
||||
.from('projects')
|
||||
.delete()
|
||||
.in('id', projectIds);
|
||||
```
|
||||
|
||||
## Upsert
|
||||
|
||||
### Insert or Update
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.upsert({
|
||||
id: projectId,
|
||||
name: 'Updated Name',
|
||||
status: 'active',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
```
|
||||
|
||||
## RPC (Stored Procedures)
|
||||
|
||||
### Call Database Function
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.rpc('get_user_projects', {
|
||||
user_id: userId,
|
||||
});
|
||||
```
|
||||
|
||||
### With Complex Parameters
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.rpc('search_projects', {
|
||||
search_term: 'design',
|
||||
account_ids: [1, 2, 3],
|
||||
min_budget: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Basic Error Handling
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client
|
||||
.from('projects')
|
||||
.select('*');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching projects:', error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
```
|
||||
|
||||
### Typed Error Handling
|
||||
|
||||
```typescript
|
||||
import { PostgrestError } from '@supabase/supabase-js';
|
||||
|
||||
function handleDatabaseError(error: PostgrestError) {
|
||||
switch (error.code) {
|
||||
case '23505': // unique_violation
|
||||
throw new Error('A project with this name already exists');
|
||||
case '23503': // foreign_key_violation
|
||||
throw new Error('Invalid account reference');
|
||||
default:
|
||||
throw new Error('Database error: ' + error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
### Generated Types
|
||||
|
||||
```typescript
|
||||
import { Database } from '~/types/database.types';
|
||||
|
||||
type Project = Database['public']['Tables']['projects']['Row'];
|
||||
type ProjectInsert = Database['public']['Tables']['projects']['Insert'];
|
||||
type ProjectUpdate = Database['public']['Tables']['projects']['Update'];
|
||||
```
|
||||
|
||||
### Typed Queries
|
||||
|
||||
```typescript
|
||||
const { data } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.returns<Project[]>();
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Select only needed columns** - Don't use `select('*')` unnecessarily
|
||||
2. **Use indexes** - Create indexes on frequently filtered columns
|
||||
3. **Limit results** - Always paginate large datasets
|
||||
4. **Avoid N+1 queries** - Use joins instead of multiple queries
|
||||
5. **Use RPC for complex queries** - Move logic to database
|
||||
6. **Cache when possible** - Use React Query or similar
|
||||
7. **Profile queries** - Use `EXPLAIN ANALYZE` in SQL
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always handle errors** - Check error responses
|
||||
2. **Validate input** - Use Zod or similar
|
||||
3. **Use TypeScript** - Generate and use types
|
||||
4. **Consistent naming** - Follow database naming conventions
|
||||
5. **Document complex queries** - Add comments
|
||||
6. **Test queries** - Unit test database operations
|
||||
7. **Monitor performance** - Track slow queries
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: "Row Level Security"
|
||||
description: "Understanding and implementing Row Level Security (RLS) for data protection."
|
||||
publishedAt: 2024-04-11
|
||||
order: 2
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Row Level Security (RLS) is PostgreSQL's built-in authorization system that controls which rows users can access in database tables.
|
||||
|
||||
## Why RLS?
|
||||
|
||||
RLS provides several advantages:
|
||||
- **Database-level security** - Protection even if application code has bugs
|
||||
- **Automatic enforcement** - No need for manual authorization checks
|
||||
- **Multi-tenant isolation** - Ensures users only see their own data
|
||||
- **Performance** - Optimized at the database level
|
||||
|
||||
## Enabling RLS
|
||||
|
||||
All tables should have RLS enabled:
|
||||
|
||||
```sql
|
||||
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
## Common Policy Patterns
|
||||
|
||||
### Personal Account Access
|
||||
|
||||
```sql
|
||||
CREATE POLICY "Users can access their personal account data"
|
||||
ON your_table FOR ALL
|
||||
USING (account_id = auth.uid());
|
||||
```
|
||||
|
||||
### Team Account Access
|
||||
|
||||
```sql
|
||||
CREATE POLICY "Users can access their team account data"
|
||||
ON your_table FOR ALL
|
||||
USING (
|
||||
account_id IN (
|
||||
SELECT account_id FROM accounts_memberships
|
||||
WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Read vs Write Permissions
|
||||
|
||||
```sql
|
||||
-- All members can read
|
||||
CREATE POLICY "Team members can view data"
|
||||
ON your_table FOR SELECT
|
||||
USING (account_id IN (SELECT get_user_accounts(auth.uid())));
|
||||
|
||||
-- Only owners can modify
|
||||
CREATE POLICY "Only owners can modify data"
|
||||
ON your_table FOR UPDATE
|
||||
USING (
|
||||
account_id IN (
|
||||
SELECT account_id FROM accounts_memberships
|
||||
WHERE user_id = auth.uid() AND role = 'owner'
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Testing RLS Policies
|
||||
|
||||
Always test your RLS policies to ensure they work correctly:
|
||||
|
||||
```sql
|
||||
-- Test as specific user
|
||||
SET request.jwt.claims.sub = 'user-uuid-here';
|
||||
|
||||
-- Try to select data
|
||||
SELECT * FROM your_table;
|
||||
|
||||
-- Reset
|
||||
RESET request.jwt.claims.sub;
|
||||
```
|
||||
|
||||
## Admin Bypass
|
||||
|
||||
Service role keys bypass RLS. Use with extreme caution and always implement manual authorization checks when using the admin client.
|
||||
43
apps/web/content/documentation/database/schema.mdoc
Normal file
43
apps/web/content/documentation/database/schema.mdoc
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "Database Overview"
|
||||
description: "Understanding the database schema and table structure in your application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 0
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
The database schema is designed with a multi-tenant architecture that supports both personal and team accounts.
|
||||
|
||||
## Core Tables
|
||||
|
||||
### Users Table
|
||||
The `users` table stores user authentication data and is managed by Supabase Auth:
|
||||
- `id` - Unique user identifier
|
||||
- `email` - User's email address
|
||||
- `created_at` - Account creation timestamp
|
||||
|
||||
### Accounts Table
|
||||
The `accounts` table represents both personal and team accounts:
|
||||
- `id` - Unique account identifier
|
||||
- `name` - Account display name
|
||||
- `slug` - URL-friendly identifier
|
||||
- `is_personal_account` - Boolean flag for personal vs team accounts
|
||||
|
||||
### Projects Table
|
||||
Store your application's project data:
|
||||
- `id` - Unique project identifier
|
||||
- `account_id` - Foreign key to accounts table
|
||||
- `name` - Project name
|
||||
- `description` - Project description
|
||||
- `created_at` - Creation timestamp
|
||||
|
||||
## Relationships
|
||||
|
||||
All data in the application is tied to accounts through foreign key relationships. This ensures proper data isolation and access control through Row Level Security (RLS).
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Learn about [migrations](/docs/database/migrations)
|
||||
- Understand [RLS policies](/docs/database/row-level-security)
|
||||
279
apps/web/content/documentation/features/email.mdoc
Normal file
279
apps/web/content/documentation/features/email.mdoc
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
title: "Features Overview"
|
||||
description: "Send emails and notifications to your users."
|
||||
publishedAt: 2024-04-11
|
||||
order: 0
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
The application includes email functionality for transactional messages and user notifications.
|
||||
|
||||
## Email Configuration
|
||||
|
||||
### Supabase Email (Default)
|
||||
|
||||
By default, emails are sent through Supabase:
|
||||
- Authentication emails (sign-up, password reset, magic links)
|
||||
- Email verification
|
||||
- Email change confirmation
|
||||
|
||||
### Custom SMTP
|
||||
|
||||
For transactional emails, configure your own SMTP provider:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-username
|
||||
SMTP_PASSWORD=your-password
|
||||
SMTP_FROM_EMAIL=noreply@yourdomain.com
|
||||
SMTP_FROM_NAME=Your App Name
|
||||
```
|
||||
|
||||
## Sending Emails
|
||||
|
||||
### Using the Email Service
|
||||
|
||||
```typescript
|
||||
import { sendEmail } from '~/lib/email/send-email';
|
||||
|
||||
await sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Welcome to Our App',
|
||||
html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
|
||||
});
|
||||
```
|
||||
|
||||
### Using Email Templates
|
||||
|
||||
Create reusable email templates:
|
||||
|
||||
```typescript
|
||||
// lib/email/templates/welcome-email.tsx
|
||||
import { EmailTemplate } from '~/lib/email/email-template';
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
name: string;
|
||||
loginUrl: string;
|
||||
}
|
||||
|
||||
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<h1>Welcome, {name}!</h1>
|
||||
<p>We're excited to have you on board.</p>
|
||||
<a href={loginUrl}>Get Started</a>
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
// Send the email
|
||||
import { render } from '@react-email/render';
|
||||
import { WelcomeEmail } from '~/lib/email/templates/welcome-email';
|
||||
|
||||
const html = render(
|
||||
<WelcomeEmail name="John" loginUrl="https://app.com/login" />
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
to: 'john@example.com',
|
||||
subject: 'Welcome to Our App',
|
||||
html,
|
||||
});
|
||||
```
|
||||
|
||||
## Email Types
|
||||
|
||||
### Transactional Emails
|
||||
|
||||
Emails triggered by user actions:
|
||||
- Welcome emails
|
||||
- Order confirmations
|
||||
- Password resets
|
||||
- Account notifications
|
||||
- Billing updates
|
||||
|
||||
### Marketing Emails
|
||||
|
||||
Promotional and engagement emails:
|
||||
- Product updates
|
||||
- Feature announcements
|
||||
- Newsletters
|
||||
- Onboarding sequences
|
||||
|
||||
## Email Providers
|
||||
|
||||
### Recommended Providers
|
||||
|
||||
**Resend** - Developer-friendly email API
|
||||
```bash
|
||||
npm install resend
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'noreply@yourdomain.com',
|
||||
to: 'user@example.com',
|
||||
subject: 'Welcome',
|
||||
html: emailHtml,
|
||||
});
|
||||
```
|
||||
|
||||
**SendGrid** - Comprehensive email platform
|
||||
```bash
|
||||
npm install @sendgrid/mail
|
||||
```
|
||||
|
||||
```typescript
|
||||
import sgMail from '@sendgrid/mail';
|
||||
|
||||
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
|
||||
|
||||
await sgMail.send({
|
||||
to: 'user@example.com',
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject: 'Welcome',
|
||||
html: emailHtml,
|
||||
});
|
||||
```
|
||||
|
||||
**Postmark** - Fast transactional email
|
||||
```bash
|
||||
npm install postmark
|
||||
```
|
||||
|
||||
## In-App Notifications
|
||||
|
||||
### Notification System
|
||||
|
||||
Send in-app notifications to users:
|
||||
|
||||
```typescript
|
||||
import { createNotification } from '~/lib/notifications';
|
||||
|
||||
await createNotification({
|
||||
userId: user.id,
|
||||
title: 'New Message',
|
||||
message: 'You have a new message from John',
|
||||
type: 'info',
|
||||
link: '/messages/123',
|
||||
});
|
||||
```
|
||||
|
||||
### Notification Types
|
||||
|
||||
```typescript
|
||||
type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
await createNotification({
|
||||
userId: user.id,
|
||||
title: 'Payment Successful',
|
||||
message: 'Your subscription has been renewed',
|
||||
type: 'success',
|
||||
});
|
||||
```
|
||||
|
||||
### Fetching Notifications
|
||||
|
||||
```typescript
|
||||
import { getUserNotifications } from '~/lib/notifications';
|
||||
|
||||
const notifications = await getUserNotifications(userId, {
|
||||
limit: 10,
|
||||
unreadOnly: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Marking as Read
|
||||
|
||||
```typescript
|
||||
import { markNotificationAsRead } from '~/lib/notifications';
|
||||
|
||||
await markNotificationAsRead(notificationId);
|
||||
```
|
||||
|
||||
## Real-time Notifications
|
||||
|
||||
Use Supabase Realtime for instant notifications:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
export function NotificationListener() {
|
||||
const supabase = useSupabase();
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel('notifications')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'notifications',
|
||||
filter: `user_id=eq.${userId}`,
|
||||
},
|
||||
(payload) => {
|
||||
// Show toast notification
|
||||
toast.info(payload.new.title);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [supabase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Email Templates Best Practices
|
||||
|
||||
1. **Keep it simple** - Plain text and minimal HTML
|
||||
2. **Mobile responsive** - Most emails are read on mobile
|
||||
3. **Clear CTAs** - Make action buttons prominent
|
||||
4. **Personalize** - Use user's name and relevant data
|
||||
5. **Test rendering** - Check across email clients
|
||||
6. **Include plain text** - Always provide text alternative
|
||||
7. **Unsubscribe link** - Required for marketing emails
|
||||
|
||||
## Testing Emails
|
||||
|
||||
### Local Development
|
||||
|
||||
In development, emails are caught by InBucket:
|
||||
|
||||
```
|
||||
http://localhost:54324
|
||||
```
|
||||
|
||||
### Preview Emails
|
||||
|
||||
Use React Email to preview templates:
|
||||
|
||||
```bash
|
||||
npm run email:dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:3001` to see email previews.
|
||||
|
||||
## Deliverability Tips
|
||||
|
||||
1. **Authenticate your domain** - Set up SPF, DKIM, DMARC
|
||||
2. **Warm up your domain** - Start with low volumes
|
||||
3. **Monitor bounce rates** - Keep below 5%
|
||||
4. **Avoid spam triggers** - Don't use all caps, excessive punctuation
|
||||
5. **Provide value** - Only send relevant, useful emails
|
||||
6. **Easy unsubscribe** - Make it one-click simple
|
||||
16
apps/web/content/documentation/features/features.mdoc
Normal file
16
apps/web/content/documentation/features/features.mdoc
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Features"
|
||||
description: "Learn about the built-in features available in MakerKit."
|
||||
publishedAt: 2024-04-11
|
||||
order: 3
|
||||
status: "published"
|
||||
---
|
||||
|
||||
MakerKit comes with a rich set of features to help you build your SaaS quickly.
|
||||
|
||||
This section covers:
|
||||
- Team collaboration
|
||||
- File uploads
|
||||
- Email functionality
|
||||
- User management
|
||||
- And more built-in features
|
||||
398
apps/web/content/documentation/features/file-uploads.mdoc
Normal file
398
apps/web/content/documentation/features/file-uploads.mdoc
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
title: "File Uploads"
|
||||
description: "Handle file uploads with Supabase Storage."
|
||||
publishedAt: 2024-04-11
|
||||
order: 2
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Enable users to upload and manage files using Supabase Storage.
|
||||
|
||||
## Setup
|
||||
|
||||
### Create Storage Bucket
|
||||
|
||||
```sql
|
||||
-- Create a public bucket for avatars
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('avatars', 'avatars', true);
|
||||
|
||||
-- Create a private bucket for documents
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('documents', 'documents', false);
|
||||
```
|
||||
|
||||
### Set Storage Policies
|
||||
|
||||
```sql
|
||||
-- Allow users to upload their own avatars
|
||||
CREATE POLICY "Users can upload their own avatar"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'avatars' AND
|
||||
auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
-- Allow users to view their own avatars
|
||||
CREATE POLICY "Users can view their own avatar"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (
|
||||
bucket_id = 'avatars' AND
|
||||
auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
-- Allow users to delete their own avatars
|
||||
CREATE POLICY "Users can delete their own avatar"
|
||||
ON storage.objects FOR DELETE
|
||||
USING (
|
||||
bucket_id = 'avatars' AND
|
||||
auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
```
|
||||
|
||||
## Upload Component
|
||||
|
||||
### Basic File Upload
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { uploadFileAction } from '../_lib/actions';
|
||||
|
||||
export function FileUpload() {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const result = await uploadFileAction(formData);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('File uploaded successfully');
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setFile(e.files?.[0] || null)}
|
||||
accept="image/*"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export const uploadFileAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${user.id}/${Date.now()}.${fileExt}`;
|
||||
|
||||
const { data, error } = await client.storage
|
||||
.from('avatars')
|
||||
.upload(fileName, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Get public URL
|
||||
const { data: { publicUrl } } = client.storage
|
||||
.from('avatars')
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
path: data.path,
|
||||
};
|
||||
},
|
||||
{ auth: true }
|
||||
);
|
||||
```
|
||||
|
||||
## Drag and Drop Upload
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
export function DragDropUpload() {
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
for (const file of acceptedFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await uploadFileAction(formData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
|
||||
},
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer',
|
||||
isDragActive && 'border-primary bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<p>Drop files here...</p>
|
||||
) : (
|
||||
<p>Drag and drop files here, or click to select</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## File Validation
|
||||
|
||||
### Client-Side Validation
|
||||
|
||||
```typescript
|
||||
function validateFile(file: File) {
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File size must be less than 5MB');
|
||||
}
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('File type must be JPEG, PNG, or GIF');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Server-Side Validation
|
||||
|
||||
```typescript
|
||||
export const uploadFileAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
// Validate file size
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('File too large');
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
// Validate dimensions for images
|
||||
if (file.type.startsWith('image/')) {
|
||||
const dimensions = await getImageDimensions(file);
|
||||
if (dimensions.width > 4000 || dimensions.height > 4000) {
|
||||
throw new Error('Image dimensions too large');
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with upload...
|
||||
},
|
||||
{ auth: true }
|
||||
);
|
||||
```
|
||||
|
||||
## Image Optimization
|
||||
|
||||
### Resize on Upload
|
||||
|
||||
```typescript
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const uploadAvatarAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const file = formData.get('file') as File;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Resize image
|
||||
const resized = await sharp(buffer)
|
||||
.resize(200, 200, {
|
||||
fit: 'cover',
|
||||
position: 'center',
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const fileName = `${user.id}/avatar.jpg`;
|
||||
|
||||
const { error } = await client.storage
|
||||
.from('avatars')
|
||||
.upload(fileName, resized, {
|
||||
contentType: 'image/jpeg',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{ auth: true }
|
||||
);
|
||||
```
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function UploadWithProgress() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const client = getSupabaseBrowserClient();
|
||||
|
||||
const { error } = await client.storage
|
||||
.from('documents')
|
||||
.upload(`uploads/${file.name}`, file, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percent = (progressEvent.loaded / progressEvent.total) * 100;
|
||||
setProgress(Math.round(percent));
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="file" onChange={(e) => handleUpload(e.target.files![0])} />
|
||||
{progress > 0 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Downloading Files
|
||||
|
||||
### Get Public URL
|
||||
|
||||
```typescript
|
||||
const { data } = client.storage
|
||||
.from('avatars')
|
||||
.getPublicUrl('user-id/avatar.jpg');
|
||||
|
||||
console.log(data.publicUrl);
|
||||
```
|
||||
|
||||
### Download Private File
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client.storage
|
||||
.from('documents')
|
||||
.download('private-file.pdf');
|
||||
|
||||
if (data) {
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'file.pdf';
|
||||
a.click();
|
||||
}
|
||||
```
|
||||
|
||||
### Generate Signed URL
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client.storage
|
||||
.from('documents')
|
||||
.createSignedUrl('private-file.pdf', 3600); // 1 hour
|
||||
|
||||
console.log(data.signedUrl);
|
||||
```
|
||||
|
||||
## Deleting Files
|
||||
|
||||
```typescript
|
||||
export const deleteFileAction = enhanceAction(
|
||||
async (data, user) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client.storage
|
||||
.from('avatars')
|
||||
.remove([data.path]);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
auth: true,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Validate on both sides** - Client and server
|
||||
2. **Limit file sizes** - Prevent abuse
|
||||
3. **Sanitize filenames** - Remove special characters
|
||||
4. **Use unique names** - Prevent collisions
|
||||
5. **Optimize images** - Resize before upload
|
||||
6. **Set storage policies** - Control access
|
||||
7. **Monitor usage** - Track storage costs
|
||||
8. **Clean up unused files** - Regular maintenance
|
||||
9. **Use CDN** - For public files
|
||||
10. **Implement virus scanning** - For user uploads
|
||||
276
apps/web/content/documentation/features/team-collaboration.mdoc
Normal file
276
apps/web/content/documentation/features/team-collaboration.mdoc
Normal file
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: "Team Collaboration"
|
||||
description: "Manage team members, roles, and permissions in your application."
|
||||
publishedAt: 2024-04-11
|
||||
order: 1
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Enable teams to collaborate effectively with built-in team management features.
|
||||
|
||||
## Team Accounts
|
||||
|
||||
The application supports multi-tenant team accounts where multiple users can collaborate.
|
||||
|
||||
### Creating a Team
|
||||
|
||||
Users can create new team accounts:
|
||||
|
||||
```typescript
|
||||
import { createTeamAccount } from '~/lib/teams/create-team';
|
||||
|
||||
const team = await createTeamAccount({
|
||||
name: 'Acme Corp',
|
||||
slug: 'acme-corp',
|
||||
ownerId: currentUser.id,
|
||||
});
|
||||
```
|
||||
|
||||
### Team Workspace
|
||||
|
||||
Each team has its own workspace with isolated data:
|
||||
- Projects and resources
|
||||
- Team-specific settings
|
||||
- Billing and subscription
|
||||
- Activity logs
|
||||
|
||||
## Inviting Members
|
||||
|
||||
### Send Invitations
|
||||
|
||||
Invite new members to your team:
|
||||
|
||||
```typescript
|
||||
import { inviteTeamMember } from '~/lib/teams/invitations';
|
||||
|
||||
await inviteTeamMember({
|
||||
teamId: team.id,
|
||||
email: 'member@example.com',
|
||||
role: 'member',
|
||||
});
|
||||
```
|
||||
|
||||
### Invitation Flow
|
||||
|
||||
1. Owner sends invitation via email
|
||||
2. Recipient receives email with invitation link
|
||||
3. Recipient accepts invitation
|
||||
4. Member gains access to team workspace
|
||||
|
||||
### Managing Invitations
|
||||
|
||||
```tsx
|
||||
import { PendingInvitations } from '~/components/teams/pending-invitations';
|
||||
|
||||
<PendingInvitations teamId={team.id} />
|
||||
```
|
||||
|
||||
## Roles and Permissions
|
||||
|
||||
### Default Roles
|
||||
|
||||
**Owner**
|
||||
- Full access to team and settings
|
||||
- Manage billing and subscriptions
|
||||
- Invite and remove members
|
||||
- Delete team
|
||||
|
||||
**Admin**
|
||||
- Manage team members
|
||||
- Manage team resources
|
||||
- Cannot access billing
|
||||
- Cannot delete team
|
||||
|
||||
**Member**
|
||||
- View team resources
|
||||
- Create and edit own content
|
||||
- Limited team settings access
|
||||
|
||||
### Custom Roles
|
||||
|
||||
Define custom roles with specific permissions:
|
||||
|
||||
```typescript
|
||||
const customRole = {
|
||||
name: 'Editor',
|
||||
permissions: [
|
||||
'read:projects',
|
||||
'write:projects',
|
||||
'read:members',
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Checking Permissions
|
||||
|
||||
```typescript
|
||||
import { checkPermission } from '~/lib/teams/permissions';
|
||||
|
||||
const canEdit = await checkPermission(userId, teamId, 'write:projects');
|
||||
|
||||
if (!canEdit) {
|
||||
throw new Error('Insufficient permissions');
|
||||
}
|
||||
```
|
||||
|
||||
## Member Management
|
||||
|
||||
### Listing Members
|
||||
|
||||
```typescript
|
||||
import { getTeamMembers } from '~/lib/teams/members';
|
||||
|
||||
const members = await getTeamMembers(teamId);
|
||||
```
|
||||
|
||||
### Updating Member Role
|
||||
|
||||
```typescript
|
||||
import { updateMemberRole } from '~/lib/teams/members';
|
||||
|
||||
await updateMemberRole({
|
||||
memberId: member.id,
|
||||
role: 'admin',
|
||||
});
|
||||
```
|
||||
|
||||
### Removing Members
|
||||
|
||||
```typescript
|
||||
import { removeMember } from '~/lib/teams/members';
|
||||
|
||||
await removeMember(memberId);
|
||||
```
|
||||
|
||||
## Team Settings
|
||||
|
||||
### Updating Team Info
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { updateTeamAction } from '../_lib/server/actions';
|
||||
|
||||
export function TeamSettingsForm({ team }) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
await updateTeamAction({ teamId: team.id, ...data });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('name')} placeholder="Team name" />
|
||||
<textarea {...register('description')} placeholder="Description" />
|
||||
<button type="submit">Save Changes</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Team Avatar
|
||||
|
||||
```typescript
|
||||
import { uploadTeamAvatar } from '~/lib/teams/avatar';
|
||||
|
||||
const avatarUrl = await uploadTeamAvatar({
|
||||
teamId: team.id,
|
||||
file: avatarFile,
|
||||
});
|
||||
```
|
||||
|
||||
## Activity Log
|
||||
|
||||
Track team activity for transparency:
|
||||
|
||||
```typescript
|
||||
import { logActivity } from '~/lib/teams/activity';
|
||||
|
||||
await logActivity({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
action: 'member_invited',
|
||||
metadata: {
|
||||
invitedEmail: 'new@example.com',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Viewing Activity
|
||||
|
||||
```typescript
|
||||
import { getTeamActivity } from '~/lib/teams/activity';
|
||||
|
||||
const activities = await getTeamActivity(teamId, {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
```
|
||||
|
||||
## Team Switching
|
||||
|
||||
Allow users to switch between their teams:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
export function TeamSwitcher() {
|
||||
const { accounts, account } = useTeamAccountWorkspace();
|
||||
|
||||
return (
|
||||
<select
|
||||
value={account.id}
|
||||
onChange={(e) => switchTeam(e.target.value)}
|
||||
>
|
||||
{accounts.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notifications
|
||||
|
||||
### Member Joined
|
||||
|
||||
```typescript
|
||||
await createNotification({
|
||||
teamId: team.id,
|
||||
title: 'New Member',
|
||||
message: `${user.name} joined the team`,
|
||||
type: 'info',
|
||||
});
|
||||
```
|
||||
|
||||
### Role Changed
|
||||
|
||||
```typescript
|
||||
await createNotification({
|
||||
userId: member.userId,
|
||||
title: 'Role Updated',
|
||||
message: `Your role was changed to ${newRole}`,
|
||||
type: 'info',
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Clear role hierarchy** - Define roles that make sense for your use case
|
||||
2. **Principle of least privilege** - Give minimum required permissions
|
||||
3. **Audit trail** - Log important team actions
|
||||
4. **Easy onboarding** - Simple invitation process
|
||||
5. **Self-service** - Let members manage their own settings
|
||||
6. **Transparent billing** - Show usage and costs clearly
|
||||
@@ -0,0 +1,350 @@
|
||||
---
|
||||
title: "Configuration"
|
||||
description: "Configure your application settings and feature flags."
|
||||
publishedAt: 2024-04-11
|
||||
order: 4
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Customize your application behavior through configuration files.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
All configuration files are located in `apps/web/config/`:
|
||||
|
||||
```
|
||||
config/
|
||||
├── paths.config.ts # Route paths
|
||||
├── billing.config.ts # Billing & pricing
|
||||
├── feature-flags.config.ts # Feature toggles
|
||||
├── personal-account-navigation.config.tsx
|
||||
├── team-account-navigation.config.tsx
|
||||
└── i18n.settings.ts # Internationalization
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Control feature availability:
|
||||
|
||||
```typescript
|
||||
// config/feature-flags.config.ts
|
||||
export const featureFlags = {
|
||||
enableTeamAccounts: true,
|
||||
enableBilling: true,
|
||||
enableNotifications: true,
|
||||
enableFileUploads: false,
|
||||
enableAnalytics: true,
|
||||
enableChat: false,
|
||||
};
|
||||
```
|
||||
|
||||
### Using Feature Flags
|
||||
|
||||
```typescript
|
||||
import { featureFlags } from '~/config/feature-flags.config';
|
||||
|
||||
export function ConditionalFeature() {
|
||||
if (!featureFlags.enableChat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ChatWidget />;
|
||||
}
|
||||
```
|
||||
|
||||
## Path Configuration
|
||||
|
||||
Define application routes:
|
||||
|
||||
```typescript
|
||||
// config/paths.config.ts
|
||||
export const pathsConfig = {
|
||||
auth: {
|
||||
signIn: '/auth/sign-in',
|
||||
signUp: '/auth/sign-up',
|
||||
passwordReset: '/auth/password-reset',
|
||||
callback: '/auth/callback',
|
||||
},
|
||||
app: {
|
||||
home: '/home',
|
||||
personalAccount: '/home',
|
||||
teamAccount: '/home/[account]',
|
||||
settings: '/home/settings',
|
||||
billing: '/home/settings/billing',
|
||||
},
|
||||
admin: {
|
||||
home: '/admin',
|
||||
users: '/admin/users',
|
||||
analytics: '/admin/analytics',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Using Paths
|
||||
|
||||
```typescript
|
||||
import { pathsConfig } from '~/config/paths.config';
|
||||
import Link from 'next/link';
|
||||
|
||||
<Link href={pathsConfig.app.settings}>
|
||||
Settings
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Navigation Configuration
|
||||
|
||||
### Personal Account Navigation
|
||||
|
||||
```typescript
|
||||
// config/personal-account-navigation.config.tsx
|
||||
import { HomeIcon, SettingsIcon } from 'lucide-react';
|
||||
|
||||
export default [
|
||||
{
|
||||
label: 'common:routes.home',
|
||||
path: pathsConfig.app.personalAccount,
|
||||
Icon: <HomeIcon className="w-4" />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.settings',
|
||||
path: pathsConfig.app.settings,
|
||||
Icon: <SettingsIcon className="w-4" />,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Team Account Navigation
|
||||
|
||||
```typescript
|
||||
// config/team-account-navigation.config.tsx
|
||||
export default [
|
||||
{
|
||||
label: 'common:routes.dashboard',
|
||||
path: createPath(pathsConfig.app.teamAccount, account),
|
||||
Icon: <LayoutDashboardIcon className="w-4" />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.projects',
|
||||
path: createPath(pathsConfig.app.projects, account),
|
||||
Icon: <FolderIcon className="w-4" />,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.members',
|
||||
path: createPath(pathsConfig.app.members, account),
|
||||
Icon: <UsersIcon className="w-4" />,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Billing Configuration
|
||||
|
||||
```typescript
|
||||
// config/billing.config.ts
|
||||
export const billingConfig = {
|
||||
provider: 'stripe', // 'stripe' | 'paddle'
|
||||
enableTrial: true,
|
||||
trialDays: 14,
|
||||
|
||||
plans: [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
features: ['5 projects', 'Basic support'],
|
||||
limits: {
|
||||
projects: 5,
|
||||
members: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Professional',
|
||||
price: 29,
|
||||
interval: 'month',
|
||||
features: ['Unlimited projects', 'Priority support'],
|
||||
limits: {
|
||||
projects: -1, // unlimited
|
||||
members: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Internationalization
|
||||
|
||||
```typescript
|
||||
// lib/i18n/i18n.settings.ts
|
||||
export const i18nSettings = {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'es', 'fr', 'de'],
|
||||
defaultNamespace: 'common',
|
||||
namespaces: ['common', 'auth', 'billing', 'errors'],
|
||||
};
|
||||
```
|
||||
|
||||
## Email Configuration
|
||||
|
||||
```typescript
|
||||
// config/email.config.ts
|
||||
export const emailConfig = {
|
||||
from: {
|
||||
email: process.env.EMAIL_FROM || 'noreply@example.com',
|
||||
name: process.env.EMAIL_FROM_NAME || 'Your App',
|
||||
},
|
||||
provider: 'resend', // 'resend' | 'sendgrid' | 'postmark'
|
||||
};
|
||||
```
|
||||
|
||||
## SEO Configuration
|
||||
|
||||
```typescript
|
||||
// config/seo.config.ts
|
||||
export const seoConfig = {
|
||||
title: 'Your App Name',
|
||||
description: 'Your app description',
|
||||
ogImage: '/images/og-image.png',
|
||||
twitterHandle: '@yourapp',
|
||||
locale: 'en_US',
|
||||
|
||||
// Per-page overrides
|
||||
pages: {
|
||||
home: {
|
||||
title: 'Home - Your App',
|
||||
description: 'Welcome to your app',
|
||||
},
|
||||
pricing: {
|
||||
title: 'Pricing - Your App',
|
||||
description: 'Simple, transparent pricing',
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Theme Configuration
|
||||
|
||||
```typescript
|
||||
// config/theme.config.ts
|
||||
export const themeConfig = {
|
||||
defaultTheme: 'system', // 'light' | 'dark' | 'system'
|
||||
enableColorSchemeToggle: true,
|
||||
|
||||
colors: {
|
||||
primary: 'blue',
|
||||
accent: 'purple',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Analytics Configuration
|
||||
|
||||
```typescript
|
||||
// config/analytics.config.ts
|
||||
export const analyticsConfig = {
|
||||
googleAnalytics: {
|
||||
enabled: true,
|
||||
measurementId: process.env.NEXT_PUBLIC_GA_ID,
|
||||
},
|
||||
|
||||
posthog: {
|
||||
enabled: false,
|
||||
apiKey: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||
},
|
||||
|
||||
plausible: {
|
||||
enabled: false,
|
||||
domain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
```typescript
|
||||
// config/rate-limit.config.ts
|
||||
export const rateLimitConfig = {
|
||||
api: {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // requests per window
|
||||
},
|
||||
|
||||
auth: {
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5, // login attempts
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Upload Configuration
|
||||
|
||||
```typescript
|
||||
// config/upload.config.ts
|
||||
export const uploadConfig = {
|
||||
maxFileSize: 5 * 1024 * 1024, // 5MB
|
||||
allowedMimeTypes: [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
],
|
||||
|
||||
storage: {
|
||||
provider: 'supabase', // 'supabase' | 's3' | 'cloudinary'
|
||||
bucket: 'uploads',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Environment-Specific Config
|
||||
|
||||
```typescript
|
||||
// config/app.config.ts
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export const appConfig = {
|
||||
environment: process.env.NODE_ENV,
|
||||
apiUrl: isProd
|
||||
? 'https://api.yourapp.com'
|
||||
: 'http://localhost:3000/api',
|
||||
|
||||
features: {
|
||||
enableDebugTools: isDev,
|
||||
enableErrorReporting: isProd,
|
||||
enableAnalytics: isProd,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use environment variables** for secrets
|
||||
2. **Type your configs** for autocomplete and safety
|
||||
3. **Document options** with comments
|
||||
4. **Validate on startup** to catch errors early
|
||||
5. **Keep configs simple** - avoid complex logic
|
||||
6. **Use feature flags** for gradual rollouts
|
||||
7. **Environment-specific values** for dev/prod differences
|
||||
|
||||
## Loading Configuration
|
||||
|
||||
Configs are automatically loaded but you can validate:
|
||||
|
||||
```typescript
|
||||
// lib/config/validate-config.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
apiUrl: z.string().url(),
|
||||
enableFeatureX: z.boolean(),
|
||||
});
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
return ConfigSchema.parse(config);
|
||||
}
|
||||
```
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
title: "Getting started with Makerkit"
|
||||
title: "Introduction"
|
||||
description: "Makerkit is a SaaS Starter Kit that helps you build a SaaS. Learn how to get started with Makerkit."
|
||||
publishedAt: 2024-04-11
|
||||
order: 0
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Makerkit is a SaaS Starter Kit that helps you build a SaaS. It provides you with a set of tools and best practices to help you build a SaaS quickly and efficiently.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
title: "Installing Dependencies"
|
||||
description: "Learn how to install dependencies for your project."
|
||||
publishedAt: 2024-04-11
|
||||
order: 0
|
||||
order: 1
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
To install dependencies in your project, please install `pnpm` by running the following command:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
title: "Project Structure"
|
||||
description: "Understanding the monorepo structure and organization."
|
||||
publishedAt: 2024-04-11
|
||||
order: 3
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Learn how the codebase is organized and where to find things.
|
||||
|
||||
## Monorepo Overview
|
||||
|
||||
This project uses Turborepo to manage a monorepo with multiple apps and packages.
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── apps/ # Applications
|
||||
│ ├── web/ # Main Next.js app
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ └── dev-tool/ # Development utilities
|
||||
├── packages/ # Shared packages
|
||||
│ ├── features/ # Feature packages
|
||||
│ ├── ui/ # UI components
|
||||
│ ├── supabase/ # Supabase utilities
|
||||
│ └── billing/ # Billing integrations
|
||||
├── tooling/ # Development tools
|
||||
├── supabase/ # Database schema & migrations
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## Main Application (`apps/web`)
|
||||
|
||||
The primary Next.js application:
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (marketing)/ # Public pages
|
||||
│ ├── (auth)/ # Authentication
|
||||
│ ├── home/ # Main application
|
||||
│ │ ├── (user)/ # Personal account
|
||||
│ │ └── [account]/ # Team accounts
|
||||
│ ├── admin/ # Admin panel
|
||||
│ └── api/ # API routes
|
||||
├── components/ # Shared components
|
||||
├── config/ # Configuration files
|
||||
├── lib/ # Utility functions
|
||||
├── public/ # Static assets
|
||||
└── supabase/ # Supabase setup
|
||||
```
|
||||
|
||||
## Route Structure
|
||||
|
||||
### Marketing Routes (`(marketing)`)
|
||||
|
||||
Public-facing pages:
|
||||
|
||||
```
|
||||
app/(marketing)/
|
||||
├── page.tsx # Landing page
|
||||
├── pricing/ # Pricing page
|
||||
├── blog/ # Blog
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
### Auth Routes (`(auth)`)
|
||||
|
||||
Authentication pages:
|
||||
|
||||
```
|
||||
app/(auth)/
|
||||
├── sign-in/
|
||||
├── sign-up/
|
||||
├── password-reset/
|
||||
└── verify/
|
||||
```
|
||||
|
||||
### Application Routes (`home`)
|
||||
|
||||
Main application:
|
||||
|
||||
```
|
||||
app/home/
|
||||
├── (user)/ # Personal account context
|
||||
│ ├── page.tsx # Personal dashboard
|
||||
│ ├── settings/ # User settings
|
||||
│ └── projects/ # Personal projects
|
||||
└── [account]/ # Team account context
|
||||
├── page.tsx # Team dashboard
|
||||
├── settings/ # Team settings
|
||||
├── projects/ # Team projects
|
||||
└── members/ # Team members
|
||||
```
|
||||
|
||||
## Packages Structure
|
||||
|
||||
### Feature Packages (`packages/features/`)
|
||||
|
||||
Modular features:
|
||||
|
||||
```
|
||||
packages/features/
|
||||
├── accounts/ # Account management
|
||||
├── auth/ # Authentication
|
||||
├── team-accounts/ # Team features
|
||||
├── billing/ # Billing & subscriptions
|
||||
├── admin/ # Admin features
|
||||
└── notifications/ # Notification system
|
||||
```
|
||||
|
||||
### UI Package (`packages/ui/`)
|
||||
|
||||
Shared UI components:
|
||||
|
||||
```
|
||||
packages/ui/
|
||||
└── src/
|
||||
├── components/ # Shadcn UI components
|
||||
│ ├── button.tsx
|
||||
│ ├── input.tsx
|
||||
│ ├── dialog.tsx
|
||||
│ └── ...
|
||||
└── utils/ # UI utilities
|
||||
```
|
||||
|
||||
### Supabase Package (`packages/supabase/`)
|
||||
|
||||
Database utilities:
|
||||
|
||||
```
|
||||
packages/supabase/
|
||||
├── schema/ # Declarative schemas
|
||||
│ ├── accounts.schema.ts
|
||||
│ ├── auth.schema.ts
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── clients/ # Supabase clients
|
||||
│ ├── hooks/ # React hooks
|
||||
│ └── middleware/ # Auth middleware
|
||||
└── migrations/ # SQL migrations
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Root Level
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── package.json # Root package.json
|
||||
├── turbo.json # Turborepo config
|
||||
├── pnpm-workspace.yaml # PNPM workspace
|
||||
└── tsconfig.json # Base TypeScript config
|
||||
```
|
||||
|
||||
### Application Level
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── next.config.js # Next.js configuration
|
||||
├── tailwind.config.ts # Tailwind CSS
|
||||
├── tsconfig.json # TypeScript config
|
||||
└── .env.local # Environment variables
|
||||
```
|
||||
|
||||
### Feature Configuration
|
||||
|
||||
```
|
||||
apps/web/config/
|
||||
├── paths.config.ts # Route paths
|
||||
├── billing.config.ts # Billing settings
|
||||
├── feature-flags.config.ts # Feature flags
|
||||
├── personal-account-navigation.config.tsx
|
||||
└── team-account-navigation.config.tsx
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files
|
||||
|
||||
- **Pages**: `page.tsx` (Next.js convention)
|
||||
- **Layouts**: `layout.tsx`
|
||||
- **Components**: `kebab-case.tsx`
|
||||
- **Utilities**: `kebab-case.ts`
|
||||
- **Types**: `types.ts` or `component-name.types.ts`
|
||||
|
||||
### Directories
|
||||
|
||||
- **Route segments**: `[param]` for dynamic
|
||||
- **Route groups**: `(group)` for organization
|
||||
- **Private folders**: `_components`, `_lib`
|
||||
- **Parallel routes**: `@folder`
|
||||
|
||||
### Code Organization
|
||||
|
||||
```
|
||||
feature/
|
||||
├── page.tsx # Route page
|
||||
├── layout.tsx # Route layout
|
||||
├── loading.tsx # Loading state
|
||||
├── error.tsx # Error boundary
|
||||
├── _components/ # Private components
|
||||
│ ├── feature-list.tsx
|
||||
│ └── feature-form.tsx
|
||||
└── _lib/ # Private utilities
|
||||
├── server/ # Server-side code
|
||||
│ ├── loaders.ts
|
||||
│ └── actions.ts
|
||||
└── schemas/ # Validation schemas
|
||||
└── feature.schema.ts
|
||||
```
|
||||
|
||||
## Import Paths
|
||||
|
||||
Use TypeScript path aliases:
|
||||
|
||||
```typescript
|
||||
// Absolute imports from packages
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { createClient } from '@kit/supabase/server-client';
|
||||
|
||||
// Relative imports within app
|
||||
import { FeatureList } from './_components/feature-list';
|
||||
import { loadData } from './_lib/server/loaders';
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep route-specific code private** - Use `_components` and `_lib`
|
||||
2. **Share reusable code** - Extract to packages
|
||||
3. **Collocate related files** - Keep files near where they're used
|
||||
4. **Use consistent naming** - Follow established patterns
|
||||
5. **Organize by feature** - Not by file type
|
||||
|
||||
## Finding Your Way
|
||||
|
||||
| Looking for... | Location |
|
||||
|----------------|----------|
|
||||
| UI Components | `packages/ui/src/components/` |
|
||||
| Database Schema | `packages/supabase/schema/` |
|
||||
| API Routes | `apps/web/app/api/` |
|
||||
| Auth Logic | `packages/features/auth/` |
|
||||
| Billing Code | `packages/features/billing/` |
|
||||
| Team Features | `packages/features/team-accounts/` |
|
||||
| Config Files | `apps/web/config/` |
|
||||
| Types | `*.types.ts` files throughout |
|
||||
133
apps/web/content/documentation/getting-started/quick-start.mdoc
Normal file
133
apps/web/content/documentation/getting-started/quick-start.mdoc
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: "Quick Start"
|
||||
description: "Get your application running in minutes with this quick start guide."
|
||||
publishedAt: 2024-04-11
|
||||
order: 2
|
||||
status: "published"
|
||||
---
|
||||
|
||||
> **Note:** This is mock/placeholder content for demonstration purposes.
|
||||
|
||||
Get your development environment up and running quickly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
- **Node.js** 18.x or higher
|
||||
- **pnpm** 8.x or higher
|
||||
- **Git** for version control
|
||||
- A **Supabase** account (free tier works great)
|
||||
|
||||
## Step 1: Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourorg/yourapp.git
|
||||
cd yourapp
|
||||
```
|
||||
|
||||
## Step 2: Install Dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
This will install all required dependencies across the monorepo.
|
||||
|
||||
## Step 3: Set Up Environment Variables
|
||||
|
||||
Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
```
|
||||
|
||||
Update the following variables:
|
||||
|
||||
```bash
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
|
||||
|
||||
# Application
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Step 4: Start Supabase
|
||||
|
||||
Start your local Supabase instance:
|
||||
|
||||
```bash
|
||||
pnpm supabase:web:start
|
||||
```
|
||||
|
||||
This will:
|
||||
- Start PostgreSQL database
|
||||
- Start Supabase Studio (localhost:54323)
|
||||
- Apply all migrations
|
||||
- Seed initial data
|
||||
|
||||
## Step 5: Start Development Server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Your application will be available at:
|
||||
- **App**: http://localhost:3000
|
||||
- **Supabase Studio**: http://localhost:54323
|
||||
- **Email Testing**: http://localhost:54324
|
||||
|
||||
## Step 6: Create Your First User
|
||||
|
||||
1. Navigate to http://localhost:3000/auth/sign-up
|
||||
2. Enter your email and password
|
||||
3. Check http://localhost:54324 for the confirmation email
|
||||
4. Click the confirmation link
|
||||
5. You're ready to go!
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that your app is running:
|
||||
|
||||
1. **Explore the Dashboard** - Check out the main features
|
||||
2. **Review the Code** - Familiarize yourself with the structure
|
||||
3. **Read the Docs** - Learn about key concepts
|
||||
4. **Build Your Feature** - Start customizing
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
If port 3000 is already in use:
|
||||
|
||||
```bash
|
||||
# Find and kill the process
|
||||
lsof -i :3000
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Supabase Won't Start
|
||||
|
||||
Try resetting Supabase:
|
||||
|
||||
```bash
|
||||
pnpm supabase:web:stop
|
||||
docker system prune -a # Clean Docker
|
||||
pnpm supabase:web:start
|
||||
```
|
||||
|
||||
### Database Connection Error
|
||||
|
||||
Ensure Docker is running and restart Supabase:
|
||||
|
||||
```bash
|
||||
docker ps # Check Docker is running
|
||||
pnpm supabase:web:reset
|
||||
```
|
||||
|
||||
## What's Next?
|
||||
|
||||
- Learn about the [project structure](/docs/getting-started/project-structure)
|
||||
- Understand [configuration options](/docs/getting-started/configuration)
|
||||
- Follow [best practices](/docs/development/workflow)
|
||||
Reference in New Issue
Block a user