refactor: consolidate AGENTS.md and CLAUDE.md files, update tech stac… (#444)

* refactor: consolidate AGENTS.md and CLAUDE.md files, update tech stack and architecture details

- Merged content from CLAUDE.md into AGENTS.md for better organization.
- Updated tech stack section to reflect the current technologies used, including Next.js, Supabase, and Tailwind CSS.
- Enhanced monorepo structure documentation with detailed directory purposes.
- Streamlined multi-tenant architecture explanation and essential commands.
- Added key patterns for naming conventions and server actions.
- Removed outdated agent files related to Playwright and PostgreSQL, ensuring a cleaner codebase.
- Bumped version to 2.23.7 to reflect changes.
This commit is contained in:
Giancarlo Buomprisco
2026-01-18 10:44:40 +01:00
committed by GitHub
parent bebd56238b
commit cfa137795b
61 changed files with 3636 additions and 9522 deletions

View File

@@ -1,105 +1 @@
# @kit/analytics Package
Analytics package providing a unified interface for tracking events, page views, and user identification across multiple analytics providers.
## Architecture
- **AnalyticsManager**: Central manager orchestrating multiple analytics providers
- **AnalyticsService**: Interface defining analytics operations (track, identify, pageView)
- **Provider System**: Pluggable providers (currently includes NullAnalyticsService)
- **Client/Server Split**: Separate entry points for client and server-side usage
## Usage
### Basic Import
```typescript
// Client-side
import { analytics } from '@kit/analytics';
// Server-side
import { analytics } from '@kit/analytics/server';
```
### Core Methods
```typescript
// Track events
await analytics.trackEvent('button_clicked', {
button_id: 'signup',
page: 'homepage'
});
// Track page views
await analytics.trackPageView('/dashboard');
// Identify users
await analytics.identify('user123', {
email: 'user@example.com',
plan: 'premium'
});
```
Page views and user identification are handled by the plugin by default.
## Creating Custom Providers
Implement the `AnalyticsService` interface:
```typescript
import { AnalyticsService } from '@kit/analytics';
class CustomAnalyticsService implements AnalyticsService {
async initialize(): Promise<void> {
// Initialize your analytics service
}
async trackEvent(name: string, properties?: Record<string, string | string[]>): Promise<void> {
// Track event implementation
}
async trackPageView(path: string): Promise<void> {
// Track page view implementation
}
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
// Identify user implementation
}
}
```
## Default Behavior
- Uses `NullAnalyticsService` when no providers are active
- All methods return Promises that resolve to arrays of provider results
- Console debug logging when no active services or using null service
- Graceful error handling with console warnings for missing providers
## Server-Side Analytics
When using PostHog, you can track events server-side for better reliability and privacy:
```typescript
import { analytics } from '@kit/analytics/server';
// Server-side event tracking (e.g., in API routes)
export async function POST(request: Request) {
// ... handle request
// Track server-side events
await analytics.trackEvent('api_call', {
endpoint: '/api/users',
method: 'POST',
user_id: userId,
});
return Response.json({ success: true });
}
// Track user registration server-side
await analytics.identify(user.id, {
email: user.email,
created_at: user.created_at,
plan: user.plan,
});
```
@AGENTS.md

View File

@@ -1,289 +1 @@
# Feature Packages Instructions
This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications.
## Feature Package Structure
- `accounts/` - Personal account management
- `admin/` - Super admin functionality
- `auth/` - Authentication features
- `notifications/` - Notification system
- `team-accounts/` - Team account management
## Account Services
### Personal Accounts API
Located at: `packages/features/accounts/src/server/api.ts`
```typescript
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
// Get account data
const account = await api.getAccount(accountId);
// Get account workspace
const workspace = await api.getAccountWorkspace();
// Load user accounts
const accounts = await api.loadUserAccounts();
// Get subscription
const subscription = await api.getSubscription(accountId);
// Get customer ID
const customerId = await api.getCustomerId(accountId);
```
### Team Accounts API
Located at: `packages/features/team-accounts/src/server/api.ts`
```typescript
import { createTeamAccountsApi } from '@kit/team-accounts/api';
const api = createTeamAccountsApi(client);
// Get team account by slug
const account = await api.getTeamAccount(slug);
// Get account workspace
const workspace = await api.getAccountWorkspace(slug);
// Check permissions
const hasPermission = await api.hasPermission({
accountId,
userId,
permission: 'billing.manage'
});
// Get members count
const count = await api.getMembersCount(accountId);
// Get invitation
const invitation = await api.getInvitation(adminClient, token);
```
## Workspace Contexts
### Personal Account Context
Use in `apps/web/app/home/(user)` routes:
```tsx
import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace';
function PersonalComponent() {
const { user, account } = useUserWorkspace();
// user: authenticated user data
// account: personal account data
return <div>Welcome {user.name}</div>;
}
```
Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx`
### Team Account Context
Use in `apps/web/app/home/[account]` routes:
```tsx
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
function TeamComponent() {
const { account, user, accounts } = useTeamAccountWorkspace();
// account: current team account data
// user: authenticated user data
// accounts: all accounts user has access to
return <div>Team: {account.name}</div>;
}
```
Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
## Billing Services
### Personal Billing
Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts`
```typescript
// Personal billing operations
// - Manage individual user subscriptions
// - Handle personal account payments
// - Process individual billing changes
```
### Team Billing
Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts`
```typescript
// Team billing operations
// - Manage team subscriptions
// - Handle team payments
// - Process team billing changes
```
### Per-Seat Billing Service
Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts`
```typescript
import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing';
const billingService = createAccountPerSeatBillingService(client);
// Increase seats when adding team members
await billingService.increaseSeats(accountId);
// Decrease seats when removing team members
await billingService.decreaseSeats(accountId);
// Get per-seat subscription item
const subscription = await billingService.getPerSeatSubscriptionItem(accountId);
```
## Authentication Features
### OTP for Sensitive Operations
Use one-time tokens from `packages/otp/src/api/index.ts`:
```tsx
import { VerifyOtpForm } from '@kit/otp/components';
<VerifyOtpForm
purpose="account-deletion"
email={user.email}
onSuccess={(otp) => {
// Proceed with verified operation
handleSensitiveOperation(otp);
}}
CancelButton={<Button variant="outline">Cancel</Button>}
/>
```
## Admin Features
### Super Admin Protection
For admin routes, use `AdminGuard`:
```tsx
import { AdminGuard } from '@kit/admin/components/admin-guard';
function AdminPage() {
return (
<div>
<h1>Admin Dashboard</h1>
{/* Admin content */}
</div>
);
}
// Wrap the page component
export default AdminGuard(AdminPage);
```
### Admin Service
Located at: `packages/features/admin/src/lib/server/services/admin.service.ts`
```typescript
// Admin service operations
// - Manage all accounts
// - Handle admin-level operations
// - Access system-wide data
```
### Checking Admin Status
```typescript
import { isSuperAdmin } from '@kit/admin';
function criticalAdminFeature() {
const isAdmin = await isSuperAdmin(client);
if (!isAdmin) {
throw new Error('Access denied: Admin privileges required');
}
// ...
}
```
## Error Handling & Logging
### Structured Logging
Use logger from `packages/shared/src/logger/logger.ts`:
```typescript
import { getLogger } from '@kit/shared/logger';
async function featureOperation() {
const logger = await getLogger();
const ctx = {
name: 'feature-operation',
userId: user.id,
accountId: account.id
};
try {
logger.info(ctx, 'Starting feature operation');
// Perform operation
const result = await performOperation();
logger.info({ ...ctx, result }, 'Feature operation completed');
return result;
} catch (error) {
logger.error({ ...ctx, error }, 'Feature operation failed');
throw error;
}
}
```
## Permission Patterns
### Team Permissions
```typescript
import { createTeamAccountsApi } from '@kit/team-accounts/api';
const api = createTeamAccountsApi(client);
// Check if user has specific permission on account
const canManageBilling = await api.hasPermission({
accountId,
userId,
permission: 'billing.manage'
});
if (!canManageBilling) {
throw new Error('Insufficient permissions');
}
```
### Account Ownership
```typescript
// Check if user is account owner (works for both personal and team accounts)
const isOwner = await client.rpc('is_account_owner', {
account_id: accountId
});
if (!isOwner) {
throw new Error('Only account owners can perform this action');
}
```
@AGENTS.md

View File

@@ -49,7 +49,9 @@ export async function initializeI18nClient(
cookieMinutes: 60 * 24 * 365, // 1 year
cookieOptions: {
sameSite: 'lax',
secure: typeof window !== 'undefined' && window.location.protocol === 'https:',
secure:
typeof window !== 'undefined' &&
window.location.protocol === 'https:',
path: '/',
},
},

View File

@@ -1,66 +1 @@
# Email Service Instructions
This file contains guidance for working with the email service supporting Resend and Nodemailer.
## Basic Usage
```typescript
import { getMailer } from '@kit/mailers';
import { renderAccountDeleteEmail } from '@kit/email-templates';
async function sendSimpleEmail() {
// Get mailer instance
const mailer = await getMailer();
// Send simple email
await mailer.sendEmail({
to: 'user@example.com',
from: 'noreply@yourdomain.com',
subject: 'Welcome!',
html: '<h1>Welcome!</h1><p>Thank you for joining us.</p>',
});
}
async function sendComplexEmail() {
// Send with email template
const { html, subject } = await renderAccountDeleteEmail({
userDisplayName: user.name,
productName: 'My SaaS App',
});
await mailer.sendEmail({
to: user.email,
from: 'noreply@yourdomain.com',
subject,
html,
});
}
```
## Email Templates
Email templates are located in `@kit/email-templates` and return `{ html, subject }`:
```typescript
import {
renderAccountDeleteEmail,
renderWelcomeEmail,
renderPasswordResetEmail
} from '@kit/email-templates';
// Render template
const { html, subject } = await renderWelcomeEmail({
userDisplayName: 'John Doe',
loginUrl: 'https://app.com/login'
});
// Send rendered email
const mailer = await getMailer();
await mailer.sendEmail({
to: user.email,
from: 'welcome@yourdomain.com',
subject,
html,
});
```
@AGENTS.md

View File

@@ -1,478 +1,58 @@
# Next.js Utilities Instructions
# Next.js Utilities
This file contains instructions for working with Next.js utilities including server actions and route handlers.
## Quick Reference
| Function | Import | Purpose |
|----------|--------|---------|
| `enhanceAction` | `@kit/next/actions` | Server actions with auth + validation |
| `enhanceRouteHandler` | `@kit/next/routes` | API routes with auth + validation |
## Guidelines
- Don't use Server Actions for data-fetching, use for mutations only
- Best Practice: Keep actions light, move business logic to ad-hoc services
- Authorization logic must be defined in RLS and DB, not Server Actions or application code (unless using the admin client, use sparinlgy!)
- Do not expose sensitive data
- Log async operations
- Validate body with Zod
- Use 'use server' at the top of the file. No need for 'server only';
- Server Actions for mutations only, not data-fetching
- Keep actions light - move business logic to services
- Authorization via RLS, not application code
- Use `'use server'` at top of file
- Always validate with Zod schema
## Server Actions Implementation
## Skills
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`.
For detailed implementation patterns:
- `/server-action-builder` - Complete server action workflow
Define a schema:
```tsx
import { z } from 'zod';
// Define your schema in its own file
export const CreateNoteSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
accountId: z.string().uuid('Invalid account ID'),
});
```
Then we define a service for crossing the network boundary:
```tsx
import { CreateNoteSchema } from '../schemas/notes.schemas.ts';
import * as z from 'zod';
export function createNotesService() {
return new NotesService();
}
class NotesService {
createNote(data: z.infer<CreateNoteSchema>) {
const client = getSupabaseServerClient();
const { data: note, error } = await client
.from('notes')
.insert({
title: data.title,
content: data.content,
account_id: data.accountId,
user_id: user.id,
})
.select()
.single();
if (error) {
throw error;
}
}
}
```
Finally, we use Server Actions for exposing POST handlers:
## Server Action Pattern
```typescript
'use server';
import { enhanceAction } from '@kit/next/actions';
import { createNotesService } from '../notes.service.ts';
export const createNoteAction = enhanceAction(
async function (data, user) {
// data is automatically validated against the schema
// user is automatically authenticated if auth: true
const service = createNotesService();
const logger = await getLogger();
logger.info({
userId: user.id,
}, `Creating note...`);
const { data: note, error } = await service.createNote(data);
if (error) {
logger.error({
error: error.message
}, `Error creating note`);
throw error;
}
logger.info({
noteId: note.id
}, `Note successfully created`);
return {
success: true,
note
};
},
{
auth: true, // Require authentication (true by default, can omit)
schema: CreateNoteSchema, // Validate input with Zod
},
);
```
### Server Action Examples
- Team billing: `@apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
- Personal settings: `@apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
### Server Action Options
```typescript
export const myAction = enhanceAction(
async function (data, user) {
// data: validated input data
// user: authenticated user (if auth: true)
// data: validated, user: authenticated
return { success: true };
},
{
auth: true, // Require authentication (default: false)
schema: MySchema, // Zod schema for validation (optional)
// Additional options available
auth: true,
schema: MySchema,
},
);
```
## Route Handlers (API Routes)
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`.
### Guidelines
- Use when data must be exposed to externally
- Use for receiving requests from external clients (such as webhooks)
- Can be used for fetching data to client side fetchers (such as React Query) if cannot use client-side Supabase queries
### Usage
## Route Handler Pattern
```typescript
import { enhanceRouteHandler } from '@kit/next/routes';
import { NextResponse } from 'next/server';
import { z } from 'zod';
// Define your schema
const CreateItemSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
});
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// body is validated against schema
// user is available if auth: true
// request is the original NextRequest
const client = getSupabaseServerClient();
const { data, error } = await client
.from('items')
.insert({
name: body.name,
description: body.description,
user_id: user.id,
})
.select()
.single();
if (error) {
return NextResponse.json(
{ error: 'Failed to create item' },
{ status: 500 }
);
}
return NextResponse.json({ success: true, data });
},
{
auth: true, // Require authentication
schema: CreateItemSchema, // Validate request body
},
);
export const GET = enhanceRouteHandler(
async function ({ user, request }) {
const url = new URL(request.url);
const limit = url.searchParams.get('limit') || '10';
const client = getSupabaseServerClient();
const { data, error } = await client
.from('items')
.select('*')
.eq('user_id', user.id)
.limit(parseInt(limit));
if (error) {
return NextResponse.json(
{ error: 'Failed to fetch items' },
{ status: 500 }
);
}
return NextResponse.json({ data });
},
{
auth: true,
// No schema needed for GET requests
},
);
```
### Route Handler Options
```typescript
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// Handler function
return NextResponse.json({ success: true });
},
{
auth: true, // Require authentication (default: false)
schema: MySchema, // Zod schema for body validation (optional)
// Additional options available
},
{ auth: true, schema: MySchema },
);
```
## Revalidation
- Use `revalidatePath` for revalidating data after a migration.
- Avoid calling `router.refresh()` or `router.push()` following a Server Action. Use `revalidatePath` and `redirect` from the server action instead.
## Error Handling Patterns
### Server Actions with Error Handling
```typescript
export const createNoteAction = enhanceAction(
async function (data, user) {
const logger = await getLogger();
const ctx = { name: 'create-note', userId: user.id };
try {
logger.info(ctx, 'Creating note');
const client = getSupabaseServerClient();
const { data: note, error } = await client
.from('notes')
.insert({
title: data.title,
content: data.content,
user_id: user.id,
})
.select()
.single();
if (error) {
logger.error({ ...ctx, error }, 'Failed to create note');
throw error;
}
logger.info({ ...ctx, noteId: note.id }, 'Note created successfully');
return { success: true, note };
} catch (error) {
if (!isRedirectError(error)) {
logger.error({ ...ctx, error }, 'Create note action failed');
throw error;
}
}
},
{
auth: true,
schema: CreateNoteSchema,
},
);
```
### Server Action Redirects - Client Handling
When server actions call `redirect()`, it throws a special error that should NOT be treated as a failure:
```typescript
import { isRedirectError } from 'next/dist/client/components/redirect-error';
async function handleSubmit(formData: FormData) {
try {
await myServerAction(formData);
} catch (error) {
// Don't treat redirects as errors
if (!isRedirectError(error)) {
// Handle actual errors
toast.error('Something went wrong');
}
}
}
### Route Handler with Error Handling
```typescript
export const POST = enhanceRouteHandler(
async function ({ body, user }) {
const logger = await getLogger();
const ctx = { name: 'api-create-item', userId: user.id };
try {
logger.info(ctx, 'Processing API request');
// Process request
const result = await processRequest(body, user);
logger.info({ ...ctx, result }, 'API request successful');
return NextResponse.json({ success: true, data: result });
} catch (error) {
logger.error({ ...ctx, error }, 'API request failed');
if (error.message.includes('validation')) {
return NextResponse.json(
{ error: 'Invalid input data' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
},
{
auth: true,
schema: CreateItemSchema,
},
);
```
## Client-Side Integration
### Using Server Actions in Components
```tsx
'use client';
import { useTransition } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@kit/ui/sonner';
import { Button } from '@kit/ui/button';
import { createNoteAction } from './actions';
import { CreateNoteSchema } from './schemas';
function CreateNoteForm() {
const [isPending, startTransition] = useTransition();
const form = useForm({
resolver: zodResolver(CreateNoteSchema),
defaultValues: {
title: '',
content: '',
},
});
const onSubmit = (data) => {
startTransition(async () => {
try {
const result = await createNoteAction(data);
if (result.success) {
toast.success('Note created successfully!');
form.reset();
}
} catch (error) {
toast.error('Failed to create note');
console.error('Create note error:', error);
}
});
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Note'}
</Button>
</form>
);
}
```
NB: When using `redirect`, we must handle it using `isRedirectError` otherwise we display an error after the server action succeeds
### Using Route Handlers with Fetch
```typescript
'use client';
async function createItem(data: CreateItemInput) {
const response = await fetch('/api/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create item');
}
return response.json();
}
// Usage in component
const handleCreateItem = async (data) => {
try {
const result = await createItem(data);
toast.success('Item created successfully!');
return result;
} catch (error) {
toast.error('Failed to create item');
throw error;
}
};
```
### Authorization Checks
```typescript
export const deleteAccountAction = enhanceAction(
async function (data, user) {
const client = getSupabaseServerClient();
// Verify user owns the account
const { data: account, error } = await client
.from('accounts')
.select('id, primary_owner_user_id')
.eq('id', data.accountId)
.single();
if (error || !account) {
throw new Error('Account not found');
}
if (account.primary_owner_user_id !== user.id) {
throw new Error('Only account owners can delete accounts');
}
// Additional checks
const hasActiveSubscription = await client
.rpc('has_active_subscription', { account_id: data.accountId });
if (hasActiveSubscription) {
throw new Error('Cannot delete account with active subscription');
}
// Proceed with deletion
await deleteAccount(data.accountId);
return { success: true };
},
{
auth: true,
schema: DeleteAccountSchema,
},
);
```
- Use `revalidatePath` after mutations
- Never use `router.refresh()` or `router.push()` after Server Actions

View File

@@ -1,478 +1 @@
# Next.js Utilities Instructions
This file contains instructions for working with Next.js utilities including server actions and route handlers.
## Guidelines
- Don't use Server Actions for data-fetching, use for mutations only
- Best Practice: Keep actions light, move business logic to ad-hoc services
- Authorization logic must be defined in RLS and DB, not Server Actions or application code (unless using the admin client, use sparinlgy!)
- Do not expose sensitive data
- Log async operations
- Validate body with Zod
- Use 'use server' at the top of the file. No need for 'server only';
## Server Actions Implementation
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`.
Define a schema:
```tsx
import { z } from 'zod';
// Define your schema in its own file
export const CreateNoteSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
accountId: z.string().uuid('Invalid account ID'),
});
```
Then we define a service for crossing the network boundary:
```tsx
import { CreateNoteSchema } from '../schemas/notes.schemas.ts';
import * as z from 'zod';
export function createNotesService() {
return new NotesService();
}
class NotesService {
createNote(data: z.infer<CreateNoteSchema>) {
const client = getSupabaseServerClient();
const { data: note, error } = await client
.from('notes')
.insert({
title: data.title,
content: data.content,
account_id: data.accountId,
user_id: user.id,
})
.select()
.single();
if (error) {
throw error;
}
}
}
```
Finally, we use Server Actions for exposing POST handlers:
```typescript
'use server';
import { enhanceAction } from '@kit/next/actions';
import { createNotesService } from '../notes.service.ts';
export const createNoteAction = enhanceAction(
async function (data, user) {
// data is automatically validated against the schema
// user is automatically authenticated if auth: true
const service = createNotesService();
const logger = await getLogger();
logger.info({
userId: user.id,
}, `Creating note...`);
const { data: note, error } = await service.createNote(data);
if (error) {
logger.error({
error: error.message
}, `Error creating note`);
throw error;
}
logger.info({
noteId: note.id
}, `Note successfully created`);
return {
success: true,
note
};
},
{
auth: true, // Require authentication (true by default, can omit)
schema: CreateNoteSchema, // Validate input with Zod
},
);
```
### Server Action Examples
- Team billing: `@apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
- Personal settings: `@apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
### Server Action Options
```typescript
export const myAction = enhanceAction(
async function (data, user) {
// data: validated input data
// user: authenticated user (if auth: true)
return { success: true };
},
{
auth: true, // Require authentication (default: false)
schema: MySchema, // Zod schema for validation (optional)
// Additional options available
},
);
```
## Route Handlers (API Routes)
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`.
### Guidelines
- Use when data must be exposed to externally
- Use for receiving requests from external clients (such as webhooks)
- Can be used for fetching data to client side fetchers (such as React Query) if cannot use client-side Supabase queries
### Usage
```typescript
import { enhanceRouteHandler } from '@kit/next/routes';
import { NextResponse } from 'next/server';
import { z } from 'zod';
// Define your schema
const CreateItemSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
});
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// body is validated against schema
// user is available if auth: true
// request is the original NextRequest
const client = getSupabaseServerClient();
const { data, error } = await client
.from('items')
.insert({
name: body.name,
description: body.description,
user_id: user.id,
})
.select()
.single();
if (error) {
return NextResponse.json(
{ error: 'Failed to create item' },
{ status: 500 }
);
}
return NextResponse.json({ success: true, data });
},
{
auth: true, // Require authentication
schema: CreateItemSchema, // Validate request body
},
);
export const GET = enhanceRouteHandler(
async function ({ user, request }) {
const url = new URL(request.url);
const limit = url.searchParams.get('limit') || '10';
const client = getSupabaseServerClient();
const { data, error } = await client
.from('items')
.select('*')
.eq('user_id', user.id)
.limit(parseInt(limit));
if (error) {
return NextResponse.json(
{ error: 'Failed to fetch items' },
{ status: 500 }
);
}
return NextResponse.json({ data });
},
{
auth: true,
// No schema needed for GET requests
},
);
```
### Route Handler Options
```typescript
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// Handler function
return NextResponse.json({ success: true });
},
{
auth: true, // Require authentication (default: false)
schema: MySchema, // Zod schema for body validation (optional)
// Additional options available
},
);
```
## Revalidation
- Use `revalidatePath` for revalidating data after a migration.
- Avoid calling `router.refresh()` or `router.push()` following a Server Action. Use `revalidatePath` and `redirect` from the server action instead.
## Error Handling Patterns
### Server Actions with Error Handling
```typescript
export const createNoteAction = enhanceAction(
async function (data, user) {
const logger = await getLogger();
const ctx = { name: 'create-note', userId: user.id };
try {
logger.info(ctx, 'Creating note');
const client = getSupabaseServerClient();
const { data: note, error } = await client
.from('notes')
.insert({
title: data.title,
content: data.content,
user_id: user.id,
})
.select()
.single();
if (error) {
logger.error({ ...ctx, error }, 'Failed to create note');
throw error;
}
logger.info({ ...ctx, noteId: note.id }, 'Note created successfully');
return { success: true, note };
} catch (error) {
if (!isRedirectError(error)) {
logger.error({ ...ctx, error }, 'Create note action failed');
throw error;
}
}
},
{
auth: true,
schema: CreateNoteSchema,
},
);
```
### Server Action Redirects - Client Handling
When server actions call `redirect()`, it throws a special error that should NOT be treated as a failure:
```typescript
import { isRedirectError } from 'next/dist/client/components/redirect-error';
async function handleSubmit(formData: FormData) {
try {
await myServerAction(formData);
} catch (error) {
// Don't treat redirects as errors
if (!isRedirectError(error)) {
// Handle actual errors
toast.error('Something went wrong');
}
}
}
### Route Handler with Error Handling
```typescript
export const POST = enhanceRouteHandler(
async function ({ body, user }) {
const logger = await getLogger();
const ctx = { name: 'api-create-item', userId: user.id };
try {
logger.info(ctx, 'Processing API request');
// Process request
const result = await processRequest(body, user);
logger.info({ ...ctx, result }, 'API request successful');
return NextResponse.json({ success: true, data: result });
} catch (error) {
logger.error({ ...ctx, error }, 'API request failed');
if (error.message.includes('validation')) {
return NextResponse.json(
{ error: 'Invalid input data' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
},
{
auth: true,
schema: CreateItemSchema,
},
);
```
## Client-Side Integration
### Using Server Actions in Components
```tsx
'use client';
import { useTransition } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@kit/ui/sonner';
import { Button } from '@kit/ui/button';
import { createNoteAction } from './actions';
import { CreateNoteSchema } from './schemas';
function CreateNoteForm() {
const [isPending, startTransition] = useTransition();
const form = useForm({
resolver: zodResolver(CreateNoteSchema),
defaultValues: {
title: '',
content: '',
},
});
const onSubmit = (data) => {
startTransition(async () => {
try {
const result = await createNoteAction(data);
if (result.success) {
toast.success('Note created successfully!');
form.reset();
}
} catch (error) {
toast.error('Failed to create note');
console.error('Create note error:', error);
}
});
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Note'}
</Button>
</form>
);
}
```
NB: When using `redirect`, we must handle it using `isRedirectError` otherwise we display an error after the server action succeeds
### Using Route Handlers with Fetch
```typescript
'use client';
async function createItem(data: CreateItemInput) {
const response = await fetch('/api/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create item');
}
return response.json();
}
// Usage in component
const handleCreateItem = async (data) => {
try {
const result = await createItem(data);
toast.success('Item created successfully!');
return result;
} catch (error) {
toast.error('Failed to create item');
throw error;
}
};
```
### Authorization Checks
```typescript
export const deleteAccountAction = enhanceAction(
async function (data, user) {
const client = getSupabaseServerClient();
// Verify user owns the account
const { data: account, error } = await client
.from('accounts')
.select('id, primary_owner_user_id')
.eq('id', data.accountId)
.single();
if (error || !account) {
throw new Error('Account not found');
}
if (account.primary_owner_user_id !== user.id) {
throw new Error('Only account owners can delete accounts');
}
// Additional checks
const hasActiveSubscription = await client
.rpc('has_active_subscription', { account_id: data.accountId });
if (hasActiveSubscription) {
throw new Error('Cannot delete account with active subscription');
}
// Proceed with deletion
await deleteAccount(data.accountId);
return { success: true };
},
{
auth: true,
schema: DeleteAccountSchema,
},
);
```
@AGENTS.md

View File

@@ -1,684 +1 @@
# FeaturePolicy API - Registry-Based Policy System
A unified, registry-based foundation for implementing business rules across all Makerkit features.
## Overview
The FeaturePolicy API provides:
- **Registry-based architecture** - centralized policy management with IDs
- **Configuration support** - policies can accept typed configuration objects
- **Stage-aware evaluation** - policies can be filtered by execution stage
- **Immutable contexts** for safe policy evaluation
- **Customer extensibility** - easy to add custom policies without forking
## Quick Start
### 1. Register Policies
```typescript
import { z } from 'zod';
import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies';
const registry = createPolicyRegistry();
// Register a basic policy
registry.registerPolicy(
definePolicy({
id: 'email-validation',
stages: ['preliminary', 'submission'],
evaluate: async (context) => {
if (!context.userEmail?.includes('@')) {
return deny({
code: 'INVALID_EMAIL_FORMAT',
message: 'Invalid email format',
remediation: 'Please provide a valid email address',
});
}
return allow();
},
}),
);
// Register a configurable policy
registry.registerPolicy(
definePolicy({
id: 'max-invitations',
configSchema: z.object({
maxInvitations: z.number().positive(),
}),
evaluate: async (context, config = { maxInvitations: 5 }) => {
if (context.invitations.length > config.maxInvitations) {
return deny({
code: 'MAX_INVITATIONS_EXCEEDED',
message: `Cannot invite more than ${config.maxInvitations} members`,
remediation: `Reduce invitations to ${config.maxInvitations} or fewer`,
});
}
return allow();
},
}),
);
```
### 2. Use Policies from Registry
```typescript
import {
createPoliciesFromRegistry,
createPolicyEvaluator,
createPolicyRegistry,
} from '@kit/policies';
const registry = createPolicyRegistry();
// Load policies from registry
const policies = await createPoliciesFromRegistry(registry, [
'email-validation',
'subscription-required',
['max-invitations', { maxInvitations: 5 }], // with configuration
]);
const evaluator = createPolicyEvaluator();
const result = await evaluator.evaluatePolicies(policies, context, 'ALL');
if (!result.allowed) {
console.log('Failed reasons:', result.reasons);
}
```
### 3. Group Policies with Complex Logic
```typescript
// Basic group example
const preliminaryGroup = {
operator: 'ALL' as const,
policies: [emailValidationPolicy, authenticationPolicy],
};
const billingGroup = {
operator: 'ANY' as const,
policies: [subscriptionPolicy, trialPolicy],
};
// Evaluate groups in sequence
const result = await evaluator.evaluateGroups(
[preliminaryGroup, billingGroup],
context,
);
```
## Complex Group Flows
### Real-World Multi-Stage Team Invitation Flow
```typescript
import { createPolicy, createPolicyEvaluator } from '@kit/policies';
// Complex business logic: (Authentication AND Email Validation) AND (Subscription OR Trial) AND Billing Limits
async function validateTeamInvitation(context: InvitationContext) {
const evaluator = createPolicyEvaluator();
// Stage 1: Authentication Requirements (ALL must pass)
const authenticationGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.userId
? allow({ step: 'authenticated' })
: deny('Authentication required'),
),
createPolicy(async (ctx) =>
ctx.email.includes('@')
? allow({ step: 'email-valid' })
: deny('Valid email required'),
),
createPolicy(async (ctx) =>
ctx.permissions.includes('invite')
? allow({ step: 'permissions' })
: deny('Insufficient permissions'),
),
],
};
// Stage 2: Subscription Validation (ANY sufficient - flexible billing)
const subscriptionGroup = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.subscription?.active && ctx.subscription.plan === 'enterprise'
? allow({ billing: 'enterprise' })
: deny('Enterprise subscription required'),
),
createPolicy(async (ctx) =>
ctx.subscription?.active && ctx.subscription.plan === 'pro'
? allow({ billing: 'pro' })
: deny('Pro subscription required'),
),
createPolicy(async (ctx) =>
ctx.trial?.active && ctx.trial.daysRemaining > 0
? allow({ billing: 'trial', daysLeft: ctx.trial.daysRemaining })
: deny('Active trial required'),
),
],
};
// Stage 3: Final Constraints (ALL must pass)
const constraintsGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.team.memberCount < ctx.subscription?.maxMembers
? allow({ constraint: 'member-limit' })
: deny('Member limit exceeded'),
),
createPolicy(async (ctx) =>
ctx.invitations.length <= 10
? allow({ constraint: 'batch-size' })
: deny('Cannot invite more than 10 members at once'),
),
],
};
// Execute all groups sequentially - ALL groups must pass
const result = await evaluator.evaluateGroups(
[authenticationGroup, subscriptionGroup, constraintsGroup],
context,
);
return {
allowed: result.allowed,
reasons: result.reasons,
metadata: {
stagesCompleted: result.results.length,
authenticationPassed: result.results.some(
(r) => r.metadata?.step === 'authenticated',
),
billingType: result.results.find((r) => r.metadata?.billing)?.metadata
?.billing,
constraintsChecked: result.results.some((r) => r.metadata?.constraint),
},
};
}
```
### Middleware-Style Policy Chain
```typescript
// Simulate middleware pattern: Auth → Rate Limiting → Business Logic
async function processApiRequest(context: ApiContext) {
const evaluator = createPoliciesEvaluator();
// Layer 1: Security (ALL required)
const securityLayer = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.apiKey && ctx.apiKey.length > 0
? allow({ security: 'api-key-valid' })
: deny('API key required'),
),
createPolicy(async (ctx) =>
ctx.rateLimitRemaining > 0
? allow({ security: 'rate-limit-ok' })
: deny('Rate limit exceeded'),
),
createPolicy(async (ctx) =>
!ctx.blacklisted
? allow({ security: 'not-blacklisted' })
: deny('Client is blacklisted'),
),
],
};
// Layer 2: Authorization (ANY sufficient - flexible access levels)
const authorizationLayer = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.role === 'admin'
? allow({ access: 'admin' })
: deny('Admin access denied'),
),
createPolicy(async (ctx) =>
ctx.user.permissions.includes(ctx.requestedResource)
? allow({ access: 'resource-specific' })
: deny('Resource access denied'),
),
createPolicy(async (ctx) =>
ctx.user.subscription?.includes('api-access')
? allow({ access: 'subscription-based' })
: deny('Subscription access denied'),
),
],
};
// Layer 3: Business Rules (ALL required)
const businessLayer = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.request.size <= ctx.maxRequestSize
? allow({ business: 'size-valid' })
: deny('Request too large'),
),
createPolicy(async (ctx) =>
ctx.user.dailyQuota > ctx.user.dailyUsage
? allow({ business: 'quota-available' })
: deny('Daily quota exceeded'),
),
],
};
return evaluator.evaluateGroups(
[securityLayer, authorizationLayer, businessLayer],
context,
);
}
```
### Complex Nested Logic with Short-Circuiting
```typescript
// Complex scenario: (Premium User OR (Basic User AND Low Usage)) AND Security Checks
async function validateFeatureAccess(context: FeatureContext) {
const evaluator = createPoliciesEvaluator();
// Group 1: User Tier Logic - demonstrates complex OR conditions
const userTierGroup = {
operator: 'ANY' as const,
policies: [
// Premium users get immediate access
createPolicy(async (ctx) =>
ctx.user.plan === 'premium'
? allow({ tier: 'premium', reason: 'premium-user' })
: deny('Not premium user'),
),
// Enterprise users get immediate access
createPolicy(async (ctx) =>
ctx.user.plan === 'enterprise'
? allow({ tier: 'enterprise', reason: 'enterprise-user' })
: deny('Not enterprise user'),
),
// Basic users need additional validation (sub-group logic)
createPolicy(async (ctx) => {
if (ctx.user.plan !== 'basic') {
return deny('Not basic user');
}
// Simulate nested AND logic for basic users
const basicUserRequirements = [
ctx.user.monthlyUsage < 1000,
ctx.user.accountAge > 30, // days
!ctx.user.hasViolations,
];
const allBasicRequirementsMet = basicUserRequirements.every(
(req) => req,
);
return allBasicRequirementsMet
? allow({ tier: 'basic', reason: 'low-usage-basic-user' })
: deny('Basic user requirements not met');
}),
],
};
// Group 2: Security Requirements (ALL must pass)
const securityGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.emailVerified
? allow({ security: 'email-verified' })
: deny('Email verification required'),
),
createPolicy(async (ctx) =>
ctx.user.twoFactorEnabled || ctx.user.plan === 'basic'
? allow({ security: '2fa-compliant' })
: deny('Two-factor authentication required for premium plans'),
),
createPolicy(async (ctx) =>
!ctx.user.suspiciousActivity
? allow({ security: 'activity-clean' })
: deny('Suspicious activity detected'),
),
],
};
return evaluator.evaluateGroups([userTierGroup, securityGroup], context);
}
```
### Dynamic Policy Composition
```typescript
// Dynamically compose policies based on context
async function createContextAwarePolicyFlow(context: DynamicContext) {
const evaluator = createPoliciesEvaluator();
const groups = [];
// Always include base security
const baseSecurityGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.isAuthenticated ? allow() : deny('Authentication required'),
),
],
};
groups.push(baseSecurityGroup);
// Add user-type specific policies
if (context.user.type === 'admin') {
const adminGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.adminLevel >= ctx.requiredAdminLevel
? allow({ admin: 'level-sufficient' })
: deny('Insufficient admin level'),
),
createPolicy(async (ctx) =>
ctx.user.lastLogin > Date.now() - 24 * 60 * 60 * 1000 // 24 hours
? allow({ admin: 'recent-login' })
: deny('Admin must have logged in within 24 hours'),
),
],
};
groups.push(adminGroup);
}
// Add feature-specific policies based on requested feature
if (context.feature.requiresBilling) {
const billingGroup = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.subscription?.active
? allow({ billing: 'subscription' })
: deny('Active subscription required'),
),
createPolicy(async (ctx) =>
ctx.credits && ctx.credits > ctx.feature.creditCost
? allow({ billing: 'credits' })
: deny('Insufficient credits'),
),
],
};
groups.push(billingGroup);
}
// Add rate limiting for high-impact features
if (context.feature.highImpact) {
const rateLimitGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.rateLimit.remaining > 0
? allow({ rateLimit: 'within-limits' })
: deny('Rate limit exceeded for high-impact features'),
),
],
};
groups.push(rateLimitGroup);
}
return evaluator.evaluateGroups(groups, context);
}
```
### Performance-Optimized Large Group Evaluation
```typescript
// Handle large numbers of policies efficiently
async function validateComplexBusinessRules(context: BusinessContext) {
const evaluator = createPoliciesEvaluator({ maxCacheSize: 200 });
// Group policies by evaluation cost and criticality
const criticalFastGroup = {
operator: 'ALL' as const,
policies: [
// Fast critical checks first
createPolicy(async (ctx) =>
ctx.isActive ? allow() : deny('Account inactive'),
),
createPolicy(async (ctx) =>
ctx.hasPermission ? allow() : deny('No permission'),
),
createPolicy(async (ctx) =>
!ctx.isBlocked ? allow() : deny('Account blocked'),
),
],
};
const businessLogicGroup = {
operator: 'ANY' as const,
policies: [
// Complex business rules
createPolicy(async (ctx) => {
// Simulate complex calculation
const score = await calculateRiskScore(ctx);
return score < 0.8
? allow({ risk: 'low' })
: deny('High risk detected');
}),
createPolicy(async (ctx) => {
// Simulate external API call
const verification = await verifyWithThirdParty(ctx);
return verification.success
? allow({ external: 'verified' })
: deny('External verification failed');
}),
],
};
const finalValidationGroup = {
operator: 'ALL' as const,
policies: [
// Final checks after complex logic
createPolicy(async (ctx) =>
ctx.complianceCheck ? allow() : deny('Compliance check failed'),
),
],
};
// Use staged evaluation for better performance
const startTime = Date.now();
const result = await evaluator.evaluateGroups(
[
criticalFastGroup, // Fast critical checks first
businessLogicGroup, // Complex logic only if critical checks pass
finalValidationGroup, // Final validation
],
context,
);
const evaluationTime = Date.now() - startTime;
return {
...result,
performance: {
evaluationTimeMs: evaluationTime,
groupsEvaluated: result.results.length > 0 ? 3 : 1,
},
};
}
// Helper functions for complex examples
async function calculateRiskScore(context: any): Promise<number> {
// Simulate complex risk calculation
await new Promise((resolve) => setTimeout(resolve, 10));
return Math.random();
}
async function verifyWithThirdParty(
context: any,
): Promise<{ success: boolean }> {
// Simulate external API call
await new Promise((resolve) => setTimeout(resolve, 5));
return { success: Math.random() > 0.2 };
}
```
## Advanced Usage
### Configurable Policies
```typescript
// Create policy factories for configuration
const createMaxInvitationsPolicy = (maxInvitations: number) =>
createPolicy(async (context) => {
if (context.invitations.length > maxInvitations) {
return deny({
code: 'MAX_INVITATIONS_EXCEEDED',
message: `Cannot invite more than ${maxInvitations} members`,
remediation: `Reduce invitations to ${maxInvitations} or fewer`,
});
}
return allow();
});
// Use with different configurations
const strictPolicy = createMaxInvitationsPolicy(1);
const standardPolicy = createMaxInvitationsPolicy(5);
const permissivePolicy = createMaxInvitationsPolicy(25);
```
### Feature-Specific evaluators
```typescript
// Create feature-specific evaluator with preset configurations
export function createInvitationevaluator(
preset: 'strict' | 'standard' | 'permissive',
) {
const configs = {
strict: { maxInvitationsPerBatch: 1 },
standard: { maxInvitationsPerBatch: 5 },
permissive: { maxInvitationsPerBatch: 25 },
};
const config = configs[preset];
return {
async validateInvitations(context: InvitationContext) {
const policies = [
emailValidationPolicy,
createMaxInvitationsPolicy(config.maxInvitationsPerBatch),
subscriptionRequiredPolicy,
paddleBillingPolicy,
];
const evaluator = createPoliciesEvaluator();
return evaluator.evaluatePolicies(policies, context, 'ALL');
},
};
}
// Usage
const evaluator = createInvitationevaluator('standard');
const result = await evaluator.validateInvitations(context);
```
### Error Handling
```typescript
const result = await evaluator.evaluate();
if (!result.allowed) {
result.reasons.forEach((reason) => {
console.log(`Policy ${reason.policyId} failed:`);
console.log(` Code: ${reason.code}`);
console.log(` Message: ${reason.message}`);
if (reason.remediation) {
console.log(` Fix: ${reason.remediation}`);
}
});
}
```
### 1. Register Complex Policy with Configuration
```typescript
import { createPolicyRegistry, definePolicy } from '@kit/policies';
const registry = createPolicyRegistry();
const customConfigurablePolicy = definePolicy({
id: 'custom-domain-check',
configSchema: z.object({
allowedDomains: z.array(z.string()),
strictMode: z.boolean(),
}),
evaluate: async (context, config) => {
const emailDomain = context.userEmail?.split('@')[1];
if (config?.strictMode && !config.allowedDomains.includes(emailDomain)) {
return deny({
code: 'DOMAIN_NOT_ALLOWED',
message: `Email domain ${emailDomain} is not in the allowed list`,
remediation: 'Use an email from an approved domain',
});
}
return allow();
},
});
registry.registerPolicy(customConfigurablePolicy);
```
## Key Concepts
### Group Operators
- **`ALL` (AND logic)**: All policies in the group must pass
- **Short-circuits on first failure** for performance
- Use for mandatory requirements where every condition must be met
- Example: Authentication AND permissions AND rate limiting
- **`ANY` (OR logic)**: At least one policy in the group must pass
- **Short-circuits on first success** for performance
- Use for flexible requirements where multiple options are acceptable
- Example: Premium subscription OR trial access OR admin override
### Group Evaluation Flow
1. **Sequential Group Processing**: Groups are evaluated in order
2. **All Groups Must Pass**: If any group fails, entire evaluation fails
3. **Short-Circuiting**: Stops on first group failure for performance
4. **Metadata Preservation**: All policy results and metadata are collected
### Performance Considerations
- **Order groups by criticality**: Put fast, critical checks first
- **Use caching**: Configure `maxCacheSize` for frequently used policies
- **Group by evaluation cost**: Separate expensive operations
- **Monitor evaluation time**: Track performance for optimization
## Stage-Aware Evaluation
Policies can be filtered by execution stage. This is useful for running a subset of policies depending on the situation:
```typescript
// Only run preliminary checks
const prelimResult = await evaluator.evaluate(
registry,
context,
'ALL',
'preliminary',
);
// Run submission validation
const submitResult = await evaluator.evaluate(
registry,
context,
'ALL',
'submission',
);
// Run all applicable policies
const fullResult = await evaluator.evaluate(registry, context, 'ALL');
```
@AGENTS.md

View File

@@ -1,122 +1,51 @@
# Database & Authentication Instructions
# Database & Authentication
This file contains instructions for working with Supabase, database security, and authentication.
## Skills
## Schemas and Migrations ⚠️
For database work:
- `/postgres-expert` - Schemas, RLS, migrations
**Critical Understanding**: Schema files are NOT automatically applied to the database!
## Client Usage
- **Schemas** (`supabase/schemas/`) represent the desired database state (source of truth)
- **Migrations** (`supabase/migrations/`) are the actual SQL commands that modify the database
### Server Components (Preferred)
### The Required Workflow
```typescript
import { getSupabaseServerClient } from '@kit/supabase/server-client';
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
2. **Generate migration**: `pnpm --filter web supabase:db:diff -f migration_name`
- This compares your schema against the current database and creates a migration
3. **Apply migration**: `pnpm --filter web supabase migrations up`
- This actually executes the SQL changes in the database
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
## Database Security Guidelines ⚠️
**Critical Security Guidelines - Read Carefully!**
### Database Security Fundamentals
- **Always enable RLS** on new tables unless explicitly instructed otherwise
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
- **Always use security_invoker=true for views** to maintain proper access control
- **Storage buckets MUST validate access** using account_id in the path structure. See `apps/web/supabase/schemas/16-storage.sql` for proper implementation.
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
### Security Definer Function - Dangerous Pattern ❌
```sql
-- NEVER DO THIS - Allows any authenticated user to call function
CREATE OR REPLACE FUNCTION public.dangerous_function()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER AS $
BEGIN
-- This bypasses all RLS policies!
DELETE FROM sensitive_table; -- Anyone can call this!
END;
$;
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
const client = getSupabaseServerClient();
const { data } = await client.from('table').select('*');
// RLS automatically enforced
```
### Security Definer Function - Safe Pattern ✅
### Client Components
```sql
-- ONLY use SECURITY DEFINER with explicit access validation
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = '' AS $
BEGIN
-- MUST validate caller has permission FIRST
IF NOT public.is_account_owner(target_account_id) THEN
RAISE EXCEPTION 'Access denied: insufficient permissions';
END IF;
```typescript
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
-- Now safe to proceed with elevated privileges
-- Your admin operation here
END;
$;
const supabase = useSupabase();
```
Only grant critical functions to `service_role`:
### Admin Client (Use Sparingly)
```sql
grant execute on public.dangerous_function to service_role;
```typescript
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
// CRITICAL: Bypasses RLS - validate manually!
const adminClient = getSupabaseServerAdminClient();
```
## Existing Helper Functions - Use These! 📚
**DO NOT recreate these functions - they already exist:**
## Existing Helper Functions
```sql
-- Account Access Control
public.has_role_on_account(account_id, role?) -- Check team membership
public.has_permission(user_id, account_id, permission) -- Check permissions
public.is_account_owner(account_id) -- Verify ownership
public.has_active_subscription(account_id) -- Subscription status
public.is_team_member(account_id, user_id) -- Direct membership check
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
-- Administrative Functions
public.is_super_admin() -- Super admin check
public.is_aal2() -- MFA verification
public.is_mfa_compliant() -- MFA compliance
-- Configuration
public.is_set(field_name) -- Feature flag checks
public.has_role_on_account(account_id, role?)
public.has_permission(user_id, account_id, permission)
public.is_account_owner(account_id)
public.has_active_subscription(account_id)
public.is_team_member(account_id, user_id)
public.is_super_admin()
```
Always check `apps/web/supabase/schemas/` before creating new functions!
## RLS Policy Best Practices ✅
```sql
-- Proper RLS using existing helper functions
CREATE POLICY "notes_read" ON public.notes FOR SELECT
TO authenticated USING (
account_id = (select auth.uid()) OR
public.has_role_on_account(account_id)
);
-- For operations requiring specific permissions
CREATE POLICY "notes_manage" ON public.notes FOR ALL
TO authenticated USING (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
```
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
## Type Generation
```typescript
@@ -125,191 +54,21 @@ import { Tables } from '@kit/supabase/database';
type Account = Tables<'accounts'>;
```
Always prefer inferring types from generated Database types.
Never modify `database.types.ts` - regenerate with `pnpm supabase:web:typegen`.
## Client Usage Patterns
### Server Components (Preferred)
```typescript
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function NotesPage() {
const client = getSupabaseServerClient();
const { data, error } = await client.from('notes').select('*');
if (error) return <ErrorMessage error={error} />;
return <NotesList notes={data} />;
}
```
### Client Components
```typescript
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
function InteractiveNotes() {
const supabase = useSupabase();
// Use with React Query for optimal data fetching
}
```
### Admin Client (Use with Extreme Caution) ⚠️
```typescript
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function adminFunction() {
const adminClient = getSupabaseServerAdminClient();
// CRITICAL: Manual authorization required - bypasses RLS!
const currentUser = await getCurrentUser();
if (!(await isSuperAdmin(currentUser))) {
throw new Error('Unauthorized: Admin access required');
}
// Now safe to proceed with admin privileges
const { data } = await adminClient.from('table').select('*');
}
```
## Authentication Patterns
### Multi-Factor Authentication
```typescript
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(supabase);
if (requiresMultiFactorAuthentication) {
// Redirect to MFA page
}
```
### User Requirements
## Authentication
```typescript
import { requireUser } from '@kit/supabase/require-user';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
const client = getSupabaseServerClient();
const user = await requireUser(client, { verifyMfa: false });
const user = await requireUser(client);
const requiresMfa = await checkRequiresMultiFactorAuthentication(client);
```
## Storage Security
## Security Guidelines
Storage buckets must validate access using account_id in the path structure:
```sql
-- RLS policies for storage bucket account_image
create policy account_image on storage.objects for all using (
bucket_id = 'account_image'
and (
kit.get_storage_filename_as_uuid(name) = auth.uid()
or public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
)
)
with check (
bucket_id = 'account_image'
and (
kit.get_storage_filename_as_uuid(name) = auth.uid()
or public.has_permission(
auth.uid(),
kit.get_storage_filename_as_uuid(name),
'settings.manage'
)
)
);
```
## Common Database Operations
### Creating Tables with RLS
```sql
-- Create table
create table if not exists public.notes (
id uuid unique not null default extensions.uuid_generate_v4(),
account_id uuid references public.accounts(id) on delete cascade not null,
title varchar(255) not null,
content text,
created_at timestamp with time zone default now(),
updated_at timestamp with time zone default now(),
primary key (id)
);
-- Enable RLS
alter table "public"."notes" enable row level security;
-- Grant permissions
grant select, insert, update, delete on table public.notes to authenticated;
-- Create RLS policies
create policy "notes_read" on public.notes for select
to authenticated using (
account_id = (select auth.uid()) or
public.has_role_on_account(account_id)
);
create policy "notes_write" on public.notes for insert
to authenticated with check (
account_id = (select auth.uid()) or
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
```
### Indexes for Performance
```sql
-- Create indexes for common queries
create index if not exists ix_notes_account_id on public.notes (account_id);
create index if not exists ix_notes_created_at on public.notes (created_at);
```
## Error Handling
```typescript
import { getLogger } from '@kit/shared/logger';
async function databaseOperation() {
const logger = await getLogger();
const ctx = { name: 'database-operation', accountId: 'account-123' };
try {
logger.info(ctx, 'Starting database operation');
const result = await client.from('table').select('*');
if (result.error) {
logger.error({ ...ctx, error: result.error }, 'Database query failed');
throw result.error;
}
return result.data;
} catch (error) {
logger.error({ ...ctx, error }, 'Database operation failed');
throw error;
}
}
```
## Migration Best Practices
1. Always test migrations locally first
2. Use transactions for complex operations
3. Add proper indexes for new columns
4. Update RLS policies when adding new tables
5. Generate TypeScript types after schema changes
6. Take into account constraints
7. Do not add breaking changes that would distrupt the DB to new migrations
## Common Gotchas
1. **RLS bypass**: Admin client bypasses all RLS - validate manually
2. **Missing indexes**: Always add indexes for foreign keys and commonly queried columns
3. **Security definer functions**: Only use with explicit permission checks
4. **Storage paths**: Must include account_id for proper access control
5. **Type safety**: Always regenerate types after schema changes
- Standard client: Trust RLS
- Admin client: Validate everything manually
- Always add indexes for foreign keys
- Storage paths must include account_id

View File

@@ -1,317 +1 @@
# Database & Authentication Instructions
This file contains instructions for working with Supabase, database security, and authentication.
## Schemas and Migrations ⚠️
**Critical Understanding**: Schema files are NOT automatically applied to the database!
- **Schemas** (`supabase/schemas/`) represent the desired database state (source of truth)
- **Migrations** (`supabase/migrations/`) are the actual SQL commands that modify the database
### The Required Workflow
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
2. **Generate migration**:
- Either copy content from schema to migration with `pnpm --filter web supabase migrations new my-feature` and `cp apps/web/supabase/schemas/18-my-new-feature.sql apps/web/supabase/migrations/$(ls -t apps/web/supabase/migrations/ | head -n1)`. Ideal for **new** tables, enums, etc.
- or use `pnpm --filter web supabase:db:diff -f migration_name` (ideal when modifying existing entities)
- This compares your schema against the current database and creates a migration
3. **Apply migration**: `pnpm --filter web supabase migrations up`
- This actually executes the SQL changes in the database
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
## Database Security Guidelines ⚠️
**Critical Security Guidelines - Read Carefully!**
### Database Security Fundamentals
- **Always enable RLS** on new tables unless explicitly instructed otherwise
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
- **Always use security_invoker=true for views** to maintain proper access control
- **Storage buckets MUST validate access** using account_id in the path structure. See `apps/web/supabase/schemas/16-storage.sql` for proper implementation.
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
### Security Definer Function - Dangerous Pattern ❌
```sql
-- NEVER DO THIS - Allows any authenticated user to call function
CREATE OR REPLACE FUNCTION public.dangerous_function()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER AS $
BEGIN
-- This bypasses all RLS policies!
DELETE FROM sensitive_table; -- Anyone can call this!
END;
$;
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
```
### Security Definer Function - Safe Pattern ✅
```sql
-- ONLY use SECURITY DEFINER with explicit access validation
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = '' AS $
BEGIN
-- MUST validate caller has permission FIRST
IF NOT public.is_account_owner(target_account_id) THEN
RAISE EXCEPTION 'Access denied: insufficient permissions';
END IF;
-- Now safe to proceed with elevated privileges
-- Your admin operation here
END;
$;
```
Only grant critical functions to `service_role`:
```sql
grant execute on public.dangerous_function to service_role;
```
## Existing Helper Functions - Use These! 📚
**DO NOT recreate these functions - they already exist:**
```sql
-- Account Access Control
public.has_role_on_account(account_id, role?) -- Check team membership
public.has_permission(user_id, account_id, permission) -- Check permissions
public.is_account_owner(account_id) -- Verify ownership
public.has_active_subscription(account_id) -- Subscription status
public.is_team_member(account_id, user_id) -- Direct membership check
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
-- Administrative Functions
public.is_super_admin() -- Super admin check
public.is_aal2() -- MFA verification
public.is_mfa_compliant() -- MFA compliance
-- Configuration
public.is_set(field_name) -- Feature flag checks
```
Always check `apps/web/supabase/schemas/` before creating new functions!
## RLS Policy Best Practices ✅
```sql
-- Proper RLS using existing helper functions
CREATE POLICY "notes_read" ON public.notes FOR SELECT
TO authenticated USING (
account_id = (select auth.uid()) OR
public.has_role_on_account(account_id)
);
-- For operations requiring specific permissions
CREATE POLICY "notes_manage" ON public.notes FOR ALL
TO authenticated USING (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
```
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
## Type Generation
```typescript
import { Tables } from '@kit/supabase/database';
type Account = Tables<'accounts'>;
```
Always prefer inferring types from generated Database types.
## Client Usage Patterns
### Server Components (Preferred)
```typescript
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function NotesPage() {
const client = getSupabaseServerClient();
const { data, error } = await client.from('notes').select('*');
if (error) return <ErrorMessage error={error} />;
return <NotesList notes={data} />;
}
```
### Client Components
```typescript
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
function InteractiveNotes() {
const supabase = useSupabase();
// Use with React Query for optimal data fetching
}
```
### Admin Client (Use with Extreme Caution) ⚠️
```typescript
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function adminFunction() {
const adminClient = getSupabaseServerAdminClient();
// CRITICAL: Manual authorization required - bypasses RLS!
const currentUser = await getCurrentUser();
if (!(await isSuperAdmin(currentUser))) {
throw new Error('Unauthorized: Admin access required');
}
// Now safe to proceed with admin privileges
const { data } = await adminClient.from('table').select('*');
}
```
## Authentication Patterns
### Multi-Factor Authentication
```typescript
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(supabase);
if (requiresMultiFactorAuthentication) {
// Redirect to MFA page
}
```
### User Requirements
```typescript
import { requireUser } from '@kit/supabase/require-user';
const client = getSupabaseServerClient();
const user = await requireUser(client, { verifyMfa: false });
```
## Storage Security
Storage buckets must validate access using account_id in the path structure:
```sql
-- RLS policies for storage bucket account_image
create policy account_image on storage.objects for all using (
bucket_id = 'account_image'
and (
kit.get_storage_filename_as_uuid(name) = auth.uid()
or public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
)
)
with check (
bucket_id = 'account_image'
and (
kit.get_storage_filename_as_uuid(name) = auth.uid()
or public.has_permission(
auth.uid(),
kit.get_storage_filename_as_uuid(name),
'settings.manage'
)
)
);
```
## Common Database Operations
### Creating Tables with RLS
```sql
-- Create table
create table if not exists public.notes (
id uuid unique not null default extensions.uuid_generate_v4(),
account_id uuid references public.accounts(id) on delete cascade not null,
title varchar(255) not null,
content text,
created_at timestamp with time zone default now(),
updated_at timestamp with time zone default now(),
primary key (id)
);
-- Enable RLS
alter table "public"."notes" enable row level security;
-- Grant permissions
grant select, insert, update, delete on table public.notes to authenticated;
-- Create RLS policies
create policy "notes_read" on public.notes for select
to authenticated using (
account_id = (select auth.uid()) or
public.has_role_on_account(account_id)
);
create policy "notes_write" on public.notes for insert
to authenticated with check (
account_id = (select auth.uid()) or
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
```
### Indexes for Performance
```sql
-- Create indexes for common queries
create index if not exists ix_notes_account_id on public.notes (account_id);
create index if not exists ix_notes_created_at on public.notes (created_at);
```
## Error Handling
```typescript
import { getLogger } from '@kit/shared/logger';
async function databaseOperation() {
const logger = await getLogger();
const ctx = { name: 'database-operation', accountId: 'account-123' };
try {
logger.info(ctx, 'Starting database operation');
const result = await client.from('table').select('*');
if (result.error) {
logger.error({ ...ctx, error: result.error }, 'Database query failed');
throw result.error;
}
return result.data;
} catch (error) {
logger.error({ ...ctx, error }, 'Database operation failed');
throw error;
}
}
```
## Migration Best Practices
1. Always test migrations locally first
2. Use transactions for complex operations
3. Add proper indexes for new columns
4. Update RLS policies when adding new tables
5. Generate TypeScript types after schema changes
6. Take into account constraints
7. Do not add breaking changes that would distrupt the DB to new migrations
## Common Gotchas
1. **RLS bypass**: Admin client bypasses all RLS - validate manually
2. **Missing indexes**: Always add indexes for foreign keys and commonly queried columns
3. **Security definer functions**: Only use with explicit permission checks
4. **Storage paths**: Must include account_id for proper access control
5. **Type safety**: Always regenerate types after schema changes
@AGENTS.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,43 @@
# UI Components & Styling Instructions
# UI Components & Styling
This file contains instructions for working with UI components, styling, and forms.
## Skills
## Core UI Library
For forms:
- `/react-form-builder` - Forms with validation and server actions
Import from `packages/ui/src/`:
## Import Convention
Always use `@kit/ui/{component}`:
```tsx
// Shadcn components
import { Button } from '@kit/ui/button';
import { Card } from '@kit/ui/card';
// Makerkit components
import { If } from '@kit/ui/if';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
```
NB: imports must follow the convention "@kit/ui/<name>", no matter the folder they're placed in
## Styling Guidelines
- Use **Tailwind CSS v4** with semantic classes
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
- Use `cn()` utility from `@kit/ui/utils` for class merging
```tsx
import { toast } from '@kit/ui/sonner';
import { cn } from '@kit/ui/utils';
function MyComponent({ className }) {
return (
<div className={cn('bg-background text-foreground', className)}>
Content
</div>
);
}
```
### Conditional Rendering
## Styling
Use the `If` component from `packages/ui/src/makerkit/if.tsx`:
- Tailwind CSS v4 with semantic classes
- Prefer: `bg-background`, `text-muted-foreground`, `border-border`
- Use `cn()` for class merging
- Never use hardcoded colors like `bg-white`
## Key Components
| Component | Usage |
|-----------|-------|
| `If` | Conditional rendering |
| `Trans` | Internationalization |
| `toast` | Notifications |
| `Form*` | Form fields |
| `Button` | Actions |
| `Card` | Content containers |
| `Alert` | Error/info messages |
## Conditional Rendering
```tsx
import { If } from '@kit/ui/if';
@@ -48,257 +45,27 @@ import { If } from '@kit/ui/if';
<If condition={isLoading} fallback={<Content />}>
<Spinner />
</If>
// With type inference
<If condition={error}>
{(err) => <ErrorMessage error={err} />}
</If>
```
### Testing Attributes
```tsx
<button data-test="submit-button">Submit</button>
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
```
## Forms with React Hook Form & Zod
```typescript
// 1. Schema in separate file
export const CreateNoteSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
});
// 2. Client component with form
'use client';
const form = useForm({
resolver: zodResolver(CreateNoteSchema),
});
const onSubmit = (data) => {
startTransition(async () => {
await toast.promise(createNoteAction(data), {
loading: 'Creating...',
success: 'Created!',
error: 'Failed!',
}).unwrap();
});
};
```
### Form Examples
- Contact form: `apps/web/app/(marketing)/contact/_components/contact-form.tsx`
- Verify OTP form: `packages/otp/src/components/verify-otp-form.tsx`
### Guidelines
- Place Zod resolver outside so it can be reused with Server Actions
- Never add generics to `useForm`, use Zod resolver to infer types instead
- Never use `watch()` instead use hook `useWatch`
- Add `FormDescription` (optionally) and always add `FormMessage` to display errors
## Internationalization
Always use `Trans` component from `packages/ui/src/makerkit/trans.tsx`:
```tsx
import { Trans } from '@kit/ui/trans';
<Trans
i18nKey="user:welcomeMessage"
values={{ name: user.name }}
/>
// With HTML elements
<Trans
i18nKey="terms:agreement"
components={{
TermsLink: <a href="/terms" className="underline" />,
}}
/>
<Trans i18nKey="namespace:key" values={{ name }} />
```
## Toast Notifications
## Testing Attributes
Use the `toast` utility from `@kit/ui/sonner`:
Always add `data-test` for E2E:
```tsx
import { toast } from '@kit/ui/sonner';
// Simple toast
toast.success('Success message');
toast.error('Error message');
// Promise-based toast
await toast.promise(asyncFunction(), {
loading: 'Processing...',
success: 'Done!',
error: 'Failed!',
});
<button data-test="submit-button">Submit</button>
```
## Common Component Patterns
## Form Guidelines
### Loading States
```tsx
import { Spinner } from '@kit/ui/spinner';
<If condition={isLoading} fallback={<Content />}>
<Spinner className="h-4 w-4" />
</If>
```
### Error Handling
```tsx
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
<If condition={Boolean(error)}>
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
```
### Button Patterns
```tsx
import { Button } from '@kit/ui/button';
// Loading button
<Button disabled={isPending}>
{isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
Loading...
</>
) : (
'Submit'
)}
</Button>
// Variants
<Button variant="default">Default</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost">Ghost</Button>
```
### Card Layouts
```tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card';
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card description</CardDescription>
</CardHeader>
<CardContent>
Card content goes here
</CardContent>
</Card>
```
## Form Components
### Input Fields
```tsx
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
<FormField
name="title"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter title" {...field} />
</FormControl>
<FormDescription>
The title of your task
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
```
### Select Components
```tsx
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
<FormField
name="category"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The category of your task
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
```
## Accessibility Guidelines
- Always include proper ARIA labels
- Use semantic HTML elements
- Ensure proper keyboard navigation
```tsx
<button
aria-label="Close modal"
aria-describedby="modal-description"
onClick={onClose}
>
<X className="h-4 w-4" />
</button>
```
## Dark Mode Support
The UI components automatically support dark mode through CSS variables. Use semantic color classes:
```tsx
// Good - semantic colors
<div className="bg-background text-foreground border-border">
<p className="text-muted-foreground">Secondary text</p>
</div>
// Avoid - hardcoded colors
<div className="bg-white text-black border-gray-200">
<p className="text-gray-500">Secondary text</p>
</div>
```
- Use `react-hook-form` with `zodResolver`
- Never add generics to `useForm`
- Use `useWatch` instead of `watch()`
- Always include `FormMessage` for errors

View File

@@ -1,289 +1 @@
# UI Components & Styling Instructions
This file contains instructions for working with UI components, styling, and forms.
## Core UI Library
Import from `packages/ui/src/`:
```tsx
// Shadcn components
import { Button } from '@kit/ui/button';
import { Card } from '@kit/ui/card';
// Makerkit components
import { If } from '@kit/ui/if';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
```
## Styling Guidelines
- Use **Tailwind CSS v4** with semantic classes
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
- Use `cn()` utility from `@kit/ui/utils` for class merging
```tsx
import { cn } from '@kit/ui/utils';
function MyComponent({ className }) {
return (
<div className={cn('bg-background text-foreground', className)}>
Content
</div>
);
}
```
### Conditional Rendering
Use the `If` component from `packages/ui/src/makerkit/if.tsx`:
```tsx
import { If } from '@kit/ui/if';
<If condition={isLoading} fallback={<Content />}>
<Spinner />
</If>
// With type inference
<If condition={error}>
{(err) => <ErrorMessage error={err} />}
</If>
```
### Testing Attributes
```tsx
<button data-test="submit-button">Submit</button>
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
```
## Forms with React Hook Form & Zod
```typescript
// 1. Schema in separate file
export const CreateNoteSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
});
// 2. Client component with form
'use client';
const form = useForm({
resolver: zodResolver(CreateNoteSchema),
});
const onSubmit = (data) => {
startTransition(async () => {
await toast.promise(createNoteAction(data), {
loading: 'Creating...',
success: 'Created!',
error: 'Failed!',
}).unwrap();
});
};
```
### Guidelines
- Place Zod resolver outside so it can be reused with Server Actions
- Never add generics to `useForm`, use Zod resolver to infer types instead
- Never use `watch()` instead use hook `useWatch`
- Add `FormDescription` (optionally) and always add `FormMessage` to display errors
### Form Examples
- Contact form: `apps/web/app/(marketing)/contact/_components/contact-form.tsx`
- Verify OTP form: `packages/otp/src/components/verify-otp-form.tsx`
## Internationalization
Always use `Trans` component from `packages/ui/src/makerkit/trans.tsx`:
```tsx
import { Trans } from '@kit/ui/trans';
<Trans
i18nKey="user:welcomeMessage"
values={{ name: user.name }}
/>
// With HTML elements
<Trans
i18nKey="terms:agreement"
components={{
TermsLink: <a href="/terms" className="underline" />,
}}
/>
```
## Toast Notifications
Use the `toast` utility from `@kit/ui/sonner`:
```tsx
import { toast } from '@kit/ui/sonner';
// Simple toast
toast.success('Success message');
toast.error('Error message');
// Promise-based toast
await toast.promise(asyncFunction(), {
loading: 'Processing...',
success: 'Done!',
error: 'Failed!',
});
```
## Common Component Patterns
### Loading States
```tsx
import { Spinner } from '@kit/ui/spinner';
<If condition={isLoading} fallback={<Content />}>
<Spinner className="h-4 w-4" />
</If>
```
### Error Handling
```tsx
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
<If condition={Boolean(error)}>
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
```
### Button Patterns
```tsx
import { Button } from '@kit/ui/button';
// Loading button
<Button disabled={isPending}>
{isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
Loading...
</>
) : (
'Submit'
)}
</Button>
// Variants
<Button variant="default">Default</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost">Ghost</Button>
```
### Card Layouts
```tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card';
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card description</CardDescription>
</CardHeader>
<CardContent>
Card content goes here
</CardContent>
</Card>
```
## Form Components
### Input Fields
```tsx
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
<FormField
name="title"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
```
### Select Components
```tsx
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
<FormField
name="category"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
```
## Accessibility Guidelines
- Always include proper ARIA labels
- Use semantic HTML elements
- Ensure proper keyboard navigation
```tsx
<button
aria-label="Close modal"
aria-describedby="modal-description"
onClick={onClose}
>
<X className="h-4 w-4" />
</button>
```
## Dark Mode Support
The UI components automatically support dark mode through CSS variables. Use semantic color classes:
```tsx
// Good - semantic colors
<div className="bg-background text-foreground border-border">
<p className="text-muted-foreground">Secondary text</p>
</div>
// Avoid - hardcoded colors
<div className="bg-white text-black border-gray-200">
<p className="text-gray-500">Secondary text</p>
</div>
```
@AGENTS.md

View File

@@ -31,6 +31,7 @@ export function MobileModeToggle(props: { className?: string }) {
}
function setCookieTheme(theme: string) {
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:';
const secure =
typeof window !== 'undefined' && window.location.protocol === 'https:';
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`;
}

View File

@@ -126,7 +126,8 @@ export function SubMenuModeToggle() {
}
function setCookieTheme(theme: string) {
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:';
const secure =
typeof window !== 'undefined' && window.location.protocol === 'https:';
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`;
}

View File

@@ -96,7 +96,8 @@ const SidebarProvider: React.FC<
_setOpen(value);
// This sets the cookie to keep the sidebar state.
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:';
const secure =
typeof window !== 'undefined' && window.location.protocol === 'https:';
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secure ? '; Secure' : ''}`;
},
[setOpenProp, open],