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:
committed by
GitHub
parent
bebd56238b
commit
cfa137795b
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: react-form-builder
|
||||
description: MUST USE this agent when you need to create or modify client-side forms in React applications. MUST follow best practices for react-hook-form, @kit/ui/form components, and server actions integration. This includes forms with validation, error handling, loading states, and proper TypeScript typing. <example>Context: The user needs to create a form for user registration with email and password fields. user: "Create a registration form with email and password validation" assistant: "I'll use the react-form-builder agent to create a properly structured form with react-hook-form and server action integration" <commentary>Since the user needs a client-side form with validation, use the react-form-builder agent to ensure best practices are followed.</commentary></example> <example>Context: The user wants to add a form for updating user profile information. user: "I need a form to update user profile with name, bio, and avatar fields" assistant: "Let me use the react-form-builder agent to create a profile update form following the established patterns" <commentary>The user is requesting a form component, so the react-form-builder agent should be used to ensure proper implementation with react-hook-form and server actions.</commentary></example> <example>Context: The user has a broken form that needs fixing. user: "My form isn't handling errors properly when the server action fails" assistant: "I'll use the react-form-builder agent to review and fix the error handling in your form" <commentary>Since this involves fixing form-specific issues related to server actions and error handling, the react-form-builder agent is appropriate.</commentary></example>
|
||||
model: sonnet
|
||||
color: yellow
|
||||
---
|
||||
|
||||
You are an expert React form architect specializing in building robust, accessible, and type-safe forms using react-hook-form, @kit/ui/form components, and Next.js server actions. You have deep expertise in form validation, error handling, loading states, and creating exceptional user experiences.
|
||||
|
||||
**Core Responsibilities:**
|
||||
|
||||
You will create and modify client-side forms that strictly adhere to these architectural patterns:
|
||||
|
||||
1. **Form Structure Requirements:**
|
||||
- Always use `useForm` from react-hook-form WITHOUT redundant generic types when using zodResolver
|
||||
- Implement Zod schemas for validation, stored in `_lib/schemas/` directory
|
||||
- Use `@kit/ui/form` components (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage)
|
||||
- Handle loading states with `useTransition` hook
|
||||
- Implement proper error handling with try/catch blocks
|
||||
|
||||
2. **Server Action Integration:**
|
||||
- Call server actions within `startTransition` for proper loading states
|
||||
- Handle redirect errors using `isRedirectError` from 'next/dist/client/components/redirect-error'
|
||||
- Display error states using Alert components from '@kit/ui/alert'
|
||||
- Ensure server actions are imported from dedicated server files
|
||||
|
||||
3. **Code Organization Pattern:**
|
||||
```
|
||||
_lib/
|
||||
├── schemas/
|
||||
│ └── feature.schema.ts # Shared Zod schemas
|
||||
├── server/
|
||||
│ └── server-actions.ts # Server actions
|
||||
└── client/
|
||||
└── forms.tsx # Form components
|
||||
```
|
||||
|
||||
4. **Import Guidelines:**
|
||||
- Toast notifications: `import { toast } from '@kit/ui/sonner'`
|
||||
- Form components: `import { Form, FormField, ... } from '@kit/ui/form'`
|
||||
- Always check @kit/ui for components before using external packages
|
||||
- Use `Trans` component from '@kit/ui/trans' for internationalization
|
||||
|
||||
5. **Best Practices You Must Follow:**
|
||||
- Add `data-test` attributes for E2E testing on form elements and submit buttons
|
||||
- Use `reValidateMode: 'onChange'` and `mode: 'onChange'` for responsive validation
|
||||
- Implement proper TypeScript typing without using `any`
|
||||
- Handle both success and error states gracefully
|
||||
- Use `If` component from '@kit/ui/if' for conditional rendering
|
||||
- Disable submit buttons during pending states
|
||||
- Include FormDescription for user guidance
|
||||
- Use Dialog components from '@kit/ui/dialog' when forms are in modals
|
||||
|
||||
6. **State Management:**
|
||||
- Use `useState` for error states
|
||||
- Use `useTransition` for pending states
|
||||
- Avoid multiple separate useState calls - prefer single state objects when appropriate
|
||||
- Never use useEffect unless absolutely necessary and justified
|
||||
|
||||
7. **Validation Patterns:**
|
||||
- Create reusable Zod schemas that can be shared between client and server
|
||||
- Use schema.refine() for custom validation logic
|
||||
- Provide clear, user-friendly error messages
|
||||
- Implement field-level validation with proper error display
|
||||
|
||||
8. **Error Handling Template:**
|
||||
```typescript
|
||||
const onSubmit = (data: FormData) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await serverAction(data);
|
||||
} catch (error) {
|
||||
if (!isRedirectError(error)) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
9. **Type Safety:**
|
||||
- Let zodResolver infer types - don't add redundant generics
|
||||
- Export schema types when needed for reuse
|
||||
- Ensure all form fields have proper typing
|
||||
|
||||
10. **Accessibility and UX:**
|
||||
- Always include FormLabel for screen readers
|
||||
- Provide helpful FormDescription text
|
||||
- Show clear error messages with FormMessage
|
||||
- Implement loading indicators during form submission
|
||||
- Use semantic HTML and ARIA attributes where appropriate
|
||||
|
||||
When creating forms, you will analyze requirements and produce complete, production-ready implementations that handle all edge cases, provide excellent user feedback, and maintain consistency with the codebase's established patterns. You prioritize type safety, reusability, and maintainability in every form you create.
|
||||
|
||||
Always verify that UI components exist in @kit/ui before importing from external packages, and ensure your forms integrate seamlessly with the project's internationalization system using Trans components.
|
||||
205
.claude/commands/feature-builder.md
Normal file
205
.claude/commands/feature-builder.md
Normal file
@@ -0,0 +1,205 @@
|
||||
---
|
||||
description: End-to-end feature implementation following Makerkit patterns across database, API, and UI layers
|
||||
---
|
||||
|
||||
# Feature Builder
|
||||
|
||||
You are an expert at implementing complete features in Makerkit following established patterns across all layers.
|
||||
|
||||
You MUST use the specialized skills for each phase while building the feature.
|
||||
|
||||
- Database Schema: `postgres-supabase-expert`
|
||||
- Server Layer: `server-action-builder`
|
||||
- Forms: `forms-builder`
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Database Schema
|
||||
|
||||
Use `postgres-supabase-expert` skill.
|
||||
|
||||
1. Create schema file in `apps/web/supabase/schemas/`
|
||||
2. Enable RLS and create policies using helper functions
|
||||
3. Generate migration: `pnpm --filter web supabase:db:diff -f feature_name`
|
||||
4. Apply: `pnpm --filter web supabase migrations up`
|
||||
5. Generate types: `pnpm supabase:web:typegen`
|
||||
|
||||
```sql
|
||||
-- Example: apps/web/supabase/schemas/20-projects.sql
|
||||
create table if not exists public.projects (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
name varchar(255) not null,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
alter table "public"."projects" enable row level security;
|
||||
revoke all on public.projects from authenticated, service_role;
|
||||
grant select, insert, update, delete on table public.projects to authenticated;
|
||||
|
||||
create policy "projects_read" on public.projects for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
create policy "projects_write" on public.projects for all
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'projects.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Phase 2: Server Layer
|
||||
|
||||
Use `server-action-builder` skill for detailed patterns.
|
||||
|
||||
Create in route's `_lib/server/` directory:
|
||||
|
||||
1. **Schema** (`_lib/schemas/feature.schema.ts`)
|
||||
2. **Service** (`_lib/server/feature.service.ts`)
|
||||
3. **Actions** (`_lib/server/server-actions.ts`)
|
||||
|
||||
### Phase 3: UI Components
|
||||
|
||||
Use `form-builder` skill for form patterns.
|
||||
|
||||
Create in route's `_components/` directory:
|
||||
|
||||
1. **List component** - Display items with loading states
|
||||
2. **Form component** - Create/edit with validation
|
||||
3. **Detail component** - Single item view
|
||||
|
||||
### Phase 4: Page Integration
|
||||
|
||||
Create page in appropriate route group:
|
||||
- Personal: `apps/web/app/home/(user)/feature/`
|
||||
- Team: `apps/web/app/home/[account]/feature/`
|
||||
|
||||
```typescript
|
||||
// apps/web/app/home/[account]/projects/page.tsx
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { ProjectsList } from './_components/projects-list';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function ProjectsPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: projects } = await client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Projects" />
|
||||
<PageBody>
|
||||
<ProjectsList projects={projects ?? []} accountSlug={account} />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Navigation
|
||||
|
||||
Add routes to sidebar navigation in `apps/web/config/team-account-navigation.config.tsx` or `apps/web/config/personal-account-navigation.config.tsx`.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
apps/web/app/home/[account]/projects/
|
||||
├── page.tsx # List page
|
||||
├── [projectId]/
|
||||
│ └── page.tsx # Detail page
|
||||
├── _components/
|
||||
│ ├── projects-list.tsx
|
||||
│ ├── project-form.tsx
|
||||
│ └── project-card.tsx
|
||||
└── _lib/
|
||||
├── schemas/
|
||||
│ └── project.schema.ts
|
||||
└── server/
|
||||
├── project.service.ts
|
||||
└── server-actions.ts
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Database Layer
|
||||
|
||||
- [ ] Schema file created in `apps/web/supabase/schemas/`
|
||||
- [ ] RLS enabled on table
|
||||
- [ ] Default permissions revoked
|
||||
- [ ] Specific permissions granted to `authenticated`
|
||||
- [ ] RLS policies use helper functions (`has_role_on_account`, `has_permission`)
|
||||
- [ ] Indexes added for foreign keys and common queries
|
||||
- [ ] Timestamps triggers added if applicable
|
||||
- [ ] Migration generated and applied
|
||||
- [ ] TypeScript types regenerated
|
||||
|
||||
### Server Layer
|
||||
|
||||
- [ ] Zod schema in `_lib/schemas/`
|
||||
- [ ] Service class in `_lib/server/`
|
||||
- [ ] Server actions use `enhanceAction`
|
||||
- [ ] Actions have `auth: true` and `schema` options
|
||||
- [ ] Logging added for operations
|
||||
- [ ] `revalidatePath` called after mutations
|
||||
- [ ] Error handling with `isRedirectError` check if applicable (i.e. when using redirect() in a server action)
|
||||
|
||||
### UI Layer
|
||||
|
||||
- [ ] Components in `_components/` directory
|
||||
- [ ] Forms use `react-hook-form` with `zodResolver`
|
||||
- [ ] Loading states with `useTransition`
|
||||
- [ ] Error display with `Alert` component
|
||||
- [ ] `data-test` attributes for E2E testing
|
||||
- [ ] `Trans` component for all user-facing strings
|
||||
- [ ] Toast notifications for success/error if applicable
|
||||
|
||||
### Page Layer
|
||||
|
||||
- [ ] Page in correct route group (user vs team)
|
||||
- [ ] Async params handled with `await params`
|
||||
- [ ] Server-side data fetching
|
||||
- [ ] `PageHeader` and `PageBody` components used
|
||||
- [ ] Proper error boundaries
|
||||
|
||||
### Navigation
|
||||
|
||||
- [ ] Path added to `config/paths.config.ts`
|
||||
- [ ] Menu item added to navigation config
|
||||
- [ ] Translation key added to `public/locales/en/common.json`
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] Page Object created for E2E tests
|
||||
- [ ] Basic CRUD operations tested
|
||||
- [ ] Error states tested
|
||||
- [ ] `data-test` selectors used in tests
|
||||
|
||||
### Final Verification
|
||||
|
||||
```bash
|
||||
# Type check
|
||||
pnpm typecheck
|
||||
|
||||
# Lint
|
||||
pnpm lint:fix
|
||||
|
||||
# Format
|
||||
pnpm format:fix
|
||||
|
||||
# Test (if tests exist)
|
||||
pnpm --filter web-e2e exec playwright test feature-name --workers=1
|
||||
```
|
||||
|
||||
When you are done, run the code quality reviewer agent to verify the code quality.
|
||||
311
.claude/evals/feature-implementation.md
Normal file
311
.claude/evals/feature-implementation.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Agent Evaluation: Full Feature Implementation
|
||||
|
||||
This eval tests whether the agent correctly follows Makerkit patterns when implementing a complete feature spanning database, API, and UI layers.
|
||||
|
||||
## Eval Metadata
|
||||
|
||||
- **Type**: Capability eval (target: improvement over time)
|
||||
- **Complexity**: High (multi-step, multi-file)
|
||||
- **Expected Duration**: 15-30 minutes
|
||||
- **Skills Tested**: `/feature-builder`, `/server-action-builder`, `/react-form-builder`, `/postgres-expert`, `/navigation-config`
|
||||
|
||||
---
|
||||
|
||||
## Task: Implement "Projects" Feature
|
||||
|
||||
### Prompt
|
||||
|
||||
```
|
||||
Implement a "Projects" feature for team accounts with the following requirements:
|
||||
|
||||
1. Database: Projects table with name, description, status (enum: draft/active/archived), and account_id
|
||||
2. Server: CRUD actions for projects (create, update, delete, list)
|
||||
3. UI: Projects list page with create/edit forms
|
||||
4. Navigation: Add to team sidebar
|
||||
|
||||
Use the available skills for guidance. The feature should be accessible at /home/[account]/projects.
|
||||
```
|
||||
|
||||
### Reference Solution Exists
|
||||
|
||||
A correct implementation requires:
|
||||
- 1 schema file
|
||||
- 1 migration
|
||||
- 1 Zod schema file
|
||||
- 1 service file
|
||||
- 1 server actions file
|
||||
- 2-3 component files
|
||||
- 1 page file
|
||||
- Config updates (paths, navigation, translations)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria (Grading Rubric)
|
||||
|
||||
### 1. Database Layer (25 points)
|
||||
|
||||
| Criterion | Points | Grader Type | Pass Condition |
|
||||
|-----------|--------|-------------|----------------|
|
||||
| Schema file created in `apps/web/supabase/schemas/` | 3 | Code | File exists with `.sql` extension |
|
||||
| Table has correct columns | 5 | Code | Contains: id, account_id, name, description, status, created_at |
|
||||
| RLS enabled | 5 | Code | Contains `enable row level security` |
|
||||
| Uses helper functions in policies | 5 | Code | Contains `has_role_on_account` OR `has_permission` |
|
||||
| Permissions revoked/granted correctly | 4 | Code | Contains `revoke all` AND `grant select, insert, update, delete` |
|
||||
| Status enum created | 3 | Code | Contains `create type` with draft/active/archived |
|
||||
|
||||
**Anti-patterns to penalize (-3 each):**
|
||||
- SECURITY DEFINER without access checks
|
||||
- Missing `on delete cascade` for account_id FK
|
||||
- No index on account_id
|
||||
|
||||
### 2. Server Layer (25 points)
|
||||
|
||||
| Criterion | Points | Grader Type | Pass Condition |
|
||||
|-----------|--------|-------------|----------------|
|
||||
| Zod schema in `_lib/schemas/` | 3 | Code | File exists, exports schema with `z.object` |
|
||||
| Service class pattern used | 5 | Code | Contains `class` with methods, uses `getSupabaseServerClient` |
|
||||
| Actions use `enhanceAction` | 5 | Code | Import from `@kit/next/actions`, wraps handler |
|
||||
| Actions have `auth: true` | 3 | Code | Options object contains `auth: true` |
|
||||
| Actions have `schema` validation | 3 | Code | Options object contains `schema:` |
|
||||
| Uses `revalidatePath` after mutations | 3 | Code | Import and call `revalidatePath` |
|
||||
| Logging with `getLogger` | 3 | Model | Appropriate logging before/after operations |
|
||||
|
||||
**Anti-patterns to penalize (-3 each):**
|
||||
- Manual auth checks instead of trusting RLS
|
||||
- `await logger.info()` (logger methods are not promises)
|
||||
- Business logic in action instead of service
|
||||
|
||||
### 3. UI Layer (25 points)
|
||||
|
||||
| Criterion | Points | Grader Type | Pass Condition |
|
||||
|-----------|--------|-------------|----------------|
|
||||
| Components in `_components/` directory | 2 | Code | Path contains `_components/` |
|
||||
| Form uses `react-hook-form` with `zodResolver` | 5 | Code | Imports both, uses `useForm({ resolver: zodResolver() })` |
|
||||
| No generics on `useForm` | 3 | Code | NOT contains `useForm<` |
|
||||
| Uses `@kit/ui/form` components | 4 | Code | Imports `Form, FormField, FormItem, FormLabel, FormControl, FormMessage` |
|
||||
| Uses `Trans` for strings | 3 | Code | Import from `@kit/ui/trans`, uses `<Trans i18nKey=` |
|
||||
| Uses `useTransition` for loading | 3 | Code | `const [pending, startTransition] = useTransition()` |
|
||||
| Has `data-test` attributes | 3 | Code | Contains `data-test=` on form/buttons |
|
||||
| Error handling with `isRedirectError` | 2 | Code | Import and check in catch block |
|
||||
|
||||
**Anti-patterns to penalize (-3 each):**
|
||||
- `useForm<SomeType>` with explicit generic
|
||||
- Using `watch()` instead of `useWatch`
|
||||
- Hardcoded strings without `Trans`
|
||||
- Missing `FormMessage` for error display
|
||||
|
||||
### 4. Integration & Navigation (15 points)
|
||||
|
||||
| Criterion | Points | Grader Type | Pass Condition |
|
||||
|-----------|--------|-------------|----------------|
|
||||
| Page in correct route group | 3 | Code | Path is `app/home/[account]/projects/page.tsx` |
|
||||
| Uses `await params` pattern | 3 | Code | Contains `const { account } = await params` |
|
||||
| Path added to `paths.config.ts` | 3 | Code | Contains `projects` path |
|
||||
| Nav item added to team config | 3 | Code | Entry in `team-account-navigation.config.tsx` |
|
||||
| Translation key added | 3 | Code | Entry in `public/locales/en/common.json` |
|
||||
|
||||
### 5. Code Quality (10 points)
|
||||
|
||||
| Criterion | Points | Grader Type | Pass Condition |
|
||||
|-----------|--------|-------------|----------------|
|
||||
| TypeScript compiles | 5 | Code | `pnpm typecheck` exits 0 |
|
||||
| Lint passes | 3 | Code | `pnpm lint:fix` exits 0 |
|
||||
| Format passes | 2 | Code | `pnpm format:fix` exits 0 |
|
||||
|
||||
---
|
||||
|
||||
## Grader Implementation
|
||||
|
||||
### Code-Based Grader (Automated)
|
||||
|
||||
```typescript
|
||||
interface EvalResult {
|
||||
score: number;
|
||||
maxScore: number;
|
||||
passed: boolean;
|
||||
details: {
|
||||
criterion: string;
|
||||
points: number;
|
||||
maxPoints: number;
|
||||
evidence: string;
|
||||
}[];
|
||||
antiPatterns: string[];
|
||||
}
|
||||
|
||||
async function gradeFeatureImplementation(): Promise<EvalResult> {
|
||||
const details = [];
|
||||
const antiPatterns = [];
|
||||
|
||||
// 1. Check schema file
|
||||
const schemaFiles = glob('apps/web/supabase/schemas/*project*.sql');
|
||||
const schemaContent = schemaFiles.length > 0 ? read(schemaFiles[0]) : '';
|
||||
|
||||
details.push({
|
||||
criterion: 'Schema file exists',
|
||||
points: schemaFiles.length > 0 ? 3 : 0,
|
||||
maxPoints: 3,
|
||||
evidence: schemaFiles[0] || 'No schema file found'
|
||||
});
|
||||
|
||||
details.push({
|
||||
criterion: 'RLS enabled',
|
||||
points: schemaContent.includes('enable row level security') ? 5 : 0,
|
||||
maxPoints: 5,
|
||||
evidence: 'Checked for RLS statement'
|
||||
});
|
||||
|
||||
// Check anti-patterns
|
||||
if (schemaContent.includes('security definer') &&
|
||||
!schemaContent.includes('has_permission') &&
|
||||
!schemaContent.includes('is_account_owner')) {
|
||||
antiPatterns.push('SECURITY DEFINER without access validation');
|
||||
}
|
||||
|
||||
// 2. Check server files
|
||||
const actionFiles = glob('apps/web/app/home/[account]/projects/**/*actions*.ts');
|
||||
const actionContent = actionFiles.length > 0 ? read(actionFiles[0]) : '';
|
||||
|
||||
details.push({
|
||||
criterion: 'Uses enhanceAction',
|
||||
points: actionContent.includes('enhanceAction') ? 5 : 0,
|
||||
maxPoints: 5,
|
||||
evidence: 'Checked for enhanceAction import/usage'
|
||||
});
|
||||
|
||||
if (actionContent.includes('await logger.info')) {
|
||||
antiPatterns.push('await on logger.info (not a promise)');
|
||||
}
|
||||
|
||||
// 3. Check UI files
|
||||
const componentFiles = glob('apps/web/app/home/[account]/projects/_components/*.tsx');
|
||||
const formContent = componentFiles.map(f => read(f)).join('\n');
|
||||
|
||||
details.push({
|
||||
criterion: 'No generics on useForm',
|
||||
points: !formContent.includes('useForm<') ? 3 : 0,
|
||||
maxPoints: 3,
|
||||
evidence: 'Checked for useForm<Type> pattern'
|
||||
});
|
||||
|
||||
if (formContent.includes('useForm<')) {
|
||||
antiPatterns.push('Explicit generic on useForm (should use zodResolver inference)');
|
||||
}
|
||||
|
||||
// 4. Check integration
|
||||
const pathsConfig = read('apps/web/config/paths.config.ts');
|
||||
details.push({
|
||||
criterion: 'Path configured',
|
||||
points: pathsConfig.includes('projects') ? 3 : 0,
|
||||
maxPoints: 3,
|
||||
evidence: 'Checked paths.config.ts'
|
||||
});
|
||||
|
||||
// 5. Run verification
|
||||
const typecheckResult = await exec('pnpm typecheck');
|
||||
details.push({
|
||||
criterion: 'TypeScript compiles',
|
||||
points: typecheckResult.exitCode === 0 ? 5 : 0,
|
||||
maxPoints: 5,
|
||||
evidence: `Exit code: ${typecheckResult.exitCode}`
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
const score = details.reduce((sum, d) => sum + d.points, 0);
|
||||
const maxScore = details.reduce((sum, d) => sum + d.maxPoints, 0);
|
||||
const penaltyPoints = antiPatterns.length * 3;
|
||||
|
||||
return {
|
||||
score: Math.max(0, score - penaltyPoints),
|
||||
maxScore,
|
||||
passed: (score - penaltyPoints) >= maxScore * 0.8, // 80% threshold
|
||||
details,
|
||||
antiPatterns
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Model-Based Grader (For Nuanced Criteria)
|
||||
|
||||
```
|
||||
You are evaluating an AI agent's implementation of a "Projects" feature in a Makerkit SaaS application.
|
||||
|
||||
Review the following files and assess:
|
||||
|
||||
1. **Logging Quality** (0-3 points):
|
||||
- Are log messages descriptive and include relevant context (userId, projectId)?
|
||||
- Is logging done before AND after important operations?
|
||||
- Are error cases logged with appropriate severity?
|
||||
|
||||
2. **Code Organization** (0-3 points):
|
||||
- Is business logic in services, not actions?
|
||||
- Are files in the correct directories per Makerkit conventions?
|
||||
- Is there appropriate separation of concerns?
|
||||
|
||||
3. **Error Handling** (0-3 points):
|
||||
- Are errors handled gracefully?
|
||||
- Does the UI show appropriate error states?
|
||||
- Are redirect errors handled correctly?
|
||||
|
||||
Provide a score for each criterion with brief justification.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trial Configuration
|
||||
|
||||
```yaml
|
||||
trials: 3 # Run 3 times to account for non-determinism
|
||||
pass_threshold: 0.8 # 80% of max score
|
||||
metrics:
|
||||
- pass@1: "Passes on first attempt"
|
||||
- pass@3: "Passes at least once in 3 attempts"
|
||||
- pass^3: "Passes all 3 attempts (reliability)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Before each trial:
|
||||
1. Reset to clean git state: `git checkout -- .`
|
||||
2. Ensure Supabase types are current: `pnpm supabase:web:typegen`
|
||||
3. Verify clean typecheck: `pnpm typecheck`
|
||||
|
||||
After each trial:
|
||||
1. Capture transcript (full conversation)
|
||||
2. Capture outcome (files created/modified)
|
||||
3. Run graders
|
||||
4. Reset environment
|
||||
|
||||
---
|
||||
|
||||
## Expected Failure Modes
|
||||
|
||||
Document these to distinguish agent errors from eval problems:
|
||||
|
||||
| Failure | Likely Cause | Is Eval Problem? |
|
||||
|---------|--------------|------------------|
|
||||
| Missing RLS | Agent didn't follow postgres-expert skill | No |
|
||||
| `useForm<Type>` | Agent ignored react-form-builder guidance | No |
|
||||
| Wrong file path | Ambiguous task description | Maybe - clarify paths |
|
||||
| Typecheck fails on unrelated code | Existing codebase issue | Yes - fix baseline |
|
||||
| Agent uses different but valid approach | Eval too prescriptive | Yes - grade outcome not path |
|
||||
|
||||
---
|
||||
|
||||
## Iteration Log
|
||||
|
||||
Track eval refinements here:
|
||||
|
||||
| Date | Change | Reason |
|
||||
|------|--------|--------|
|
||||
| Initial | Created eval | - |
|
||||
| | | |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Grade outcomes, not paths**: If agent creates a working feature with slightly different file organization, that's acceptable
|
||||
- **Partial credit**: A feature missing navigation but with working CRUD is still valuable
|
||||
- **Read transcripts**: When scores are low, check if agent attempted to use skills or ignored them entirely
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: playwright-e2e-expert
|
||||
description: Use this agent when you need to write, review, or debug end-to-end tests using Playwright. This includes creating new test suites, fixing flaky tests, implementing complex UI interaction sequences, or ensuring test reliability and isolation. The agent excels at handling asynchronous operations, managing test concurrency, and applying Playwright best practices.\n\nExamples:\n<example>\nContext: The user needs to write e2e tests for a new feature.\nuser: "Write tests for the user registration flow"\nassistant: "I'll use the playwright-e2e-expert agent to create comprehensive end-to-end tests for the registration flow."\n<commentary>\nSince the user needs e2e tests written, use the Task tool to launch the playwright-e2e-expert agent to create robust Playwright tests.\n</commentary>\n</example>\n<example>\nContext: The user has flaky tests that need fixing.\nuser: "These login tests keep failing intermittently"\nassistant: "Let me use the playwright-e2e-expert agent to diagnose and fix the flaky test issues."\n<commentary>\nThe user has problematic e2e tests, so use the playwright-e2e-expert agent to apply best practices for test stability.\n</commentary>\n</example>\n<example>\nContext: After implementing a complex UI feature, e2e tests should be written.\nuser: "I've just finished the multi-step checkout process"\nassistant: "Now I'll use the playwright-e2e-expert agent to create thorough e2e tests for the checkout flow."\n<commentary>\nProactively use the playwright-e2e-expert agent after complex UI features are implemented to ensure proper test coverage.\n</commentary>\n</example>
|
||||
model: sonnet
|
||||
color: green
|
||||
name: playwright-e2e
|
||||
description: Write, review, or debug end-to-end tests using Playwright. Use when creating test suites, fixing flaky tests, implementing UI interaction sequences, or ensuring test reliability. Invoke with /playwright-e2e or when user mentions e2e tests, Playwright, or test automation.
|
||||
---
|
||||
|
||||
# Playwright E2E Testing Expert
|
||||
|
||||
You are an elite QA automation engineer with deep expertise in Playwright and end-to-end testing. Your mastery encompasses the intricacies of browser automation, asynchronous JavaScript execution, and the unique challenges of UI testing.
|
||||
|
||||
**Core Expertise:**
|
||||
## Core Expertise
|
||||
|
||||
You understand that e2e testing requires a fundamentally different approach from unit testing. You know that UI interactions are inherently asynchronous and that timing issues are the root of most test failures. You excel at:
|
||||
|
||||
@@ -17,7 +17,7 @@ You understand that e2e testing requires a fundamentally different approach from
|
||||
- Managing test isolation through proper setup and teardown procedures
|
||||
- Handling dynamic content, animations, and network requests gracefully
|
||||
|
||||
**Testing Philosophy:**
|
||||
## Testing Philosophy
|
||||
|
||||
You write tests that verify actual user workflows and business logic, not trivial UI presence checks. Each test you create:
|
||||
- Has a clear purpose and tests meaningful functionality
|
||||
@@ -26,7 +26,7 @@ You write tests that verify actual user workflows and business logic, not trivia
|
||||
- Avoids conditional logic that makes tests unpredictable
|
||||
- Includes descriptive test names that explain what is being tested and why
|
||||
|
||||
**Technical Approach:**
|
||||
## Technical Approach
|
||||
|
||||
When writing tests, you:
|
||||
1. Always use `await` for every Playwright action and assertion
|
||||
@@ -34,9 +34,9 @@ When writing tests, you:
|
||||
3. Use `expect()` with Playwright's web-first assertions for automatic retries
|
||||
4. Implement Page Object Model when tests become complex
|
||||
5. Never use `page.waitForTimeout()` except as an absolute last resort
|
||||
6. Chain actions logically: interact → wait for response → assert → proceed
|
||||
6. Chain actions logically: interact -> wait for response -> assert -> proceed
|
||||
|
||||
**Common Pitfalls You Avoid:**
|
||||
## Common Pitfalls You Avoid
|
||||
|
||||
- Race conditions from not waiting for network requests or state changes
|
||||
- Brittle selectors that break with minor UI changes
|
||||
@@ -45,7 +45,7 @@ When writing tests, you:
|
||||
- Missing error boundaries that cause cascading failures
|
||||
- Ignoring viewport sizes and responsive behavior
|
||||
|
||||
**Best Practices You Follow:**
|
||||
## Best Practices
|
||||
|
||||
```typescript
|
||||
// You write tests like this:
|
||||
@@ -74,6 +74,8 @@ test('user can complete checkout', async ({ page }) => {
|
||||
|
||||
You understand that e2e tests are expensive to run and maintain, so each test you write provides maximum value. You balance thoroughness with practicality, ensuring tests are comprehensive enough to catch real issues but simple enough to debug when they fail.
|
||||
|
||||
## Debugging Failed Tests
|
||||
|
||||
When debugging failed tests, you systematically analyze:
|
||||
1. Screenshots and trace files to understand the actual state
|
||||
2. Network activity to identify failed or slow requests
|
||||
156
.claude/skills/playwright-e2e/makerkit.md
Normal file
156
.claude/skills/playwright-e2e/makerkit.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Makerkit E2E Testing Patterns
|
||||
|
||||
## Page Objects Location
|
||||
|
||||
`apps/e2e/tests/*.po.ts`
|
||||
|
||||
## Auth Page Object
|
||||
|
||||
```typescript
|
||||
export class AuthPageObject {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
static MFA_KEY = 'test-mfa-key';
|
||||
|
||||
async signIn(params: { email: string; password: string }) {
|
||||
await this.page.fill('input[name="email"]', params.email);
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||
}
|
||||
|
||||
async bootstrapUser(params: { email: string; password: string; name: string }) {
|
||||
// Creates user via API
|
||||
await fetch('/api/test/create-user', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
async loginAsUser(params: { email: string; password: string }) {
|
||||
await this.page.goto('/auth/sign-in');
|
||||
await this.signIn(params);
|
||||
await this.page.waitForURL('**/home/**');
|
||||
}
|
||||
|
||||
createRandomEmail() {
|
||||
const value = Math.random() * 10000000000000;
|
||||
return `${value.toFixed(0)}@makerkit.dev`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Selectors
|
||||
|
||||
```typescript
|
||||
// Account dropdown
|
||||
'[data-test="account-dropdown-trigger"]'
|
||||
'[data-test="account-dropdown-sign-out"]'
|
||||
|
||||
// Navigation
|
||||
'[data-test="sidebar-menu"]'
|
||||
'[data-test="mobile-menu-trigger"]'
|
||||
|
||||
// Forms
|
||||
'[data-test="submit-button"]'
|
||||
'[data-test="cancel-button"]'
|
||||
|
||||
// Modals
|
||||
'[data-test="dialog-confirm"]'
|
||||
'[data-test="dialog-cancel"]'
|
||||
```
|
||||
|
||||
## Test Setup Pattern
|
||||
|
||||
```typescript
|
||||
// tests/auth.setup.ts
|
||||
import { test as setup } from '@playwright/test';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.bootstrapUser({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
await auth.loginAsUser({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
// Save authentication state
|
||||
await page.context().storageState({ path: '.auth/user.json' });
|
||||
});
|
||||
```
|
||||
|
||||
## Reliability Patterns
|
||||
|
||||
### OTP/Email Operations
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||
expect(otpCode).not.toBeNull();
|
||||
await this.enterOtpCode(otpCode);
|
||||
}).toPass();
|
||||
```
|
||||
|
||||
### MFA Verification
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||
}).toPass({
|
||||
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||
});
|
||||
```
|
||||
|
||||
### Network Requests
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
const response = await this.page.waitForResponse(
|
||||
resp => resp.url().includes('auth/v1/user')
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
}).toPass();
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
apps/e2e/
|
||||
├── playwright.config.ts
|
||||
├── tests/
|
||||
│ ├── auth.setup.ts
|
||||
│ ├── authentication/
|
||||
│ │ ├── sign-in.spec.ts
|
||||
│ │ └── sign-up.spec.ts
|
||||
│ ├── billing/
|
||||
│ │ └── subscription.spec.ts
|
||||
│ ├── teams/
|
||||
│ │ └── invitations.spec.ts
|
||||
│ └── utils/
|
||||
│ └── auth.po.ts
|
||||
└── .auth/
|
||||
└── user.json
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Single file
|
||||
pnpm --filter web-e2e exec playwright test authentication --workers=1
|
||||
|
||||
# With UI
|
||||
pnpm --filter web-e2e exec playwright test --ui
|
||||
|
||||
# Debug mode
|
||||
pnpm --filter web-e2e exec playwright test --debug
|
||||
```
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: postgres-expert
|
||||
description: MUST USE this agent when you need to create, review, optimize, or test SQL, PostgreSQL, Supabase database code including schemas, migrations, functions, triggers, RLS policies, and PgTAP tests. This includes tasks like designing new database schemas, reviewing existing SQL for safety and performance, writing migrations that preserve data integrity, implementing row-level security, optimizing queries, or creating comprehensive database tests.\n\nExamples:\n- <example>\n Context: The user needs to create a new database schema for a feature.\n user: "I need to add a comments system to my app with proper permissions"\n assistant: "I'll use the postgres-expert agent to design a robust comments schema with RLS policies"\n <commentary>\n Since this involves creating database schemas and security policies, the postgres-expert should handle this.\n </commentary>\n</example>\n- <example>\n Context: The user has written a migration and wants it reviewed.\n user: "I've created a migration to add user profiles, can you check if it's safe?"\n assistant: "Let me use the postgres-expert agent to review your migration for safety and best practices"\n <commentary>\n Database migration review requires expertise in non-destructive changes and data integrity.\n </commentary>\n</example>\n- <example>\n Context: The user needs help with database performance.\n user: "My query is running slowly, it's fetching posts with their comments"\n assistant: "I'll engage the postgres-expert agent to analyze and optimize your query performance"\n <commentary>\n Query optimization requires deep PostgreSQL knowledge that this specialist agent provides.\n </commentary>\n</example>
|
||||
model: sonnet
|
||||
color: green
|
||||
name: postgres-supabase-expert
|
||||
description: Create, review, optimize, or test PostgreSQL and Supabase database code including SQL code, schemas, migrations, functions, triggers, RLS policies, and PgTAP tests. Use when writing and designing schemas, reviewing SQL for safety, writing migrations, implementing row-level security, or optimizing queries. Invoke with /postgres-supabase-expert or when user mentions database, SQL, migrations, RLS, or schema design.
|
||||
---
|
||||
|
||||
# PostgreSQL & Supabase Database Expert
|
||||
|
||||
You are an elite PostgreSQL and Supabase database architect with deep expertise in designing, implementing, and testing production-grade database systems. Your mastery spans schema design, performance optimization, data integrity, security, and testing methodologies.
|
||||
|
||||
## Core Expertise
|
||||
@@ -110,3 +110,11 @@ You will anticipate and handle:
|
||||
When reviewing existing code, you will identify issues related to security vulnerabilities, performance bottlenecks, data integrity risks, missing indexes, improper transaction boundaries, and suggest specific, actionable improvements with example code.
|
||||
|
||||
You communicate technical concepts clearly, providing rationale for all recommendations and trade-offs for different approaches. You stay current with PostgreSQL and Supabase latest features and best practices.
|
||||
|
||||
## Examples
|
||||
|
||||
See `[Examples](examples.md)` for examples of database code.
|
||||
|
||||
## Patterns and Functions
|
||||
|
||||
See `[Patterns and Functions](makerkit.md)` for patterns and functions.
|
||||
144
.claude/skills/postgres-expert/examples.md
Normal file
144
.claude/skills/postgres-expert/examples.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Makerkit Database Examples
|
||||
|
||||
Real examples from the codebase.
|
||||
|
||||
## Accounts Schema
|
||||
|
||||
Location: `apps/web/supabase/schemas/03-accounts.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.accounts (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
primary_owner_user_id uuid references auth.users on delete cascade not null,
|
||||
name varchar(255) not null,
|
||||
slug varchar(255) unique,
|
||||
is_personal_account boolean not null default false,
|
||||
picture_url varchar(1000),
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
alter table "public"."accounts" enable row level security;
|
||||
```
|
||||
|
||||
## Account Memberships
|
||||
|
||||
Location: `apps/web/supabase/schemas/04-accounts-memberships.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.accounts_memberships (
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
user_id uuid references auth.users(id) on delete cascade not null,
|
||||
account_role varchar(50) not null,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
created_by uuid references auth.users(id),
|
||||
updated_by uuid references auth.users(id),
|
||||
primary key (account_id, user_id)
|
||||
);
|
||||
|
||||
-- RLS policies using helper functions
|
||||
create policy accounts_memberships_select on public.accounts_memberships
|
||||
for select to authenticated using (
|
||||
user_id = (select auth.uid())
|
||||
or public.has_role_on_account(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Subscriptions
|
||||
|
||||
Location: `apps/web/supabase/schemas/11-subscriptions.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.subscriptions (
|
||||
id varchar(255) not null,
|
||||
account_id uuid not null references public.accounts(id) on delete cascade,
|
||||
billing_customer_id varchar(255) not null,
|
||||
status public.subscription_status not null,
|
||||
currency varchar(10) not null,
|
||||
cancel_at_period_end boolean not null default false,
|
||||
period_starts_at timestamp with time zone,
|
||||
period_ends_at timestamp with time zone,
|
||||
trial_starts_at timestamp with time zone,
|
||||
trial_ends_at timestamp with time zone,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
updated_at timestamp with time zone not null default now(),
|
||||
primary key (id)
|
||||
);
|
||||
```
|
||||
|
||||
## Notifications
|
||||
|
||||
Location: `apps/web/supabase/schemas/12-notifications.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
type public.notification_type not null,
|
||||
body jsonb not null default '{}',
|
||||
dismissed boolean not null default false,
|
||||
link text,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
|
||||
-- Only account members can see notifications
|
||||
create policy read_notifications on public.notifications
|
||||
for select to authenticated using (
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Storage Bucket
|
||||
|
||||
Location: `apps/web/supabase/schemas/16-storage.sql`
|
||||
|
||||
```sql
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('account_image', 'account_image', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
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'
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Enum Types
|
||||
|
||||
```sql
|
||||
-- Subscription status
|
||||
create type public.subscription_status as enum (
|
||||
'active',
|
||||
'trialing',
|
||||
'past_due',
|
||||
'canceled',
|
||||
'unpaid',
|
||||
'incomplete',
|
||||
'incomplete_expired',
|
||||
'paused'
|
||||
);
|
||||
|
||||
-- App permissions
|
||||
create type public.app_permissions as enum (
|
||||
'settings.manage',
|
||||
'billing.manage',
|
||||
'members.manage',
|
||||
'invitations.manage'
|
||||
);
|
||||
```
|
||||
138
.claude/skills/postgres-expert/makerkit.md
Normal file
138
.claude/skills/postgres-expert/makerkit.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Makerkit Database Patterns
|
||||
|
||||
## Schema Location
|
||||
|
||||
All schemas are in `apps/web/supabase/schemas/` with numbered prefixes for dependency ordering.
|
||||
|
||||
## Existing Helper Functions - DO NOT Recreate
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id uuid, role_name? text)
|
||||
public.has_permission(user_id uuid, account_id uuid, permission app_permissions)
|
||||
public.is_account_owner(account_id uuid)
|
||||
public.has_active_subscription(account_id uuid)
|
||||
public.is_team_member(account_id uuid, user_id uuid)
|
||||
public.can_action_account_member(target_account_id uuid, target_user_id uuid)
|
||||
|
||||
-- Administrative
|
||||
public.is_super_admin()
|
||||
public.is_aal2()
|
||||
public.is_mfa_compliant()
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name text)
|
||||
```
|
||||
|
||||
## RLS Policy Patterns
|
||||
|
||||
### Personal + Team Access
|
||||
|
||||
```sql
|
||||
create policy "table_read" on public.table for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Permission-Based Access
|
||||
|
||||
```sql
|
||||
create policy "table_manage" on public.table for all
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'feature.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Storage Bucket Policy
|
||||
|
||||
```sql
|
||||
create policy bucket_policy on storage.objects for all using (
|
||||
bucket_id = 'bucket_name'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Adding New Permissions
|
||||
|
||||
```sql
|
||||
-- Add to app_permissions enum
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'feature.manage';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## Standard Table Template
|
||||
|
||||
```sql
|
||||
create table if not exists public.feature (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
name varchar(255) not null,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
created_by uuid references auth.users(id),
|
||||
updated_by uuid references auth.users(id),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
alter table "public"."feature" enable row level security;
|
||||
|
||||
-- Revoke defaults, grant specific
|
||||
revoke all on public.feature from authenticated, service_role;
|
||||
grant select, insert, update, delete on table public.feature to authenticated;
|
||||
|
||||
-- Add triggers
|
||||
create trigger set_timestamps
|
||||
before insert or update on public.feature
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
create trigger set_user_tracking
|
||||
before insert or update on public.feature
|
||||
for each row execute function public.trigger_set_user_tracking();
|
||||
|
||||
-- Add indexes
|
||||
create index ix_feature_account_id on public.feature(account_id);
|
||||
```
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
```bash
|
||||
# New entity: copy schema to migration
|
||||
pnpm --filter web run supabase migrations new feature_name
|
||||
|
||||
# Modify existing: generate diff
|
||||
pnpm --filter web run supabase:db:diff -f update_feature
|
||||
|
||||
# Apply
|
||||
pnpm --filter web supabase migrations up
|
||||
|
||||
# Generate types
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
## Security Definer Function Pattern
|
||||
|
||||
```sql
|
||||
create or replace function public.admin_function(target_id uuid)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
-- ALWAYS validate permissions first
|
||||
if not public.is_account_owner(target_id) then
|
||||
raise exception 'Access denied';
|
||||
end if;
|
||||
|
||||
-- Safe to proceed
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.admin_function(uuid) to authenticated;
|
||||
```
|
||||
197
.claude/skills/react-form-builder/SKILL.md
Normal file
197
.claude/skills/react-form-builder/SKILL.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: forms-builder
|
||||
description: Create or modify client-side forms in React applications following best practices for react-hook-form, @kit/ui/form components, and server actions integration. Use when building forms with validation, error handling, loading states, and TypeScript typing. Invoke with /react-form-builder or when user mentions creating forms, form validation, or react-hook-form.
|
||||
---
|
||||
|
||||
# React Form Builder Expert
|
||||
|
||||
You are an expert React form architect specializing in building robust, accessible, and type-safe forms using react-hook-form, @kit/ui/form components, and Next.js server actions. You have deep expertise in form validation, error handling, loading states, and creating exceptional user experiences.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
You will create and modify client-side forms that strictly adhere to these architectural patterns:
|
||||
|
||||
### 1. Form Structure Requirements
|
||||
- Always use `useForm` from react-hook-form WITHOUT redundant generic types when using zodResolver
|
||||
- Implement Zod schemas for validation, stored in `_lib/schemas/` directory
|
||||
- Use `@kit/ui/form` components (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage)
|
||||
- Handle loading states with `useTransition` hook
|
||||
- Implement proper error handling with try/catch blocks
|
||||
|
||||
### 2. Server Action Integration
|
||||
- Call server actions within `startTransition` for proper loading states
|
||||
- Handle redirect errors using `isRedirectError` from 'next/dist/client/components/redirect-error'
|
||||
- Display error states using Alert components from '@kit/ui/alert'
|
||||
- Ensure server actions are imported from dedicated server files
|
||||
|
||||
### 3. Code Organization Pattern
|
||||
```
|
||||
_lib/
|
||||
├── schemas/
|
||||
│ └── feature.schema.ts # Shared Zod schemas
|
||||
├── server/
|
||||
│ └── server-actions.ts # Server actions
|
||||
└── client/
|
||||
└── forms.tsx # Form components
|
||||
```
|
||||
|
||||
### 4. Import Guidelines
|
||||
- Toast notifications: `import { toast } from '@kit/ui/sonner'`
|
||||
- Form components: `import { Form, FormField, ... } from '@kit/ui/form'`
|
||||
- Always check @kit/ui for components before using external packages
|
||||
- Use `Trans` component from '@kit/ui/trans' for internationalization
|
||||
|
||||
### 5. Best Practices You Must Follow
|
||||
- Add `data-test` attributes for E2E testing on form elements and submit buttons
|
||||
- Use `reValidateMode: 'onChange'` and `mode: 'onChange'` for responsive validation
|
||||
- Implement proper TypeScript typing without using `any`
|
||||
- Handle both success and error states gracefully
|
||||
- Use `If` component from '@kit/ui/if' for conditional rendering
|
||||
- Disable submit buttons during pending states
|
||||
- Include FormDescription for user guidance
|
||||
- Use Dialog components from '@kit/ui/dialog' when forms are in modals
|
||||
|
||||
### 6. State Management
|
||||
- Use `useState` for error states
|
||||
- Use `useTransition` for pending states
|
||||
- Avoid multiple separate useState calls - prefer single state objects when appropriate
|
||||
- Never use useEffect unless absolutely necessary and justified
|
||||
|
||||
### 7. Validation Patterns
|
||||
- Create reusable Zod schemas that can be shared between client and server
|
||||
- Use schema.refine() for custom validation logic
|
||||
- Provide clear, user-friendly error messages
|
||||
- Implement field-level validation with proper error display
|
||||
|
||||
### 8. Error Handling Template
|
||||
|
||||
```typescript
|
||||
const onSubmit = (data: FormData) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await serverAction(data);
|
||||
} catch (error) {
|
||||
if (!isRedirectError(error)) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 9. Type Safety
|
||||
- Let zodResolver infer types - don't add redundant generics to useForm
|
||||
- Export schema types when needed for reuse
|
||||
- Ensure all form fields have proper typing
|
||||
|
||||
### 10. Accessibility and UX
|
||||
- Always include FormLabel for screen readers
|
||||
- Provide helpful FormDescription text
|
||||
- Show clear error messages with FormMessage
|
||||
- Implement loading indicators during form submission
|
||||
- Use semantic HTML and ARIA attributes where appropriate
|
||||
|
||||
## Complete Form Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTransition, useState } from 'react';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { CreateEntitySchema } from '../_lib/schemas/entity.schema';
|
||||
import { createEntityAction } from '../_lib/server/server-actions';
|
||||
|
||||
export function CreateEntityForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateEntitySchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof CreateEntitySchema>) => {
|
||||
setError(false);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createEntityAction(data);
|
||||
toast.success('Entity created successfully');
|
||||
} catch (e) {
|
||||
if (!isRedirectError(e)) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Form {...form}>
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey="entity:name" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test="entity-name-input"
|
||||
placeholder="Enter name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-test="submit-entity-button"
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey="common:creating" />
|
||||
) : (
|
||||
<Trans i18nKey="common:create" />
|
||||
)}
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
When creating forms, you will analyze requirements and produce complete, production-ready implementations that handle all edge cases, provide excellent user feedback, and maintain consistency with the codebase's established patterns. You prioritize type safety, reusability, and maintainability in every form you create.
|
||||
|
||||
Always verify that UI components exist in @kit/ui before importing from external packages, and ensure your forms integrate seamlessly with the project's internationalization system using Trans components.
|
||||
|
||||
## Components
|
||||
|
||||
See `[Components](components.md)` for examples of form components.
|
||||
249
.claude/skills/react-form-builder/components.md
Normal file
249
.claude/skills/react-form-builder/components.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Makerkit Form Components Reference
|
||||
|
||||
## Import Pattern
|
||||
|
||||
```typescript
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
import { Checkbox } from '@kit/ui/checkbox';
|
||||
import { Switch } from '@kit/ui/switch';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
```
|
||||
|
||||
## Form Field Pattern
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="fieldName"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey="namespace:fieldLabel" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test="field-name-input"
|
||||
placeholder="Enter value"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans i18nKey="namespace:fieldDescription" />
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Select Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="category"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger data-test="category-select">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Checkbox Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="acceptTerms"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
data-test="accept-terms-checkbox"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">
|
||||
<Trans i18nKey="namespace:acceptTerms" />
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Switch Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="notifications"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between">
|
||||
<div>
|
||||
<FormLabel>Enable Notifications</FormLabel>
|
||||
<FormDescription>Receive email notifications</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
data-test="notifications-switch"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Textarea Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
data-test="description-textarea"
|
||||
placeholder="Enter description..."
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Error Alert
|
||||
|
||||
```tsx
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
```
|
||||
|
||||
## Submit Button
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-test="submit-button"
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey="common:submitting" />
|
||||
) : (
|
||||
<Trans i18nKey="common:submit" />
|
||||
)}
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Complete Form Template
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTransition, useState } from 'react';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { MySchema } from '../_lib/schemas/my.schema';
|
||||
import { myAction } from '../_lib/server/server-actions';
|
||||
|
||||
export function MyForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(MySchema),
|
||||
defaultValues: { name: '' },
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof MySchema>) => {
|
||||
setError(false);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await myAction(data);
|
||||
toast.success('Success!');
|
||||
} catch (e) {
|
||||
if (!isRedirectError(e)) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Form {...form}>
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input data-test="name-input" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={pending} data-test="submit-button">
|
||||
{pending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
126
.claude/skills/server-action-builder/SKILL.md
Normal file
126
.claude/skills/server-action-builder/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: server-action-builder
|
||||
description: Create Next.js Server Actions with enhanceAction, Zod validation, and service patterns. Use when implementing mutations, form submissions, or API operations that need authentication and validation. Invoke with /server-action-builder.
|
||||
---
|
||||
|
||||
# Server Action Builder
|
||||
|
||||
You are an expert at creating type-safe server actions for Makerkit following established patterns.
|
||||
|
||||
## Workflow
|
||||
|
||||
When asked to create a server action, follow these steps:
|
||||
|
||||
### Step 1: Create Zod Schema
|
||||
|
||||
Create validation schema in `_lib/schemas/`:
|
||||
|
||||
```typescript
|
||||
// _lib/schemas/feature.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateFeatureSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
export type CreateFeatureInput = z.infer<typeof CreateFeatureSchema>;
|
||||
```
|
||||
|
||||
### Step 2: Create Service Layer
|
||||
|
||||
Create service in `_lib/server/`:
|
||||
|
||||
```typescript
|
||||
// _lib/server/feature.service.ts
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import type { CreateFeatureInput } from '../schemas/feature.schema';
|
||||
|
||||
export function createFeatureService() {
|
||||
return new FeatureService();
|
||||
}
|
||||
|
||||
class FeatureService {
|
||||
async create(data: CreateFeatureInput) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: result, error } = await client
|
||||
.from('features')
|
||||
.insert({
|
||||
name: data.name,
|
||||
account_id: data.accountId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create Server Action
|
||||
|
||||
Create action in `_lib/server/server-actions.ts`:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { CreateFeatureSchema } from '../schemas/feature.schema';
|
||||
import { createFeatureService } from './feature.service';
|
||||
|
||||
export const createFeatureAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'create-feature', userId: user.id };
|
||||
|
||||
logger.info(ctx, 'Creating feature');
|
||||
|
||||
const service = createFeatureService();
|
||||
const result = await service.create(data);
|
||||
|
||||
logger.info({ ...ctx, featureId: result.id }, 'Feature created');
|
||||
|
||||
revalidatePath('/home/[account]/features');
|
||||
|
||||
return { success: true, data: result };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateFeatureSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Schema in separate file** - Reusable between client and server
|
||||
2. **Service layer** - Business logic isolated from action
|
||||
3. **Logging** - Always log before and after operations
|
||||
4. **Revalidation** - Use `revalidatePath` after mutations
|
||||
5. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
feature/
|
||||
├── _lib/
|
||||
│ ├── schemas/
|
||||
│ │ └── feature.schema.ts
|
||||
│ └── server/
|
||||
│ ├── feature.service.ts
|
||||
│ └── server-actions.ts
|
||||
└── _components/
|
||||
└── feature-form.tsx
|
||||
```
|
||||
|
||||
## Reference Files
|
||||
|
||||
See examples in:
|
||||
- `[Examples](examples.md)`
|
||||
- `[Reference](reference.md)`
|
||||
194
.claude/skills/server-action-builder/examples.md
Normal file
194
.claude/skills/server-action-builder/examples.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Server Action Examples
|
||||
|
||||
Real examples from the Makerkit codebase.
|
||||
|
||||
## Team Billing Action
|
||||
|
||||
Location: `apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { UpdateBillingSchema } from '../schemas/billing.schema';
|
||||
import { createBillingService } from './billing.service';
|
||||
|
||||
export const updateBillingAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'update-billing', userId: user.id, accountId: data.accountId };
|
||||
|
||||
logger.info(ctx, 'Updating billing settings');
|
||||
|
||||
const service = createBillingService();
|
||||
await service.updateBilling(data);
|
||||
|
||||
logger.info(ctx, 'Billing settings updated');
|
||||
|
||||
revalidatePath(`/home/${data.accountSlug}/billing`);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateBillingSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Personal Settings Action
|
||||
|
||||
Location: `apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { UpdateProfileSchema } from '../schemas/profile.schema';
|
||||
|
||||
export const updateProfileAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'update-profile', userId: user.id };
|
||||
|
||||
logger.info(ctx, 'Updating user profile');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client
|
||||
.from('accounts')
|
||||
.update({ name: data.name })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to update profile');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Profile updated successfully');
|
||||
|
||||
revalidatePath('/home/settings');
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateProfileSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Action with Redirect
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { CreateProjectSchema } from '../schemas/project.schema';
|
||||
import { createProjectService } from './project.service';
|
||||
|
||||
export const createProjectAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const service = createProjectService();
|
||||
const project = await service.create(data);
|
||||
|
||||
// Redirect after creation
|
||||
redirect(`/home/${data.accountSlug}/projects/${project.id}`);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Delete Action with Confirmation
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { DeleteItemSchema } from '../schemas/item.schema';
|
||||
|
||||
export const deleteItemAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'delete-item', userId: user.id, itemId: data.itemId };
|
||||
|
||||
logger.info(ctx, 'Deleting item');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client
|
||||
.from('items')
|
||||
.delete()
|
||||
.eq('id', data.itemId)
|
||||
.eq('account_id', data.accountId); // RLS will also validate
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to delete item');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Item deleted successfully');
|
||||
|
||||
revalidatePath(`/home/${data.accountSlug}/items`);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: DeleteItemSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling with isRedirectError
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const submitFormAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'submit-form', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Submitting form');
|
||||
|
||||
// Process form
|
||||
await processForm(data);
|
||||
|
||||
logger.info(ctx, 'Form submitted, redirecting');
|
||||
|
||||
redirect('/success');
|
||||
} catch (error) {
|
||||
// Don't treat redirects as errors
|
||||
if (!isRedirectError(error)) {
|
||||
logger.error({ ...ctx, error }, 'Form submission failed');
|
||||
throw error;
|
||||
}
|
||||
throw error; // Re-throw redirect
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: FormSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
179
.claude/skills/server-action-builder/reference.md
Normal file
179
.claude/skills/server-action-builder/reference.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Server Action Reference
|
||||
|
||||
## enhanceAction API
|
||||
|
||||
```typescript
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
export const myAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data: validated input (typed from schema)
|
||||
// user: authenticated user object (if auth: true)
|
||||
|
||||
return { success: true, data: result };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: ZodSchema, // Zod schema for validation (optional)
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `auth` | `boolean` | `false` | Require authenticated user |
|
||||
| `schema` | `ZodSchema` | - | Zod schema for input validation |
|
||||
|
||||
### Handler Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `z.infer<Schema>` | Validated input data |
|
||||
| `user` | `User` | Authenticated user (if auth: true) |
|
||||
|
||||
## enhanceRouteHandler API
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body: validated request body
|
||||
// user: authenticated user (if auth: true)
|
||||
// request: original NextRequest
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function ({ user, request }) {
|
||||
const url = new URL(request.url);
|
||||
const param = url.searchParams.get('param');
|
||||
|
||||
return NextResponse.json({ data: result });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Common Zod Patterns
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Basic schema
|
||||
export const CreateItemSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
// With transforms
|
||||
export const SearchSchema = z.object({
|
||||
query: z.string().trim().min(1),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(10),
|
||||
});
|
||||
|
||||
// With refinements
|
||||
export const DateRangeSchema = z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
}).refine(
|
||||
(data) => data.endDate > data.startDate,
|
||||
{ message: 'End date must be after start date' }
|
||||
);
|
||||
|
||||
// Enum values
|
||||
export const StatusSchema = z.object({
|
||||
status: z.enum(['active', 'inactive', 'pending']),
|
||||
});
|
||||
```
|
||||
|
||||
## Revalidation
|
||||
|
||||
```typescript
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
// Revalidate specific path
|
||||
revalidatePath('/home/[account]/items');
|
||||
|
||||
// Revalidate with dynamic segment
|
||||
revalidatePath(`/home/${accountSlug}/items`);
|
||||
|
||||
// Revalidate by tag
|
||||
revalidateTag('items');
|
||||
```
|
||||
|
||||
## Redirect
|
||||
|
||||
```typescript
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Redirect after action
|
||||
redirect('/success');
|
||||
|
||||
// Redirect with dynamic path
|
||||
redirect(`/home/${accountSlug}/items/${itemId}`);
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
const logger = await getLogger();
|
||||
|
||||
// Context object for all logs
|
||||
const ctx = {
|
||||
name: 'action-name',
|
||||
userId: user.id,
|
||||
accountId: data.accountId,
|
||||
};
|
||||
|
||||
// Log levels
|
||||
logger.info(ctx, 'Starting operation');
|
||||
logger.warn({ ...ctx, warning: 'details' }, 'Warning message');
|
||||
logger.error({ ...ctx, error }, 'Operation failed');
|
||||
```
|
||||
|
||||
## Supabase Clients
|
||||
|
||||
```typescript
|
||||
// Standard client (RLS enforced)
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Admin client (bypasses RLS - use sparingly)
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
try {
|
||||
await operation();
|
||||
redirect('/success');
|
||||
} catch (error) {
|
||||
if (!isRedirectError(error)) {
|
||||
// Handle actual error
|
||||
logger.error({ error }, 'Operation failed');
|
||||
throw error;
|
||||
}
|
||||
throw error; // Re-throw redirect
|
||||
}
|
||||
```
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
description: Personal Accounts context and functionality
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Personal Account Context
|
||||
|
||||
This rule provides guidance for working with personal account related components in the application.
|
||||
|
||||
The user/personal account context in the application lives under the path `app/home/(user)`. Under this context, we identify the user using Supabase Auth.
|
||||
|
||||
We can use the `requireUserInServerComponent` to retrieve the relative Supabase User object and identify the user. [require-user-in-server-component.ts](mdc:apps/web/lib/server/require-user-in-server-component.ts)
|
||||
|
||||
### Client Components
|
||||
|
||||
In a Client Component, we can access the `UserWorkspaceContext` and use the `user` object to identify the user.
|
||||
|
||||
We can use it like this:
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
```
|
||||
|
||||
This utility only works in paths under `apps/*/app/home/(user)`.
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Components and Structure
|
||||
- Personal account components are used in the `/home/(user)` route
|
||||
- Reusable components should be in `packages/features/accounts/src/components`
|
||||
- Settings-related components should be in `packages/features/accounts/src/components/personal-account-settings`
|
||||
|
||||
### State Management
|
||||
- Use the `UserWorkspaceContext` to access user workspace data
|
||||
- Personal account data can be fetched using `usePersonalAccountData` hook
|
||||
- Mutations should use React Query's `useMutation` hooks
|
||||
|
||||
### Authentication Flow
|
||||
- User authentication status is available via `useUser` hook
|
||||
- Account deletion requires OTP verification
|
||||
- Password updates may require reauthentication
|
||||
|
||||
### Feature Flags
|
||||
- Personal account features are controlled via `featureFlagsConfig` [feature-flags.config.ts](mdc:apps/web/config/feature-flags.config.ts)
|
||||
- Key flags:
|
||||
- `enableAccountDeletion`
|
||||
- `enablePasswordUpdate`
|
||||
- `enablePersonalAccountBilling`
|
||||
- `enableNotifications`
|
||||
|
||||
## Personal Account API
|
||||
|
||||
The API for the personal account is [api.ts](mdc:packages/features/accounts/src/server/api.ts)
|
||||
|
||||
A class that provides methods for interacting with account-related data in the database. Initializes a new instance of the `AccountsApi` class with a Supabase client.
|
||||
|
||||
### AccountsApi
|
||||
```typescript
|
||||
constructor(client: SupabaseClient<Database>)
|
||||
```
|
||||
|
||||
### Methods
|
||||
- `getAccount(id: string)` - Get account by ID
|
||||
- `getAccountWorkspace()` - Get current user's account workspace
|
||||
- `loadUserAccounts()` - Get all accounts for current user
|
||||
- `getSubscription(accountId: string)` - Get account subscription
|
||||
- `getOrder(accountId: string)` - Get account order
|
||||
- `getCustomerId(accountId: string)` - Get account customer ID
|
||||
|
||||
## Database
|
||||
|
||||
When applying Database rules [database.mdc](mdc:.cursor/rules/database.mdc) must ensure the authenticated user matches the account ID of the entity
|
||||
|
||||
```sql
|
||||
account_id = (select auth.uid())
|
||||
```
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
description: Fetch data from the Database using the Supabase Clients
|
||||
globs: apps/**,packages/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# Data Fetching
|
||||
|
||||
## General Data Flow
|
||||
- In a Server Component context, please use the Supabase Client directly for data fetching
|
||||
- In a Client Component context, please use the `useQuery` hook from the "@tanstack/react-query" package
|
||||
|
||||
Data Flow works in the following way:
|
||||
|
||||
1. Server Component uses the Supabase Client to fetch data.
|
||||
2. Data is rendered in Server Components or passed down to Client Components when absolutely necessary to use a client component (e.g. when using React Hooks or any interaction with the DOM).
|
||||
|
||||
```tsx
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function ServerComponent() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
// use data
|
||||
}
|
||||
```
|
||||
|
||||
or pass down the data to a Client Component:
|
||||
|
||||
```tsx
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export default function ServerComponent() {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const { data, error } = await supabase.from('notes').select('*');
|
||||
|
||||
if (error) {
|
||||
return <SomeErrorComponent error={error} />;
|
||||
}
|
||||
|
||||
return <SomeClientComponent data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Supabase Clients
|
||||
- In a Server Component context, use the `getSupabaseServerClient` function from the "@kit/supabase/server-client" package [server-client.ts](mdc:packages/supabase/src/clients/server-client.ts)
|
||||
- In a Client Component context, use the `useSupabase` hook from the "@kit/supabase/hooks/use-supabase" package.
|
||||
|
||||
### Admin Actions
|
||||
|
||||
Only in rare cases suggest using the Admin client `getSupabaseServerAdminClient` when needing to bypass RLS from the package `@kit/supabase/server-admin-client` [server-admin-client.ts](mdc:packages/supabase/src/clients/server-admin-client.ts)
|
||||
|
||||
## React Query
|
||||
|
||||
When using `useQuery`, make sure to define the data fetching hook. Create two components: one that fetches the data and one that displays the data. For example a good usage is [roles-data-provider.tsx](mdc:packages/features/team-accounts/src/components/members/roles-data-provider.tsx) as shown in [update-member-role-dialog.tsx](mdc:packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Logging using the `@kit/shared/logger` package [logger.ts](mdc:packages/shared/src/logger/logger.ts)
|
||||
- Don't swallow errors, always handle them appropriately
|
||||
- Handle promises and async/await gracefully
|
||||
- Consider the unhappy path and handle errors appropriately
|
||||
- Context without sensitive data
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
export async function myServerAction() {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info('Request started...');
|
||||
|
||||
try {
|
||||
// your code here
|
||||
await someAsyncFunction();
|
||||
|
||||
logger.info('Request succeeded...');
|
||||
} catch (error) {
|
||||
logger.error('Request failed...');
|
||||
|
||||
// handle error
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -1,294 +0,0 @@
|
||||
---
|
||||
description: Detailed Database Schema and Architecture
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Database Rules
|
||||
|
||||
## Database Architecture
|
||||
- Supabase uses Postgres
|
||||
- We strive to create a safe, robust, performant schema
|
||||
- Accounts are the general concept of a user account, defined by the having the same ID as Supabase Auth's users (personal). They can be a team account or a personal account.
|
||||
- Generally speaking, other tables will be used to store data related to the account. For example, a table `notes` would have a foreign key `account_id` to link it to an account.
|
||||
|
||||
## Schemas
|
||||
- The DB schemas are available at `apps/web/supabase/schemas`
|
||||
- To edit the DB schema, we can either change the schema files, or created new ones
|
||||
- To create a new schema, create a file at `apps/web/supabase/schemas/<number>-<name>.sql`
|
||||
|
||||
## Migrations
|
||||
- After creating a schema, we can create a migration
|
||||
- Use the command `pnpm --filter web supabase:db:diff` for creating migrations from schemas
|
||||
- After generating a migration, reset the database for applying the changes using the command `pnpm run supabase:web:reset`
|
||||
|
||||
## Security & RLS
|
||||
- Using RLS, we must ensure that only the account owner can access the data. Always write safe RLS policies and ensure that the policies are enforced.
|
||||
- Unless specified, always enable RLS when creating a table. Propose the required RLS policies ensuring the safety of the data.
|
||||
- Always consider any required constraints and triggers are in place for data consistency
|
||||
- Always consider the compromises you need to make and explain them so I can make an educated decision. Follow up with the considerations make and explain them.
|
||||
- Always consider the security of the data and explain the security implications of the data.
|
||||
- Always use Postgres schemas explicitly (e.g., `public.accounts`)
|
||||
- Consider the required compromises between simplicity, functionality and developer experience. However, never compromise on security, which is paramount and fundamental.
|
||||
- Use existing helper functions for access control instead of making your own queries, unless unavailable
|
||||
|
||||
## Schema Overview
|
||||
|
||||
Makerkit uses a Supabase Postgres database with a well-defined schema focused on multi-tenancy through the concepts of accounts (both personal and team) and robust permission systems.
|
||||
|
||||
### Database Schema
|
||||
|
||||
1. Enums [01-enums.sql](mdc:apps/web/supabase/schemas/01-enums.sql)
|
||||
2. Config [02-config.sql](mdc:apps/web/supabase/schemas/02-config.sql)
|
||||
3. Accounts [03-accounts.sql](mdc:apps/web/supabase/schemas/03-accounts.sql)
|
||||
4. Roles [04-roles.sql](mdc:apps/web/supabase/schemas/04-roles.sql)
|
||||
5. Memberships [05-memberships.sql](mdc:apps/web/supabase/schemas/05-memberships.sql)
|
||||
6. Roles Permissions [06-roles-permissions.sql](mdc:apps/web/supabase/schemas/06-roles-permissions.sql)
|
||||
7. Invitations [07-invitations.sql](mdc:apps/web/supabase/schemas/07-invitations.sql)
|
||||
8. Billing Customers [08-billing-customers.sql](mdc:apps/web/supabase/schemas/08-billing-customers.sql)
|
||||
9. Subscriptions [09-subscriptions.sql](mdc:apps/web/supabase/schemas/09-subscriptions.sql)
|
||||
10. Orders [10-orders.sql](mdc:apps/web/supabase/schemas/10-orders.sql)
|
||||
11. Notifications [11-notifications.sql](mdc:apps/web/supabase/schemas/11-notifications.sql)
|
||||
12. One Time Tokens [12-one-time-tokens.sql](mdc:apps/web/supabase/schemas/12-one-time-tokens.sql)
|
||||
13. Multi Factor Auth [13-mfa.sql](mdc:apps/web/supabase/schemas/13-mfa.sql)
|
||||
14. Super Admin [14-super-admin.sql](mdc:apps/web/supabase/schemas/14-super-admin.sql)
|
||||
15. Account Views [15-account-views.sql](mdc:apps/web/supabase/schemas/15-account-views.sql)
|
||||
16. Storage [16-storage.sql](mdc:apps/web/supabase/schemas/16-storage.sql)
|
||||
|
||||
## Database Best Practices
|
||||
|
||||
### Inferring Database types
|
||||
|
||||
Fetch auto-generated data types using the `@kit/supabase/database` import. Do not write types manually if the shape is the same as the one from the database row.
|
||||
|
||||
```tsx
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
// public.accounts
|
||||
type Account = Tables<'accounts'>;
|
||||
|
||||
// public.subscriptions
|
||||
type Subscription = Tables<'subscriptions'>;
|
||||
|
||||
// public.notifications
|
||||
type Notification = Tables<'notifications'>;
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- **Always enable RLS** on new tables unless explicitly instructed otherwise
|
||||
- **Create proper RLS policies** for all CRUD operations following existing patterns
|
||||
- **Always associate data with accounts** using a foreign key to ensure proper access control
|
||||
- **Use explicit schema references** (`public.table_name` not just `table_name`)
|
||||
- **Private schema**: Place internal functions in the `kit` schema
|
||||
- **Search Path**: Always set search path to '' when defining functions
|
||||
- **Security Definer**: Do not use `security definer` functions unless stricly required
|
||||
|
||||
### Data Access Patterns
|
||||
|
||||
- Use `public.has_role_on_account(account_id, role?)` to check membership
|
||||
- Use `public.has_permission(user_id, account_id, permission)` for permission checks
|
||||
- Use `public.is_account_owner(account_id)` to identify account ownership
|
||||
|
||||
### SQL Coding Style
|
||||
|
||||
- Use explicit transactions for multi-step operations
|
||||
- Follow existing naming conventions:
|
||||
- Tables: snake_case, plural nouns (`accounts`, `subscriptions`)
|
||||
- Functions: snake_case, verb phrases (`create_team_account`, `verify_nonce`)
|
||||
- Triggers: descriptive action names (`set_slug_from_account_name`)
|
||||
- Document functions and complex SQL with comments
|
||||
- Use parameterized queries to prevent SQL injection
|
||||
|
||||
### Common Patterns
|
||||
|
||||
- **Account Lookup**: Typically by `id` (UUID) or `slug` (for team accounts)
|
||||
- **Permission Check**: Always verify proper permissions before mutations
|
||||
- **Timestamp Automation**: Use the `trigger_set_timestamps()` function
|
||||
- **User Tracking**: Use the `trigger_set_user_tracking()` function
|
||||
- **Configuration**: Use `is_set(field_name)` to check enabled features
|
||||
|
||||
## Best Practices for Database Code
|
||||
|
||||
### 1. RLS Policy Management
|
||||
|
||||
#### Always Enable RLS
|
||||
|
||||
Always enable RLS for your tables unless you have a specific reason not to.
|
||||
```sql
|
||||
ALTER TABLE public.my_table ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
#### Use Helper Functions to validate permissions and access control
|
||||
Use the existing structure for policies:
|
||||
```sql
|
||||
-- SELECT policy
|
||||
CREATE POLICY "my_table_read" ON public.my_table FOR SELECT
|
||||
TO authenticated USING (
|
||||
account_id = (select auth.uid()) OR
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
-- INSERT/UPDATE/DELETE policies follow similar patterns
|
||||
```
|
||||
|
||||
When using RLS at team-account level, use `public.has_role_on_account(account_id)` for a generic check to understand if a user is part of a team.
|
||||
|
||||
When using RLS at user-account level, use `account_id = (select auth.uid())`.
|
||||
|
||||
When an entity can belong to both, use both.
|
||||
|
||||
When requiring a specific role, use the role parameter `public.has_role_on_account(account_id, 'owner')`
|
||||
|
||||
### 2. Account Association
|
||||
|
||||
- **Associate Data with Accounts**: Always link data to accounts using a foreign key:
|
||||
```sql
|
||||
CREATE TABLE if not exists public.my_data (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES public.accounts(id) ON DELETE CASCADE NOT NULL,
|
||||
/* other fields */
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Permission System
|
||||
|
||||
- **Use the Permission System**: Leverage the built-in permission system for access control:
|
||||
```sql
|
||||
-- Check if a user has a specific permission
|
||||
SELECT public.has_permission(
|
||||
auth.uid(),
|
||||
account_id,
|
||||
'my.permission'::public.app_permissions
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Schema Organization
|
||||
|
||||
- **Use Schemas Explicitly**: Always use schema prefixes explicitly:
|
||||
```sql
|
||||
-- Good
|
||||
SELECT * FROM public.accounts;
|
||||
|
||||
-- Avoid
|
||||
SELECT * FROM accounts;
|
||||
```
|
||||
|
||||
- **Put Internal Functions in 'kit' Schema**: Use the 'kit' schema for internal helper functions
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION kit.my_helper_function()
|
||||
RETURNS void AS $$
|
||||
-- function body
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
### 5. Types and Constraints
|
||||
|
||||
- **Use Enums for Constrained Values**: Create and use enum types for values with a fixed set:
|
||||
```sql
|
||||
CREATE TYPE public.my_status AS ENUM('active', 'inactive', 'pending');
|
||||
|
||||
CREATE TABLE if not exists public.my_table (
|
||||
status public.my_status NOT NULL DEFAULT 'pending'
|
||||
);
|
||||
```
|
||||
|
||||
- **Apply Appropriate Constraints**: Use constraints to ensure data integrity:
|
||||
```sql
|
||||
CREATE TABLE if not exists public.my_table (
|
||||
email VARCHAR(255) NOT NULL CHECK (email ~* '^.+@.+\..+$'),
|
||||
count INTEGER NOT NULL CHECK (count >= 0),
|
||||
/* other fields */
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Authentication and User Management
|
||||
|
||||
- **Use Supabase Auth**: Leverage auth.users for identity management
|
||||
- **Handle User Creation**: Use triggers like `kit.setup_new_user` to set up user data after registration
|
||||
|
||||
### 7. Function Security
|
||||
|
||||
- **Apply Security Definer Carefully**: For functions that need elevated privileges:
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.my_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = '' AS $$
|
||||
-- function body
|
||||
$$;
|
||||
```
|
||||
|
||||
- **Set Proper Function Permissions**:
|
||||
```sql
|
||||
GRANT EXECUTE ON FUNCTION public.my_function() TO authenticated, service_role;
|
||||
```
|
||||
|
||||
### 8. Error Handling and Validation
|
||||
|
||||
- **Use Custom Error Messages**: Return meaningful errors:
|
||||
```sql
|
||||
IF NOT validation_passed THEN
|
||||
RAISE EXCEPTION 'Validation failed: %', error_message;
|
||||
END IF;
|
||||
```
|
||||
|
||||
### 9. Triggers for Automation
|
||||
|
||||
- **Use Triggers for Derived Data**: Automate updates to derived fields:
|
||||
```sql
|
||||
CREATE TRIGGER update_timestamp
|
||||
BEFORE UPDATE ON public.my_table
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamps();
|
||||
```
|
||||
|
||||
### 10. View Structure for Commonly Used Queries
|
||||
|
||||
- **Create Views for Complex Joins**: As done with `user_account_workspace`
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW public.my_view
|
||||
WITH (security_invoker = true) AS
|
||||
SELECT ...
|
||||
```
|
||||
|
||||
You always must use `(security_invoker = true)` for views.
|
||||
|
||||
## Key Functions to Know
|
||||
|
||||
1. **Account Access**
|
||||
- `public.has_role_on_account(account_id, account_role)`
|
||||
- `public.is_account_owner(account_id)`
|
||||
- `public.is_team_member(account_id, user_id)`
|
||||
|
||||
2. **Permissions**
|
||||
- `public.has_permission(user_id, account_id, permission_name)`
|
||||
- `public.has_more_elevated_role(target_user_id, target_account_id, role_name)`
|
||||
|
||||
3. **Team Management**
|
||||
- `public.create_team_account(account_name)`
|
||||
|
||||
4. **Billing & Subscriptions**
|
||||
- `public.has_active_subscription(target_account_id)`
|
||||
|
||||
5. **One-Time Tokens**
|
||||
- `public.create_nonce(...)`
|
||||
- `public.verify_nonce(...)`
|
||||
- `public.revoke_nonce(...)`
|
||||
|
||||
6. **Super Admins**
|
||||
- `public.is_super_admin()`
|
||||
|
||||
7. **MFA**:
|
||||
- `public.is_aal2()`
|
||||
- `public.is_mfa_compliant()`
|
||||
|
||||
## Configuration Control
|
||||
|
||||
- **Use the `config` Table**: The application has a central configuration table
|
||||
- **Check Features with `public.is_set(field_name)`**:
|
||||
```sql
|
||||
-- Check if team accounts are enabled
|
||||
SELECT public.is_set('enable_team_accounts');
|
||||
```
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
description: Writing Forms with Shadcn UI, Server Actions, Zod
|
||||
globs: apps/**/*.tsx,packages/**/*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
- Use React Hook Form for form validation and submission.
|
||||
- Use Zod for form validation.
|
||||
- Use the `zodResolver` function to resolve the Zod schema to the form.
|
||||
- Use Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) for server-side code handling
|
||||
- Use Sonner for writing toasters for UI feedback
|
||||
- Never add generics to `useForm`, use Zod resolver to infer types instead
|
||||
|
||||
Follow the example below to create all forms:
|
||||
|
||||
## Define the schema
|
||||
|
||||
Zod schemas should be defined in the `schema` folder and exported, so we can reuse them across a Server Action and the client-side form:
|
||||
|
||||
```tsx
|
||||
// _lib/schema/create-note.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
```
|
||||
|
||||
## Create the Server Action
|
||||
|
||||
Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) can help us create endpoints for our forms.
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
import { CreateNoteSchema } from '../schema/create-note.schema';
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// 1. "data" has been validated against the Zod schema, and it's safe to use
|
||||
// 2. "user" is the authenticated user
|
||||
|
||||
// ... your code here
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Create the Form Component
|
||||
|
||||
Then create a client component to handle the form submission:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CreateNoteSchema } from '../_lib/schema/create-note.schema';
|
||||
|
||||
export function CreateNoteForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
await toast.promise(createNoteAction(data), {
|
||||
loading: t('notes:creatingNote`),
|
||||
success: t('notes:createNoteSuccess`),
|
||||
error: t('notes:createNoteError`)
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Form {...form}>
|
||||
<FormField name={'title'} render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<span className={'text-sm font-medium'}>Title</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type={'text'}
|
||||
className={'w-full'}
|
||||
placeholder={'Title'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name={'content'} render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<span className={'text-sm font-medium'}>Content</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className={'w-full'}
|
||||
placeholder={'Content'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<button disabled={pending} type={'submit'} className={'w-full'}>
|
||||
Submit
|
||||
</button>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Always use `@kit/ui` for writing the UI of the form.
|
||||
@@ -1,206 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: *.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
# JSX Best Practices
|
||||
|
||||
This guide outlines our conventions for writing clean, maintainable JSX in React applications.
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### Class Name Management
|
||||
|
||||
When merging complex classes, always use the `cn` utility from `clsx`/`tailwind-merge`:
|
||||
|
||||
```tsx
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
// Simple usage
|
||||
<button className={cn('btn', className)}>Submit</button>
|
||||
|
||||
// Conditional classes
|
||||
<div className={cn('base-class', {
|
||||
'text-lg': isLarge,
|
||||
'bg-primary': isPrimary,
|
||||
'opacity-50': isDisabled
|
||||
})}>
|
||||
Content
|
||||
</div>
|
||||
|
||||
// Array syntax for dynamic classes
|
||||
<span className={cn([
|
||||
'badge',
|
||||
variant === 'success' && 'badge-success',
|
||||
variant === 'error' && 'badge-error'
|
||||
])}>
|
||||
{label}
|
||||
</span>
|
||||
```
|
||||
|
||||
Why use `cn`:
|
||||
- Handles merging tailwind classes correctly
|
||||
- Automatically removes duplicate classes
|
||||
- Resolves conflicting classes by keeping the last one
|
||||
- Provides type-safety with TypeScript
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Conditional Rendering with `If`
|
||||
|
||||
Prefer the `If` component to complex ternary operators in JSX:
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
// Basic usage
|
||||
<If condition={isLoading}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With fallback
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With callback function for condition match
|
||||
<If condition={user}>
|
||||
{(userData) => <UserProfile data={userData} />}
|
||||
</If>
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Improves readability compared to ternary operators
|
||||
- Type-safe with TypeScript
|
||||
- Reduces nesting and complexity in JSX
|
||||
|
||||
### List Rendering
|
||||
|
||||
Consistently use these patterns for list rendering:
|
||||
|
||||
```tsx
|
||||
// Empty state handling, avoid ternaries
|
||||
{items.length > 0 ? (
|
||||
<ul className="list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message="No items found" />
|
||||
)}
|
||||
|
||||
// Even better with If component
|
||||
<If condition={items.length > 0} fallback={
|
||||
<EmptyState message="No items found" />
|
||||
}>
|
||||
<ul className="list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</If>
|
||||
```
|
||||
|
||||
### Using Translations
|
||||
|
||||
All user-facing text should use the `Trans` component unless specified otherwise:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
// Basic usage
|
||||
<Trans i18nKey="common:welcomeMessage" defaults="Welcome!" />
|
||||
|
||||
// With variables
|
||||
<Trans
|
||||
i18nKey="user:lastLogin"
|
||||
values={{ date: formatDate(lastLogin) }}
|
||||
defaults="Last login: {date}"
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
PrivacyLink: <a href="/privacy" className="underline" />
|
||||
}}
|
||||
defaults="I agree to the <TermsLink>Terms</TermsLink> and <PrivacyLink>Privacy Policy</PrivacyLink>."
|
||||
/>
|
||||
|
||||
// Pluralization
|
||||
<Trans
|
||||
i18nKey="notifications:count"
|
||||
count={notifications.length}
|
||||
defaults="{count, plural, =0 {No notifications} one {# notification} other {# notifications}}"
|
||||
/>
|
||||
```
|
||||
|
||||
Important rules:
|
||||
- Always provide a `defaults` prop with the English text as fallback
|
||||
- Ensure the key exists in the appropriate translation file
|
||||
- Keep HTML elements minimal in translations
|
||||
|
||||
## Error and Loading States
|
||||
|
||||
Use consistent patterns for handling loading and error states:
|
||||
|
||||
```tsx
|
||||
// Loading state
|
||||
<If condition={isLoading}>
|
||||
<div className="flex justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
</If>
|
||||
|
||||
// Error state that infer the type of the condition. The type of the variable "err" is now inferred
|
||||
// Always use this pattern when the value of the condition is used within the body
|
||||
<If condition={error}>
|
||||
{(err) => (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="common:errorTitle" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
{err.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</If>
|
||||
|
||||
// Empty state
|
||||
<If condition={items.length === 0}>
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<EmptyIcon className="h-12 w-12 text-muted-foreground" />
|
||||
|
||||
<h3 className="mt-4 text-lg font-medium">
|
||||
<Trans i18nKey="common:noData" />
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans i18nKey="common:noDataDescription" />
|
||||
</p>
|
||||
</div>
|
||||
</If>
|
||||
```
|
||||
|
||||
## Testing Attributes
|
||||
|
||||
Add consistent data attributes for testing:
|
||||
|
||||
```tsx
|
||||
<button data-test="submit-button">
|
||||
Submit
|
||||
</button>
|
||||
|
||||
<div data-test="user-profile" data-user-id={user.id}>
|
||||
{/* User profile content */}
|
||||
</div>
|
||||
|
||||
<form data-test="signup-form">
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
```
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
description: Server side functions logging
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
## Logging
|
||||
|
||||
Consider logging asynchronous requests using the `@kit/shared/logger` [logger.ts](mdc:packages/shared/src/logger/logger.ts) package in a structured way to provide context to the logs in both server actions and route handlers.
|
||||
|
||||
The logger uses the following interface:
|
||||
|
||||
```tsx
|
||||
type LogFn = {
|
||||
<T extends object>(obj: T, msg?: string, ...args: unknown[]): void;
|
||||
(obj: unknown, msg?: string, ...args: unknown[]): void;
|
||||
(msg: string, ...args: unknown[]): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @name Logger
|
||||
* @description Logger interface for logging messages
|
||||
*/
|
||||
export interface Logger {
|
||||
info: LogFn;
|
||||
error: LogFn;
|
||||
warn: LogFn;
|
||||
debug: LogFn;
|
||||
fatal: LogFn;
|
||||
}
|
||||
```
|
||||
|
||||
Using the logger:
|
||||
|
||||
```tsx
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function fetchNotes() {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'notes', // use a meaningful name
|
||||
userId: user.id, // use the authenticated user's ID
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Request started...');
|
||||
|
||||
const { data, error } = await supabase.from('notes').select('*');
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Request failed...');
|
||||
// handle error
|
||||
} else {
|
||||
logger.info(ctx, 'Request succeeded...');
|
||||
// use data
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
description: The OTP API provides the ability to perform additional checks before executing sensitive operations
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
The OTP API allows the user to:
|
||||
|
||||
1. protect sensitive operations behind an additional layer of verification
|
||||
2. other security operations such as oAuth2 (storing the "state" parameter with additional metadata)
|
||||
|
||||
- API: The OTP API [index.ts](mdc:packages/otp/src/api/index.ts) abstract operations with the Database RPCs.
|
||||
- The Database schema can be found at [12-one-time-tokens.sql](mdc:apps/web/supabase/schemas/12-one-time-tokens.sql)
|
||||
|
||||
## Creating an OTP Token
|
||||
We can se the [verify-otp-form.tsx](mdc:packages/otp/src/components/verify-otp-form.tsx) for creating a quick form to create tokens server side.
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
function MyVerificationPage(props: {
|
||||
userEmail: string;
|
||||
}) {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
purpose="password-reset"
|
||||
email={props.userEmail}
|
||||
onSuccess={(otp) => {
|
||||
// Handle successful verification
|
||||
// Use the OTP for verification on the server
|
||||
}}
|
||||
CancelButton={
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Verifying a Token
|
||||
And here is the server action that verifies the OTP:
|
||||
|
||||
```tsx
|
||||
// Verify the token
|
||||
const result = await api.verifyToken({
|
||||
token: submittedToken,
|
||||
purpose: 'email-verification'
|
||||
});
|
||||
|
||||
if (result.valid) {
|
||||
// Token is valid, proceed with the operation
|
||||
const { userId, metadata } = result;
|
||||
// Handle successful verification
|
||||
} else {
|
||||
// Token is invalid or expired
|
||||
// Handle verification failure
|
||||
}
|
||||
```
|
||||
@@ -1,321 +0,0 @@
|
||||
---
|
||||
description: Creating new Pages in the app
|
||||
globs: apps/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# Creating Pages
|
||||
|
||||
# Makerkit Page & Layout Guidelines
|
||||
|
||||
## Page Structure Overview
|
||||
|
||||
Makerkit uses Next.js App Router architecture with a clear separation of concerns for layouts and pages. The application's structure reflects the multi-tenant approach with specific routing patterns:
|
||||
|
||||
```
|
||||
- app
|
||||
- home # protected routes
|
||||
- (user) # user workspace (personal account context)
|
||||
- [account] # team workspace (team account context)
|
||||
- (marketing) # marketing pages
|
||||
- auth # auth pages
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### Layouts
|
||||
|
||||
Layouts in Makerkit provide the structure for various parts of the application:
|
||||
|
||||
1. **Root Layout**: The base structure for the entire application
|
||||
2. **Workspace Layouts**:
|
||||
- User Workspace Layout (`app/home/(user)/layout.tsx`): For personal account context
|
||||
- Team Workspace Layout (`app/home/[account]/layout.tsx`): For team account context
|
||||
|
||||
Layouts handle:
|
||||
- Workspace context providers
|
||||
- Navigation components
|
||||
- Authentication requirements
|
||||
- UI structure (sidebar vs header style)
|
||||
|
||||
### Pages
|
||||
|
||||
Pages represent the actual content for each route and follow a consistent pattern:
|
||||
|
||||
1. **Metadata Generation**: Using `generateMetadata()` for SEO and page titles
|
||||
2. **Content Structure**:
|
||||
- Page headers with titles and descriptions
|
||||
- Page body containing the main content
|
||||
3. **i18n Implementation**: Wrapped with `withI18n` HOC
|
||||
|
||||
## Creating a New Page
|
||||
|
||||
### 1. Define the Page Structure
|
||||
|
||||
Create a new file within the appropriate route folder:
|
||||
|
||||
```tsx
|
||||
// app/home/(user)/my-feature/page.tsx
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
// Import components from the _components folder if needed
|
||||
import { MyFeatureHeader } from './_components/my-feature-header';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('account:myFeaturePage');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
function MyFeaturePage() {
|
||||
return (
|
||||
<>
|
||||
<MyFeatureHeader
|
||||
title={<Trans i18nKey={'common:routes.myFeature'} />}
|
||||
description={<Trans i18nKey={'common:myFeatureDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
{/* Main page content */}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(MyFeaturePage);
|
||||
```
|
||||
|
||||
- Authentication is enforced already in the middleware
|
||||
- Authorization is normally enforced by RLS at the database level
|
||||
- In the rare case you use the Supabase Admin client, you must enforce both authentication and authorization manually
|
||||
|
||||
### 2. Create a Loading State
|
||||
|
||||
```tsx
|
||||
// app/home/(user)/my-feature/loading.tsx
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
```
|
||||
|
||||
### 3. Create a Layout (if needed)
|
||||
|
||||
If the feature requires a specific layout, create a layout file:
|
||||
|
||||
```tsx
|
||||
// app/home/(user)/my-feature/layout.tsx
|
||||
import { use } from 'react';
|
||||
|
||||
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
||||
import { Page, PageNavigation } from '@kit/ui/page';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
|
||||
// Import components from the _components folder
|
||||
import { MyFeatureNavigation } from './_components/my-feature-navigation';
|
||||
|
||||
function MyFeatureLayout({ children }: React.PropsWithChildren) {
|
||||
const workspace = use(loadUserWorkspace());
|
||||
|
||||
return (
|
||||
<UserWorkspaceContextProvider value={workspace}>
|
||||
<Page>
|
||||
<PageNavigation>
|
||||
<MyFeatureNavigation workspace={workspace} />
|
||||
</PageNavigation>
|
||||
|
||||
{children}
|
||||
</Page>
|
||||
</UserWorkspaceContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(MyFeatureLayout);
|
||||
```
|
||||
|
||||
## Layout Patterns
|
||||
|
||||
### 1. User Workspace Layout
|
||||
|
||||
For pages in the personal account context, use the user workspace layout pattern:
|
||||
|
||||
```tsx
|
||||
import { use } from 'react';
|
||||
|
||||
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
||||
import { Page } from '@kit/ui/page';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
import { loadUserWorkspace } from './_lib/server/load-user-workspace';
|
||||
|
||||
function MyLayout({ children }: React.PropsWithChildren) {
|
||||
const workspace = use(loadUserWorkspace());
|
||||
|
||||
return (
|
||||
<UserWorkspaceContextProvider value={workspace}>
|
||||
<Page>
|
||||
{/* Navigation components */}
|
||||
{children}
|
||||
</Page>
|
||||
</UserWorkspaceContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(MyLayout);
|
||||
```
|
||||
|
||||
### 2. Team Workspace Layout
|
||||
|
||||
For pages in the team account context, use the team workspace layout pattern:
|
||||
|
||||
```tsx
|
||||
import { use } from 'react';
|
||||
|
||||
import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components';
|
||||
import { Page } from '@kit/ui/page';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
import { loadTeamWorkspace } from './_lib/server/load-team-workspace';
|
||||
|
||||
function TeamLayout({ children, params }: LayoutParams) {
|
||||
const workspace = use(loadTeamWorkspace(params.account));
|
||||
|
||||
return (
|
||||
<TeamAccountWorkspaceContextProvider value={workspace}>
|
||||
<Page>
|
||||
{/* Navigation components */}
|
||||
{children}
|
||||
</Page>
|
||||
</TeamAccountWorkspaceContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(TeamLayout);
|
||||
```
|
||||
|
||||
## UI Components Structure
|
||||
|
||||
### Page Components
|
||||
|
||||
Break down pages into reusable components:
|
||||
|
||||
1. **Page Headers**: Create header components for consistent titling:
|
||||
```tsx
|
||||
// _components/my-feature-header.tsx
|
||||
import { PageHeader } from '@kit/ui/page-header';
|
||||
|
||||
export function MyFeatureHeader({
|
||||
title,
|
||||
description
|
||||
}: {
|
||||
title: React.ReactNode,
|
||||
description: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Feature Components**: Create components for feature-specific functionality:
|
||||
```tsx
|
||||
// _components/my-feature-component.tsx
|
||||
'use client';
|
||||
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
export function MyFeatureComponent() {
|
||||
const { user } = useUserWorkspace();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Component content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Components
|
||||
|
||||
Create navigation components to handle sidebar or header navigation:
|
||||
|
||||
```tsx
|
||||
// _components/my-feature-navigation.tsx
|
||||
'use client';
|
||||
|
||||
import { NavigationMenu } from '@kit/ui/navigation-menu';
|
||||
|
||||
export function MyFeatureNavigation({
|
||||
workspace
|
||||
}: {
|
||||
workspace: UserWorkspace
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenu>
|
||||
{/* Navigation items */}
|
||||
</NavigationMenu>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Styles
|
||||
|
||||
Makerkit supports different layout styles that can be toggled by the user:
|
||||
|
||||
1. **Sidebar Layout**: A vertical sidebar navigation
|
||||
2. **Header Layout**: A horizontal header navigation
|
||||
|
||||
The layout style is stored in cookies and can be accessed server-side:
|
||||
|
||||
```tsx
|
||||
async function getLayoutState() {
|
||||
const cookieStore = await cookies();
|
||||
const layoutStyleCookie = cookieStore.get('layout-style');
|
||||
|
||||
return {
|
||||
style: layoutStyleCookie?.value ?? defaultStyle,
|
||||
// Other layout state properties
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Server vs. Client Components**:
|
||||
- Use Server Components for data fetching and initial rendering
|
||||
- Use Client Components ('use client') for interactive elements
|
||||
|
||||
2. **Data Loading**:
|
||||
- Load workspace data in layouts using server functions
|
||||
- Pass data down to components that need it
|
||||
- Use React Query for client-side data fetching
|
||||
|
||||
3. **Component Organization**:
|
||||
- Place feature-specific components in a `_components` folder
|
||||
- Place feature-specific server utilities in a `_lib/server` folder
|
||||
- Place feature-specific client utilities in a `_lib/client` folder
|
||||
|
||||
4. **i18n Support**:
|
||||
- Always use `withI18n` HOC for pages and layouts
|
||||
- Use `<Trans>` component for translated text
|
||||
- Define translation keys in the appropriate namespace in `apps/web/public/locales/<locale>/<namespace>.json`
|
||||
|
||||
5. **Metadata**:
|
||||
- Always include `generateMetadata` for SEO and UX
|
||||
- Use translations for page titles and descriptions
|
||||
|
||||
6. **Loading States**:
|
||||
- Always provide a loading state for each route
|
||||
- Use the `GlobalLoader` or custom loading components
|
||||
|
||||
7. **Error Handling**:
|
||||
- Implement error.tsx files for route error boundaries
|
||||
- Handle data fetching errors gracefully
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
description: Permissions
|
||||
globs: apps/**,packages/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# Access Control & Permissions Guidelines
|
||||
|
||||
This rule provides guidance for implementing access control, permissions, and subscription-related functionality in the application.
|
||||
|
||||
## Role-Based Access Control
|
||||
|
||||
### Account Roles
|
||||
- Roles are defined in the `roles` table with hierarchy levels (lower number = higher privilege)
|
||||
- Default roles include `owner` (hierarchy_level=1) and `member` with specific permissions
|
||||
- Primary account owner has special privileges that cannot be revoked
|
||||
- Role hierarchy controls what actions users can perform on other members
|
||||
|
||||
### Role Permissions
|
||||
- Permissions are stored in `role_permissions` table mapping roles to specific permissions
|
||||
- Core permissions:
|
||||
- `roles.manage`: Manage roles of users with lower hierarchy
|
||||
- `billing.manage`: Access/update billing information
|
||||
- `settings.manage`: Update account settings
|
||||
- `members.manage`: Add/remove members
|
||||
- `invites.manage`: Create/update/delete invitations
|
||||
|
||||
### Permission Checking
|
||||
- Use `has_permission(user_id, account_id, permission_name)` to check specific permissions
|
||||
- Use `can_action_account_member(target_team_account_id, target_user_id)` to verify if a user can act on another
|
||||
- Use `is_account_owner(account_id)` to check if user is primary owner
|
||||
- Primary owners can perform any action regardless of explicit permissions
|
||||
|
||||
## Team Account Access
|
||||
|
||||
### Team Membership
|
||||
- Use `has_role_on_account(account_id, account_role)` to check if user is a member with specific role
|
||||
- Use `is_team_member(account_id, user_id)` to check if a specific user is a member
|
||||
- Use the authenticated user's `TeamAccountWorkspaceContext` to access current permissions array
|
||||
|
||||
### Invitations
|
||||
- Only users with `invites.manage` permission can create/manage invitations
|
||||
- Users can only invite others with the same or lower role hierarchy than they have
|
||||
- Invitations have expiry dates (default: 7 days)
|
||||
- Accept invitations using `accept_invitation` function with token
|
||||
|
||||
## Subscription Access
|
||||
|
||||
### Subscription Status Checking
|
||||
- Check active subscriptions with `has_active_subscription(account_id)`
|
||||
- Active status includes both `active` and `trialing` subscriptions
|
||||
- Guard premium features with subscription checks in both frontend and backend
|
||||
|
||||
### Billing Access
|
||||
- Only users with `billing.manage` permission can access billing functions
|
||||
- All billing operations should be guarded with permission checks
|
||||
- Per-seat billing automatically updates when members are added/removed
|
||||
|
||||
## Row Level Security
|
||||
|
||||
### Table RLS
|
||||
- Most tables have RLS policies restricting access based on team membership
|
||||
- Personal account data is only accessible by the account owner
|
||||
- Team account data is accessible by all team members based on their roles
|
||||
|
||||
### Actions on Members
|
||||
- Higher roles can update/remove lower roles but not equal or higher roles
|
||||
- Primary owner cannot be removed from their account
|
||||
- Ownership transfer requires OTP verification and is limited to primary owners
|
||||
@@ -1,236 +0,0 @@
|
||||
---
|
||||
description: Detailed Project Structure of the app
|
||||
globs: apps/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# Project Structure
|
||||
|
||||
```
|
||||
apps/web/app/ # Root directory (apps/web/app)
|
||||
│
|
||||
├── (marketing)/ # Marketing pages group
|
||||
│ ├── _components/ # Shared components for marketing routes
|
||||
│ │ ├── site-footer.tsx
|
||||
│ │ ├── site-header.tsx
|
||||
│ │ ├── site-navigation.tsx
|
||||
│ │ └── site-page-header.tsx
|
||||
│ │
|
||||
│ ├── (legal)/ # Legal pages subgroup
|
||||
│ │ ├── cookie-policy/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── privacy-policy/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── terms-of-service/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── blog/ # Blog section
|
||||
│ │ ├── _components/ # Blog-specific components
|
||||
│ │ │ ├── blog-pagination.tsx
|
||||
│ │ │ ├── post-header.tsx
|
||||
│ │ │ └── post-preview.tsx
|
||||
│ │ ├── [slug]/ # Dynamic route for blog posts
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── page.tsx # Blog listing page
|
||||
│ │
|
||||
│ ├── contact/ # Contact page
|
||||
│ │ ├── _components/
|
||||
│ │ │ └── contact-form.tsx
|
||||
│ │ ├── _lib/ # Contact page utilities
|
||||
│ │ │ ├── contact-email.schema.ts
|
||||
│ │ │ └── server/
|
||||
│ │ │ └── server-actions.ts
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── docs/ # Documentation pages
|
||||
│ │ ├── _components/
|
||||
│ │ ├── _lib/
|
||||
│ │ │ ├── server/
|
||||
│ │ │ │ └── docs.loader.ts
|
||||
│ │ │ └── utils.ts
|
||||
│ │ ├── [slug]/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── layout.tsx # Layout specific to docs section
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── faq/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── pricing/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── layout.tsx # Layout for all marketing pages
|
||||
│ ├── loading.tsx # Loading state for marketing pages
|
||||
│ └── page.tsx # Home/landing page
|
||||
│
|
||||
├── (auth)/ # Authentication pages group
|
||||
│ ├── callback/ # Auth callback routes
|
||||
│ │ ├── error/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── route.ts # API route handler for auth callback
|
||||
│ │
|
||||
│ ├── confirm/
|
||||
│ │ └── route.ts
|
||||
│ │
|
||||
│ ├── password-reset/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── sign-in/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── sign-up/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── verify/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── layout.tsx # Layout for auth pages
|
||||
│ └── loading.tsx # Loading state for auth pages
|
||||
│
|
||||
├── admin/ # Admin section
|
||||
│ ├── _components/
|
||||
│ │ ├── admin-sidebar.tsx
|
||||
│ │ └── mobile-navigation.tsx
|
||||
│ │
|
||||
│ ├── accounts/
|
||||
│ │ ├── [id]/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── layout.tsx
|
||||
│ ├── loading.tsx
|
||||
│ └── page.tsx
|
||||
│
|
||||
├── api/ # API routes
|
||||
│ ├── billing/
|
||||
│ │ └── webhook/
|
||||
│ │ └── route.ts
|
||||
│ │
|
||||
│ └── db/
|
||||
│ └── webhook/
|
||||
│ └── route.ts
|
||||
│
|
||||
├── home/ # User dashboard area
|
||||
│ ├── (user)/ # Personal user routes
|
||||
│ │ ├── _components/ # User dashboard components
|
||||
│ │ │ ├── home-account-selector.tsx
|
||||
│ │ │ └── home-sidebar.tsx
|
||||
│ │ │
|
||||
│ │ ├── _lib/ # User dashboard utilities
|
||||
│ │ │ └── server/
|
||||
│ │ │ └── load-user-workspace.ts
|
||||
│ │ │
|
||||
│ │ ├── billing/ # Personal account billing
|
||||
│ │ │ ├── _components/
|
||||
│ │ │ ├── _lib/
|
||||
│ │ │ │ ├── schema/
|
||||
│ │ │ │ │ └── personal-account-checkout.schema.ts
|
||||
│ │ │ │ └── server/
|
||||
│ │ │ │ ├── personal-account-billing-page.loader.ts
|
||||
│ │ │ │ ├── server-actions.ts
|
||||
│ │ │ │ └── user-billing.service.ts
|
||||
│ │ │ │
|
||||
│ │ │ ├── error.tsx
|
||||
│ │ │ ├── layout.tsx
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── return/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ │
|
||||
│ │ ├── settings/
|
||||
│ │ │ ├── layout.tsx
|
||||
│ │ │ └── page.tsx
|
||||
│ │ │
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── loading.tsx
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── [account]/ # Team account routes (dynamic)
|
||||
│ │ ├── _components/ # Team account components
|
||||
│ │ │ ├── dashboard-demo.tsx
|
||||
│ │ │ ├── team-account-accounts-selector.tsx
|
||||
│ │ │ └── team-account-layout-sidebar.tsx
|
||||
│ │ │
|
||||
│ │ ├── _lib/ # Team account utilities
|
||||
│ │ │ └── server/
|
||||
│ │ │ ├── team-account-billing-page.loader.ts
|
||||
│ │ │ └── team-account-workspace.loader.ts
|
||||
│ │ │
|
||||
│ │ ├── billing/ # Team billing section
|
||||
│ │ │ ├── _components/
|
||||
│ │ │ ├── _lib/
|
||||
│ │ │ │ ├── schema/
|
||||
│ │ │ │ │ └── team-billing.schema.ts
|
||||
│ │ │ │ └── server/
|
||||
│ │ │ │ ├── server-actions.ts
|
||||
│ │ │ │ └── team-billing.service.ts
|
||||
│ │ │ │
|
||||
│ │ │ ├── error.tsx
|
||||
│ │ │ ├── layout.tsx
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── return/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ │
|
||||
│ │ ├── members/ # Team members management
|
||||
│ │ │ ├── _lib/
|
||||
│ │ │ │ └── server/
|
||||
│ │ │ │ └── members-page.loader.ts
|
||||
│ │ │ └── page.tsx
|
||||
│ │ │
|
||||
│ │ ├── settings/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ │
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── loading.tsx
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ └── loading.tsx
|
||||
│
|
||||
├── join/ # Team join page
|
||||
│ └── page.tsx
|
||||
│
|
||||
├── update-password/
|
||||
│ └── page.tsx
|
||||
│
|
||||
├── error.tsx # Global error page
|
||||
├── global-error.tsx # Global error component
|
||||
├── layout.tsx # Root layout
|
||||
├── not-found.tsx # 404 page
|
||||
├── robots.ts # Robots.txt config
|
||||
├── sitemap.xml/ # Sitemap generation
|
||||
│ └── route.ts
|
||||
└── version/ # Version info endpoint
|
||||
└── route.ts
|
||||
```
|
||||
|
||||
## Key Organization Patterns
|
||||
|
||||
1. **Route Groups**
|
||||
- `(marketing)` - Groups all marketing/public pages
|
||||
- `(auth)` - Groups all authentication related pages
|
||||
- `(user)` - Groups all personal user dashboard pages
|
||||
|
||||
2. **Component Organization**
|
||||
- `_components/` - Route-specific components
|
||||
- Global components are in the root `/components` directory (not shown)
|
||||
|
||||
3. **Utilities & Data**
|
||||
- `_lib/` - Route-specific utilities, types, and helpers
|
||||
- `_lib/server/` - Server-side utilities including data loaders
|
||||
- `/lib/` - Global utilities (not shown)
|
||||
|
||||
4. **Data Fetching**
|
||||
- Use of React's `cache()` function for request deduplication
|
||||
|
||||
5. **Server Actions**
|
||||
- `server-actions.ts` - Server-side actions for mutating data
|
||||
- Follows 'use server' directive pattern
|
||||
|
||||
6. **Special Files**
|
||||
- `layout.tsx` - Define layouts for routes
|
||||
- `loading.tsx` - Loading UI for routes
|
||||
- `error.tsx` - Error handling for routes
|
||||
- `page.tsx` - Page component for routes
|
||||
- `route.ts` - API route handlers
|
||||
|
||||
7. **Dynamic Routes**
|
||||
- `[account]` - Dynamic route for team accounts. The [account] property is the account slug in the table `public.accounts`.
|
||||
- `[slug]` - Dynamic route for blog posts and documentation
|
||||
@@ -1,224 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: *.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
# React
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Component-Driven Development**: Build applications as a composition of isolated, reusable components
|
||||
- **One-Way Data Flow**: Follow React's unidirectional data flow pattern
|
||||
- **Single Responsibility**: Each component should have a clear, singular purpose
|
||||
- **TypeScript First**: Use TypeScript for type safety and better developer experience
|
||||
- **Internationalization (i18n) By Default**: All user-facing text should be translatable
|
||||
|
||||
## React Components
|
||||
|
||||
### Component Structure
|
||||
|
||||
- Always use functional components with TypeScript
|
||||
- Name components using PascalCase (e.g., `UserProfile`)
|
||||
- Use named exports for components, not default exports
|
||||
- Split components by responsibility and avoid "god components"
|
||||
- Name files to match their component name (e.g., `user-profile.tsx`)
|
||||
|
||||
### Props
|
||||
|
||||
- Always type props using TypeScript interfaces or type aliases
|
||||
- Use discriminated unions for complex prop types with conditional rendering
|
||||
- Destructure props at the start of component functions
|
||||
- Use prop spreading cautiously and only when appropriate
|
||||
- Provide default props for optional parameters when it makes sense
|
||||
|
||||
```typescript
|
||||
type ButtonProps = {
|
||||
variant: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function Button({
|
||||
variant,
|
||||
size = 'md',
|
||||
children,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: ButtonProps) {
|
||||
// Component implementation
|
||||
}
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
- Keep state as local as possible
|
||||
- Lift state up when multiple components need access
|
||||
- Use Context sparingly and only for truly global state
|
||||
- Prefer the "Container/Presenter" pattern when separating data and UI
|
||||
|
||||
```typescript
|
||||
// Container component (manages data)
|
||||
function UserProfileContainer() {
|
||||
const userData = useUserData();
|
||||
|
||||
if (userData.isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (userData.error) {
|
||||
return <ErrorMessage error={userData.error} />;
|
||||
}
|
||||
|
||||
return <UserProfilePresenter data={userData.data} />;
|
||||
}
|
||||
|
||||
// Presenter component (renders UI)
|
||||
function UserProfilePresenter({ data }: { data: UserData }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.name}</h1>
|
||||
{/* Rest of the UI */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
- Follow the Rules of Hooks (only call hooks at the top level, only call them from React functions)
|
||||
- Create custom hooks for reusable logic
|
||||
- Keep custom hooks focused on a single concern
|
||||
- Name custom hooks with a 'use' prefix (e.g., `useUserProfile`)
|
||||
- Extract complex effect logic into separate functions
|
||||
- Always provide a complete dependencies array to `useEffect`
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
- Apply `useMemo` for expensive calculations
|
||||
- Use `useCallback` for functions passed as props to child components
|
||||
- Split code using dynamic imports and `React.lazy()`
|
||||
|
||||
```typescript
|
||||
const MemoizedComponent = React.memo(function Component(props: Props) {
|
||||
// Component implementation
|
||||
});
|
||||
|
||||
// For expensive calculations
|
||||
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
|
||||
|
||||
// For callback functions passed as props
|
||||
const memoizedCallback = useCallback(() => {
|
||||
doSomething(a, b);
|
||||
}, [a, b]);
|
||||
```
|
||||
|
||||
### Internationalization (i18n)
|
||||
|
||||
- Always use the `Trans` component for text rendering (no hardcoded strings)
|
||||
- Ensure all i18n keys are available in locale files
|
||||
- Use namespaces to organize translations logically
|
||||
- Include interpolation variables in translation keys
|
||||
- Test UI with different languages, especially those with longer text
|
||||
|
||||
```typescript
|
||||
// Correct
|
||||
<Trans i18nKey="user:profile.welcomeMessage" values={{ name: user.name }} />
|
||||
|
||||
// Incorrect
|
||||
<p>Welcome, {user.name}!</p>
|
||||
```
|
||||
|
||||
## Server Components
|
||||
|
||||
### Fundamentals
|
||||
|
||||
- Server Components render React server-side and never run on the client
|
||||
- Use Server Components as the default choice, especially for data fetching
|
||||
- No use of hooks, browser APIs, or event handlers in Server Components
|
||||
- No use of `useState`, `useEffect`, or any other React hooks
|
||||
- Server Components can render Client Components but not vice versa
|
||||
|
||||
### Data Fetching
|
||||
|
||||
- Fetch data directly using async/await in Server Components
|
||||
- Use Suspense boundaries around data-fetching components
|
||||
- Apply security checks before fetching sensitive data
|
||||
- Never pass sensitive data (API keys, tokens) to Client Components
|
||||
- Use React's `cache()` function for caching data requests
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Implement error boundaries at appropriate levels
|
||||
- Use the Next.js `error.tsx` file for route-level error handling
|
||||
- Create fallback UI for when data fetching fails
|
||||
- Log server errors appropriately without exposing details to clients
|
||||
|
||||
### Streaming and Suspense
|
||||
|
||||
- Use React Suspense for progressive loading experiences if specified
|
||||
- Implement streaming rendering for large or complex pages
|
||||
- Structure components to enable meaningful loading states
|
||||
- Prioritize above-the-fold content when using streaming
|
||||
|
||||
## Client Components
|
||||
|
||||
### Fundamentals
|
||||
|
||||
- Add the `'use client'` directive at the top of files for Client Components
|
||||
- Keep Client Components focused on interactivity and browser APIs
|
||||
- Use hooks appropriately following the Rules of Hooks
|
||||
- Implement controlled components for form elements
|
||||
- Handle all browser events in Client Components
|
||||
|
||||
### Data Fetching
|
||||
|
||||
- Use React Query (TanStack Query) for data fetching in Client Components
|
||||
- Create custom hooks for data fetching logic (e.g., `useUserData`)
|
||||
- Always handle loading, success, and error states
|
||||
|
||||
### Form Handling
|
||||
|
||||
- Use libraries like React Hook Form for complex forms
|
||||
- Implement proper validation with libraries like Zod
|
||||
- Create reusable form components
|
||||
- Handle form submissions with loading and error states
|
||||
- Use controlled components for form inputs
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Implement error boundaries to catch and handle component errors if using client components
|
||||
- Always handle network request errors
|
||||
- Provide user-friendly error messages
|
||||
- Log errors appropriately
|
||||
- Implement retry mechanisms where applicable
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
<div role="alert">
|
||||
<p>Something went wrong:</p>
|
||||
<pre>{error.message}</pre>
|
||||
<button onClick={resetErrorBoundary}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserProfileWithErrorHandling() {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onReset={() => {
|
||||
// Reset application state here if needed
|
||||
}}
|
||||
>
|
||||
<UserProfile userId="123" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
description: Next.js API Endpoints/Route Handlers
|
||||
globs: apps/**/route.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
# Route Handler / API Routes
|
||||
|
||||
- Use Route Handlers when data fetching from Client Components
|
||||
- To create API routes (route.ts), always use the `enhanceRouteHandler` function from the "@kit/supabase/routes" package. [index.ts](mdc:packages/next/src/routes/index.ts)
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const ZodSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function({ body, user, request }) {
|
||||
// 1. "body" is already a valid ZodSchema and it's safe to use
|
||||
// 2. "user" is the authenticated user
|
||||
// 3. "request" is NextRequest
|
||||
// ... your code here
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
{
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
|
||||
// example of unauthenticated route (careful!)
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function({ user, request }) {
|
||||
// 1. "user" is null, as "auth" is false and we don't require authentication
|
||||
// 2. "request" is NextRequest
|
||||
// ... your code here
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
{
|
||||
auth: false,
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -1,152 +0,0 @@
|
||||
---
|
||||
description: Security guidelines for writing secure code
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Next.js-Specific Security
|
||||
|
||||
### Client Component Data Passing
|
||||
|
||||
- **Never pass sensitive data** to Client Components
|
||||
- **Never pass unsanitized data** to Client Components (raw cookies, client-provided data)
|
||||
|
||||
### Server Components Security
|
||||
|
||||
- **Always sanitize user input** before using in Server Components
|
||||
- **Validate cookies and headers** in Server Components
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- **Use `import 'server-only'`** for code that should only be run on the server side
|
||||
- **Never expose server-only env vars** to the client
|
||||
- **Never pass environment variables as props to client components** unless they're suffixed with `NEXT_PUBLIC_`
|
||||
- **Never use `NEXT_PUBLIC_` prefix** for sensitive data (ex. API keys, secrets)
|
||||
- **Use `NEXT_PUBLIC_` prefix** only for client-safe variables
|
||||
|
||||
### Client Hydration Protection
|
||||
|
||||
- **Never expose sensitive data** in initial HTML
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
### Row Level Security (RLS)
|
||||
|
||||
- **Always enable RLS** on all tables unless explicitly specified otherwise [database.mdc](mdc:.cursor/rules/database.mdc)
|
||||
|
||||
### Super Admin Protected Routes
|
||||
Always perform extra checks when writing Super Admin code [super-admin.mdc](mdc:.cursor/rules/super-admin.mdc)
|
||||
|
||||
## Server Actions & API Routes
|
||||
|
||||
### Server Actions
|
||||
|
||||
- Always use `enhanceAction` wrapper for consistent security [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc)
|
||||
- Always use 'use server' directive at the top of the file to safely bundle server-side code
|
||||
- Validate input with Zod schemas
|
||||
- Implement authentication checks:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { MyActionSchema } from '../schema';
|
||||
|
||||
export const secureAction = enhanceAction(
|
||||
async function(data, user) {
|
||||
// Additional permission checks
|
||||
const hasPermission = await checkUserPermission(user.id, data.accountId, 'action.perform');
|
||||
if (!hasPermission) throw new Error('Insufficient permissions');
|
||||
|
||||
// Validated data available
|
||||
return processAction(data);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: MyActionSchema
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### API Routes
|
||||
|
||||
- Use `enhanceRouteHandler` for consistent security [route-handlers.mdc](mdc:.cursor/rules/route-handlers.mdc)
|
||||
- Implement authentication and authorization checks:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { RouteSchema } from '../schema';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function({ body, user, request }) {
|
||||
// Additional authorization checks
|
||||
const canAccess = await canAccessResource(user.id, body.resourceId);
|
||||
|
||||
if (!canAccess) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
// Safe to process with validated body
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: RouteSchema
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Client Components Security
|
||||
|
||||
### Context Awareness
|
||||
|
||||
- Use appropriate workspace contexts for access control:
|
||||
- `useUserWorkspace()` in personal account pages
|
||||
- `useTeamAccountWorkspace()` in team account pages
|
||||
- Check permissions before rendering sensitive UI elements:
|
||||
|
||||
```typescript
|
||||
function SecureComponent() {
|
||||
const { account, user } = useTeamAccountWorkspace();
|
||||
const canEdit = account.permissions.includes('entity.update');
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
return <EditComponent />;
|
||||
}
|
||||
```
|
||||
|
||||
### Data Fetching
|
||||
|
||||
- Use React Query with proper error handling
|
||||
- Never trust client-side permission checks alone
|
||||
|
||||
## One-Time Tokens
|
||||
|
||||
Consider using OTP tokens when implementing highly destructive operations like deleting an entity that would otherwise require a full backup: [otp.mdc](mdc:.cursor/rules/otp.mdc)
|
||||
|
||||
## Critical Data Protection
|
||||
|
||||
### Personal Information
|
||||
|
||||
- Never log or expose sensitive user data (api keys, passwords, secrets, etc.)
|
||||
- Use proper session management
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Never expose internal errors to clients
|
||||
- Log errors securely with appropriate context
|
||||
- Return generic error messages to users
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await sensitiveOperation();
|
||||
} catch (error) {
|
||||
logger.error({ error, context }, 'Operation failed');
|
||||
return { error: 'Unable to complete operation' };
|
||||
}
|
||||
```
|
||||
|
||||
## Database Security
|
||||
|
||||
- Avoid dynamic SQL generation
|
||||
- Use SECURITY DEFINER functions sparingly and carefully, warn user if you do so
|
||||
- Implement proper foreign key constraints
|
||||
- Use appropriate data types with constraints
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
description: Writing Server Actions for mutating data
|
||||
globs: apps/**","packages/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# Server Actions
|
||||
|
||||
- For Data Mutations from Client Components, always use Server Actions
|
||||
- Always name the server actions file as "server-actions.ts"
|
||||
- Always name exported Server Actions suffixed as "Action", ex. "createPostAction"
|
||||
- Always use the `enhanceAction` function from the "@kit/supabase/actions" package [index.ts](mdc:packages/next/src/actions/index.ts)
|
||||
- Always use the 'use server' directive at the top of the file
|
||||
- Place the Zod schema in a separate file so it can be reused with `react-hook-form`
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { EntitySchema } from '../entity.schema.ts`;
|
||||
|
||||
export const myServerAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// 1. "data" is already a valid EntitySchema and it's safe to use
|
||||
// 2. "user" is the authenticated user
|
||||
|
||||
// ... your code here
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: EntitySchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -1,210 +0,0 @@
|
||||
---
|
||||
description: Super Admin functionalities
|
||||
globs: apps/*/app/admin/**,packages/features/admin/**
|
||||
alwaysApply: false
|
||||
---
|
||||
## Super Admin
|
||||
|
||||
1. Page Authentication:
|
||||
- All pages in the admin section must be wrapped with the `AdminGuard` HOC
|
||||
- This ensures only users with the 'super-admin' role and MFA enabled can access these pages
|
||||
- Example: `export default AdminGuard(AdminPageComponent);`
|
||||
|
||||
2. Server Actions:
|
||||
- Use the `adminAction` wrapper for all server actions in the admin section
|
||||
- This checks if the current user is a super admin before executing the action
|
||||
- Example:
|
||||
```typescript
|
||||
export const yourAdminAction = adminAction(
|
||||
enhanceAction(
|
||||
async (data) => {
|
||||
// Action implementation
|
||||
},
|
||||
{
|
||||
schema: YourActionSchema,
|
||||
}
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
3. Authorization Functions:
|
||||
- Import and use `isSuperAdmin` from '@kit/admin' to check if the current user is a super admin [is-super-admin.ts](mdc:packages/features/admin/src/lib/server/utils/is-super-admin.ts)
|
||||
- This function returns a boolean indicating whether the user has the super-admin role and MFA enabled
|
||||
- Example:
|
||||
```typescript
|
||||
const isAdmin = await isSuperAdmin(getSupabaseServerClient());
|
||||
if (!isAdmin) {
|
||||
notFound(); // or redirect/throw error
|
||||
}
|
||||
```
|
||||
|
||||
4. Schema Validation:
|
||||
- Define Zod schemas for all admin actions in the 'schema' directory
|
||||
- Follow the pattern in [admin-actions.schema.ts](mdc:packages/features/admin/src/lib/server/schema/admin-actions.schema.ts)
|
||||
- Include appropriate validation for all fields
|
||||
|
||||
5. Data Fetching
|
||||
- Do not use `ServerDataLoader` unless the query is very simple
|
||||
- Use the authed Supabase Server Client such as [admin-dashboard.loader.ts](mdc:packages/features/admin/src/lib/server/loaders/admin-dashboard.loader.ts)
|
||||
|
||||
The Super Admin section requires strict access control as it provides elevated privileges. Always ensure the current user cannot perform destructive actions on their own account and properly validate input data."
|
||||
|
||||
## Writing Pages in the Admin Sections
|
||||
|
||||
1. Basic Page Structure:
|
||||
```typescript
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
|
||||
// Optional metadata export
|
||||
export const metadata = {
|
||||
title: `Page Title`,
|
||||
};
|
||||
|
||||
async function YourAdminPage() {
|
||||
// Load data using cached loaders
|
||||
const data = await loadYourAdminData();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader description={<AppBreadcrumbs />} />
|
||||
<PageBody>
|
||||
{/* Page content */}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: Always wrap with AdminGuard
|
||||
export default AdminGuard(YourAdminPage);
|
||||
```
|
||||
|
||||
2. Data Loading:
|
||||
- Create a cached loader function in a server directory
|
||||
- Use the Supabase client for database operations
|
||||
- Example:
|
||||
```typescript
|
||||
// in _lib/server/loaders/your-loader.ts
|
||||
import { cache } from 'react';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export const loadYourAdminData = cache(async () => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client.from('your_table').select('*');
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
});
|
||||
```
|
||||
|
||||
3. Dynamic Routes:
|
||||
- For pages that need parameters (like `[id]`), handle them appropriately
|
||||
- For example:
|
||||
```typescript
|
||||
interface Params {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function AdminDetailPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const item = await loadItemById(id);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
4. Updating Sidebar navigation at [admin-sidebar.tsx](mdc:apps/web/app/admin/_components/admin-sidebar.tsx) to include new pages
|
||||
|
||||
### Security Considerations:
|
||||
- Validate that the target is not the current super admin
|
||||
- Implement confirmation steps for destructive actions
|
||||
- Never expose sensitive error details to the client
|
||||
|
||||
### Services
|
||||
|
||||
1. Basic Service Structure:
|
||||
```typescript
|
||||
import 'server-only';
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
export function createYourAdminService(client: SupabaseClient<Database>) {
|
||||
return new YourAdminService(client);
|
||||
}
|
||||
|
||||
class YourAdminService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async performAction(params: YourActionParams) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'admin.yourService', ...params };
|
||||
|
||||
logger.info(ctx, 'Starting admin action');
|
||||
|
||||
// Perform the action
|
||||
const { data, error } = await this.client
|
||||
.from('your_table')
|
||||
.update({ some_field: params.value })
|
||||
.eq('id', params.id);
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Admin action failed');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Admin action completed successfully');
|
||||
return data;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Important Patterns:
|
||||
- Mark files with 'server-only' directive
|
||||
- Use factory functions to create service instances
|
||||
- Use class-based services with typed parameters
|
||||
- Properly type the Supabase client with the Database type
|
||||
- Use structured logging with context
|
||||
- Handle errors consistently
|
||||
|
||||
3. Security Checks:
|
||||
- Implement methods to verify the current user is not taking action on their own account
|
||||
- Example:
|
||||
```typescript
|
||||
private async assertUserIsNotCurrentSuperAdmin(targetId: string) {
|
||||
const { data } = await this.client.auth.getUser();
|
||||
const currentUserId = data.user?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
if (currentUserId === targetId) {
|
||||
throw new Error(
|
||||
`You cannot perform a destructive action on your own account as a Super Admin`
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Data Access:
|
||||
- Use the appropriate Supabase client (admin or regular)
|
||||
- For admin-only operations, use the admin client
|
||||
- For regular operations, use the standard client
|
||||
- Example:
|
||||
```typescript
|
||||
constructor(
|
||||
private readonly client: SupabaseClient<Database>,
|
||||
private readonly adminClient?: SupabaseClient<Database>
|
||||
) {}
|
||||
```
|
||||
|
||||
5. Error Handling:
|
||||
- Use structured error handling
|
||||
- Include appropriate context in error logs
|
||||
- Return typed error responses
|
||||
|
||||
Services should be focused on specific domains and follow the principle of single responsibility.
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
description: Team Accounts context and functionality
|
||||
globs: apps/*/app/home/[account],packages/features/team-accounts/**
|
||||
alwaysApply: false
|
||||
---
|
||||
## Team Accounts
|
||||
|
||||
The team account context in the application lives under the path `app/home/[account]`. The `[account]` segment is the slug of the team account, from which we can identify the team.
|
||||
|
||||
### Accessing the Account Workspace Data in Client Components
|
||||
|
||||
The data fetched from the account workspace API is available in the team context. You can access this data using the `useTeamAccountWorkspace` hook [use-team-account-workspace.ts](mdc:packages/features/team-accounts/src/hooks/use-team-account-workspace.ts)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
export default function SomeComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
// use account, user, and accounts
|
||||
}
|
||||
```
|
||||
|
||||
The `useTeamAccountWorkspace` hook returns the same data structure as the `loadTeamWorkspace` function.
|
||||
|
||||
NB: the hooks is not to be used is Server Components, only in Client Components. Additionally, this is only available in the pages under /home/[account] layout.
|
||||
|
||||
### Team Pages
|
||||
|
||||
These pages are dedicated to the team account, which means they are only accessible to team members. To access these pages, the user must be authenticated and belong to the team.
|
||||
|
||||
## Guidelines
|
||||
|
||||
### State Management
|
||||
- Use the `TeamAccountWorkspaceContext` to access account workspace data
|
||||
- Team account data can be accessed using `useTeamAccountWorkspace` hook
|
||||
- Server-side loading done with `loadTeamWorkspace` in `team-account-workspace.loader.ts` [team-account-workspace.loader.ts](mdc:apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts)
|
||||
|
||||
### Account Management Features
|
||||
- Role-based permissions control what users can do within a team
|
||||
- Team members can be invited, roles can be updated, and members can be removed
|
||||
- Primary account owner has special privileges (transfer ownership, delete team)
|
||||
- Account deletion requires OTP verification
|
||||
|
||||
### Billing Integration
|
||||
- Team account billing uses [team-billing.service.ts](mdc:apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts)
|
||||
- Per-seat billing handled by [account-per-seat-billing.service.ts](mdc:packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts)
|
||||
|
||||
## API
|
||||
|
||||
The API for the personal account is [api.ts](mdc:packages/features/team-accounts/src/server/api.ts)
|
||||
|
||||
### Factory
|
||||
```typescript
|
||||
createAccountsApi(client: SupabaseClient<Database>): AccountsApi
|
||||
```
|
||||
|
||||
### TeamAccountsApi
|
||||
```typescript
|
||||
constructor(client: SupabaseClient<Database>)
|
||||
```
|
||||
|
||||
### Methods
|
||||
- `getTeamAccount(slug: string)` - Get team by slug
|
||||
- `getTeamAccountById(accountId: string)` - Get team by ID
|
||||
- `getSubscription(accountId: string)` - Get team subscription
|
||||
- `getOrder(accountId: string)` - Get team order
|
||||
- `getAccountWorkspace(slug: string)` - Get team workspace
|
||||
- `hasPermission({accountId, userId, permission})` - Check user permission
|
||||
- `getMembersCount(accountId: string)` - Get team member count
|
||||
- `getCustomerId(accountId: string)` - Get team customer ID
|
||||
- `getInvitation(adminClient, token)` - Get invitation by token
|
||||
|
||||
## Feature Flags
|
||||
- Key flags at [feature-flags.config.ts](mdc:apps/web/config/feature-flags.config.ts)
|
||||
- `enableTeamAccountBilling`
|
||||
- `enableTeamDeletion`
|
||||
- `enableTeamCreation`
|
||||
- `enableNotifications`
|
||||
@@ -1,146 +0,0 @@
|
||||
---
|
||||
description: I18n and Translations
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# i18n System Guide
|
||||
|
||||
This document provides a comprehensive overview of the internationalization (i18n) system in our Next.js application.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The i18n system consists of:
|
||||
|
||||
1. **Core i18n Package**: Located in `packages/i18n`, providing the foundation for i18n functionality
|
||||
2. **Application-specific Implementation**: Located in `apps/web/lib/i18n`, customizing the core functionality
|
||||
3. **Translation Files**: Located in `apps/web/public/locales/[language]/[namespace].json`
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### 1. Setting Up a Page or Layout with i18n
|
||||
|
||||
Wrap your page or layout component with the `withI18n` HOC:
|
||||
|
||||
```typescript
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
function HomePage() {
|
||||
// Your component code
|
||||
}
|
||||
|
||||
export default withI18n(HomePage);
|
||||
```
|
||||
|
||||
### 2. Using Translations in Client Components
|
||||
|
||||
Use the `useTranslation` hook from react-i18next:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function MyComponent() {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return <h1>{t('homeTabLabel')}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Using Translations with the Trans Component
|
||||
|
||||
For complex translations that include HTML or variables:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function MyComponent() {
|
||||
return (
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="teams:inviteAlertBody"
|
||||
values={{ accountName: 'My Team' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Adding Language Selection to Your UI
|
||||
|
||||
Use the `LanguageSelector` component:
|
||||
|
||||
```tsx
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Language Settings</h2>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Adding New Translations
|
||||
|
||||
1. Create or update JSON files in `apps/web/public/locales/[language]/[namespace].json`
|
||||
2. Follow the existing structure, adding your new keys
|
||||
|
||||
For example, in `apps/web/public/locales/en/common.json`:
|
||||
```json
|
||||
{
|
||||
"existingKey": "Existing translation",
|
||||
"newKey": "New translation text"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Adding a New Language
|
||||
|
||||
1. Add the language code to the `languages` array in `i18n.settings.ts`
|
||||
2. Create corresponding translation files in `apps/web/public/locales/[new-language]/`
|
||||
3. Copy the structure from the English files as a template
|
||||
|
||||
### 7. Adding a New Namespace
|
||||
|
||||
1. Add the namespace to `defaultI18nNamespaces` in `i18n.settings.ts`
|
||||
2. Create corresponding translation files for all supported languages
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Dynamic Namespace Loading
|
||||
|
||||
When you need translations from namespaces not included in the default set:
|
||||
|
||||
```typescript
|
||||
import { getI18nSettings } from '~/lib/i18n/i18n.settings';
|
||||
|
||||
// Load specific namespaces
|
||||
const settings = getI18nSettings(language, ['specific-namespace']);
|
||||
```
|
||||
|
||||
### Language Priority
|
||||
|
||||
The system uses the following priority to determine the language:
|
||||
1. User-selected language (from cookie)
|
||||
2. Browser language (if priority is set to 'user')
|
||||
3. Default language from environment variable
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **Translation not showing**: Check that you're using the correct namespace
|
||||
- **Dynamic content not interpolated**: Make sure to use the `values` prop with `Trans` component
|
||||
|
||||
## Available Namespaces and Keys
|
||||
|
||||
Here's a brief overview of the available namespaces:
|
||||
|
||||
- **common**: General UI elements, navigation, errors [common.json](mdc:apps/web/public/locales/en/common.json)
|
||||
- **auth**: Authentication-related text [auth.json](mdc:apps/web/public/locales/en/auth.json)
|
||||
- **account**: Account settings and profile [account.json](mdc:apps/web/public/locales/en/account.json)
|
||||
- **teams**: Team management [teams.json](mdc:apps/web/public/locales/en/teams.json)
|
||||
- **billing**: Subscription and payment [billing.json](mdc:apps/web/public/locales/en/billing.json)
|
||||
- **marketing**: Landing pages, blog, etc. [marketing.json](mdc:apps/web/public/locales/en/marketing.json)
|
||||
|
||||
When creating a new functionality, it can be useful to add a new namespace.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
# Typescript
|
||||
|
||||
- Write clean, clear, well-designed, explicit Typescript
|
||||
- Make sure types are validated strictly
|
||||
- Use implicit type inference, unless impossible
|
||||
- Consider using classes for server-side services, but export a function instead of the class
|
||||
|
||||
```tsx
|
||||
// service.ts
|
||||
class UserService {
|
||||
getUser(id: number) {
|
||||
// ... implementation ...
|
||||
return { id, name: 'Example User' };
|
||||
}
|
||||
}
|
||||
|
||||
export function createUserService() {
|
||||
return new UserService();
|
||||
}
|
||||
```
|
||||
|
||||
- Follow the Single Responsibility Principle (SRP). Each module/function/class should have one reason to change.
|
||||
- Favor composition over inheritance.
|
||||
- Handle errors gracefully using try/catch and appropriate error types.
|
||||
- Keep functions short and focused.
|
||||
- Use descriptive names for variables, functions, and classes.
|
||||
- Avoid unnecessary complexity.
|
||||
- Avoid using `any` type as much as possible. If necessary, use `unknown`
|
||||
- Use enums only when appropriate. Consider union types of string literals as an alternative.
|
||||
- Be aware of performance implications of your code.
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
description: UI Components API reference and guidelines
|
||||
globs: **/*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# UI Components
|
||||
|
||||
- Reusable UI components are defined in the "packages/ui" package named "@kit/ui".
|
||||
- By exporting the component from the "exports" field, we can import it using the "@kit/ui/{component-name}" format.
|
||||
|
||||
## Styling
|
||||
|
||||
- Styling is done using Tailwind CSS. We use the "cn" function from the "@kit/ui/utils" package to generate class names.
|
||||
- Avoid fixes classes such as "bg-gray-500". Instead, use Shadcn classes such as "bg-background", "text-secondary-foreground", "text-muted-foreground", etc.
|
||||
|
||||
Makerkit leverages two sets of UI components:
|
||||
|
||||
1. **Shadcn UI Components**: Base components from the Shadcn UI library
|
||||
2. **Makerkit-specific Components**: Custom components built on top of Shadcn UI
|
||||
|
||||
## Importing Components
|
||||
|
||||
```tsx
|
||||
// Import Shadcn UI components
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card } from '@kit/ui/card';
|
||||
// Import Makerkit-specific 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';
|
||||
```
|
||||
|
||||
## Core Shadcn UI Components
|
||||
|
||||
| Component | Description | Import Path |
|
||||
| ---------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `Accordion` | Expandable/collapsible content sections | `@kit/ui/accordion` [accordion.tsx](mdc:packages/ui/src/shadcn/accordion.tsx) |
|
||||
| `AlertDialog` | Modal dialog for important actions | `@kit/ui/alert-dialog` [alert-dialog.tsx](mdc:packages/ui/src/shadcn/alert-dialog.tsx) |
|
||||
| `Alert` | Status/notification messages | `@kit/ui/alert` [alert.tsx](mdc:packages/ui/src/shadcn/alert.tsx) |
|
||||
| `Avatar` | User profile images with fallback | `@kit/ui/avatar` [avatar.tsx](mdc:packages/ui/src/shadcn/avatar.tsx) |
|
||||
| `Badge` | Small status indicators | `@kit/ui/badge` [badge.tsx](mdc:packages/ui/src/shadcn/badge.tsx) |
|
||||
| `Breadcrumb` | Navigation path indicators | `@kit/ui/breadcrumb` [breadcrumb.tsx](mdc:packages/ui/src/shadcn/breadcrumb.tsx) |
|
||||
| `Button` | Clickable action elements | `@kit/ui/button` [button.tsx](mdc:packages/ui/src/shadcn/button.tsx) |
|
||||
| `Calendar` | Date picker and date display | `@kit/ui/calendar` [calendar.tsx](mdc:packages/ui/src/shadcn/calendar.tsx) |
|
||||
| `Card` | Container for grouped content | `@kit/ui/card` [card.tsx](mdc:packages/ui/src/shadcn/card.tsx) |
|
||||
| `Checkbox` | Selection input | `@kit/ui/checkbox` [checkbox.tsx](mdc:packages/ui/src/shadcn/checkbox.tsx) |
|
||||
| `Command` | Command palette interface | `@kit/ui/command` [command.tsx](mdc:packages/ui/src/shadcn/command.tsx) |
|
||||
| `DataTable` | Table | `@kit/ui/data-table` [data-table.tsx](mdc:packages/ui/src/shadcn/data-table.tsx) |
|
||||
| `Dialog` | Modal window for focused interactions | `@kit/ui/dialog` [dialog.tsx](mdc:packages/ui/src/shadcn/dialog.tsx) |
|
||||
| `DropdownMenu` | Menu triggered by a button | `@kit/ui/dropdown-menu` [dropdown-menu.tsx](mdc:packages/ui/src/shadcn/dropdown-menu.tsx) |
|
||||
| `Form` | Form components with validation | `@kit/ui/form` [form.tsx](mdc:packages/ui/src/shadcn/form.tsx) |
|
||||
| `Input` | Text input field | `@kit/ui/input` [input.tsx](mdc:packages/ui/src/shadcn/input.tsx) |
|
||||
| `Input OTP` | OTP Text input field | `@kit/ui/input-otp` [input-otp.tsx](mdc:packages/ui/src/shadcn/input-otp.tsx) |
|
||||
| `Label` | Text label for form elements | `@kit/ui/label` [label.tsx](mdc:packages/ui/src/shadcn/label.tsx) |
|
||||
| `NavigationMenu` | Hierarchical navigation component | `@kit/ui/navigation-menu` [navigation-menu.tsx](mdc:packages/ui/src/shadcn/navigation-menu.tsx) |
|
||||
| `Popover` | Floating content triggered by interaction | `@kit/ui/popover` [popover.tsx](mdc:packages/ui/src/shadcn/popover.tsx) |
|
||||
| `RadioGroup` | Radio button selection group | `@kit/ui/radio-group` [radio-group.tsx](mdc:packages/ui/src/shadcn/radio-group.tsx) |
|
||||
| `ScrollArea` | Customizable scrollable area | `@kit/ui/scroll-area` [scroll-area.tsx](mdc:packages/ui/src/shadcn/scroll-area.tsx) |
|
||||
| `Select` | Dropdown selection menu | `@kit/ui/select` [select.tsx](mdc:packages/ui/src/shadcn/select.tsx) |
|
||||
| `Separator` | Visual divider between content | `@kit/ui/separator` [separator.tsx](mdc:packages/ui/src/shadcn/separator.tsx) |
|
||||
| `Sheet` | Sliding panel from screen edge | `@kit/ui/sheet` [sheet.tsx](mdc:packages/ui/src/shadcn/sheet.tsx) |
|
||||
| `Sidebar` | Advanced sidebar navigation | `@kit/ui/shadcn-sidebar` [sidebar.tsx](mdc:packages/ui/src/shadcn/sidebar.tsx) |
|
||||
| `Skeleton` | Loading placeholder | `@kit/ui/skeleton` [skeleton.tsx](mdc:packages/ui/src/shadcn/skeleton.tsx) |
|
||||
| `Switch` | Toggle control | `@kit/ui/switch` [switch.tsx](mdc:packages/ui/src/shadcn/switch.tsx) |
|
||||
| `Toast` | Toaster | `@kit/ui/sonner` [sonner.tsx](mdc:packages/ui/src/shadcn/sonner.tsx) |
|
||||
| `Tabs` | Tab-based navigation | `@kit/ui/tabs` [tabs.tsx](mdc:packages/ui/src/shadcn/tabs.tsx) |
|
||||
| `Textarea` | Multi-line text input | `@kit/ui/textarea` [textarea.tsx](mdc:packages/ui/src/shadcn/textarea.tsx) |
|
||||
| `Tooltip` | Contextual information on hover | `@kit/ui/tooltip` [tooltip.tsx](mdc:packages/ui/src/shadcn/tooltip.tsx) |
|
||||
|
||||
## Makerkit-specific Components
|
||||
|
||||
| Component | Description | Import Path |
|
||||
| ---------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `If` | Conditional rendering component | `@kit/ui/if` [if.tsx](mdc:packages/ui/src/makerkit/if.tsx) |
|
||||
| `Trans` | Internationalization text component | `@kit/ui/trans` [trans.tsx](mdc:packages/ui/src/makerkit/trans.tsx) |
|
||||
| `Page` | Page layout with navigation | `@kit/ui/page` [page.tsx](mdc:packages/ui/src/makerkit/page.tsx) |
|
||||
| `GlobalLoader` | Full-page loading indicator | `@kit/ui/global-loader` [global-loader.tsx](mdc:packages/ui/src/makerkit/global-loader.tsx) |
|
||||
| `ImageUploader` | Image upload component | `@kit/ui/image-uploader` [image-uploader.tsx](mdc:packages/ui/src/makerkit/image-uploader.tsx) |
|
||||
| `ProfileAvatar` | User avatar with fallback | `@kit/ui/profile-avatar` [profile-avatar.tsx](mdc:packages/ui/src/makerkit/profile-avatar.tsx) |
|
||||
| `DataTable` (Enhanced) | Extended data table with pagination | `@kit/ui/enhanced-data-table` [data-table.tsx](mdc:packages/ui/src/makerkit/data-table.tsx) |
|
||||
| `Stepper` | Multi-step process indicator | `@kit/ui/stepper` [stepper.tsx](mdc:packages/ui/src/makerkit/stepper.tsx) |
|
||||
| `CookieBanner` | GDPR-compliant cookie notice | `@kit/ui/cookie-banner` [cookie-banner.tsx](mdc:packages/ui/src/makerkit/cookie-banner.tsx) |
|
||||
| `CardButton` | Card-styled button | `@kit/ui/card-button` [card-button.tsx](mdc:packages/ui/src/makerkit/card-button.tsx) |
|
||||
| `MultiStepForm` | Form with multiple steps | `@kit/ui/multi-step-form` [multi-step-form.tsx](mdc:packages/ui/src/makerkit/multi-step-form.tsx) |
|
||||
| `EmptyState` | Empty data placeholder | `@kit/ui/empty-state` [empty-state.tsx](mdc:packages/ui/src/makerkit/empty-state.tsx) |
|
||||
| `AppBreadcrumbs` | Application path breadcrumbs | `@kit/ui/app-breadcrumbs` [app-breadcrumbs.tsx](mdc:packages/ui/src/makerkit/app-breadcrumbs.tsx) |
|
||||
|
||||
## Marketing Components
|
||||
|
||||
Import all marketing components with:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
GradientText,
|
||||
// etc.
|
||||
Hero,
|
||||
HeroTitle,
|
||||
} from '@kit/ui/marketing';
|
||||
```
|
||||
|
||||
Key marketing components:
|
||||
|
||||
- `Hero` - Hero sections [hero.tsx](mdc:packages/ui/src/makerkit/marketing/hero.tsx)
|
||||
- `SecondaryHero` [secondary-hero.tsx](mdc:packages/ui/src/makerkit/marketing/secondary-hero.tsx)
|
||||
- `FeatureCard`, `FeatureGrid` - Feature showcases [feature-card.tsx](mdc:packages/ui/src/makerkit/marketing/feature-card.tsx)
|
||||
- `Footer` - Page Footer [footer.tsx](mdc:packages/ui/src/makerkit/marketing/footer.tsx)
|
||||
- `Header` - Page Header [header.tsx](mdc:packages/ui/src/makerkit/marketing/header.tsx)
|
||||
- `NewsletterSignup` - Email collection [newsletter-signup-container.tsx](mdc:packages/ui/src/makerkit/marketing/newsletter-signup-container.tsx)
|
||||
- `ComingSoon` - Coming soon page template [coming-soon.tsx](mdc:packages/ui/src/makerkit/marketing/coming-soon.tsx)
|
||||
3
.gemini/GEMINI.md
Normal file
3
.gemini/GEMINI.md
Normal file
@@ -0,0 +1,3 @@
|
||||
- Check for the presence of AGENTS.md files in the project workspace
|
||||
|
||||
- There may be additional AGENTS.md in sub-folders with additional specific instructions that are related to only that part of the codebase.
|
||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"makerkit": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["packages/mcp-server/build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
239
.windsurfrules
239
.windsurfrules
@@ -1,239 +0,0 @@
|
||||
# Makerkit Guidelines
|
||||
|
||||
## Project Stack
|
||||
|
||||
- Framework: Next.js 16 App Router, TypeScript, React, Node.js
|
||||
- Backend: Supabase with Postgres
|
||||
- UI: Shadcn UI, Tailwind CSS
|
||||
- Key libraries: React Hook Form, React Query, Zod, Lucide React
|
||||
- Focus: Code clarity, Readability, Best practices, Maintainability
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/apps/web/
|
||||
/app
|
||||
/home # protected routes
|
||||
/(user) # user workspace
|
||||
/[account] # team workspace
|
||||
/(marketing) # marketing pages
|
||||
/auth # auth pages
|
||||
/components # global components
|
||||
/config # global config
|
||||
/lib # global utils
|
||||
/content # markdoc content
|
||||
/supabase # supabase root
|
||||
```
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Server Components
|
||||
- Use Supabase Client directly via `getSupabaseServerClient`
|
||||
- Handle errors with proper boundaries
|
||||
- Example:
|
||||
```tsx
|
||||
async function ServerComponent() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
if (error) return <ErrorComponent error={error} />;
|
||||
return <ClientComponent data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
2. Client Components
|
||||
- Use React Query for data fetching
|
||||
- Implement proper loading states
|
||||
- Example:
|
||||
```tsx
|
||||
function useNotes() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notes'],
|
||||
queryFn: async () => {
|
||||
const { data } = await fetch('/api/notes');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
return { data, isLoading };
|
||||
}
|
||||
```
|
||||
|
||||
### Server Actions
|
||||
|
||||
- Name files as "server-actions.ts" in `_lib/server` folder
|
||||
- Export with "Action" suffix
|
||||
- Use `enhanceAction` with proper typing
|
||||
- Example:
|
||||
```tsx
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
const { error } = await client
|
||||
.from('notes')
|
||||
.insert({ ...data, user_id: user.id });
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: NoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handlers
|
||||
|
||||
- Use `enhanceRouteHandler` to wrap route handlers
|
||||
- Use Route Handlers when data fetching from Client Components
|
||||
|
||||
## Database & Security
|
||||
|
||||
### RLS Policies
|
||||
|
||||
- Strive to create a safe, robust, secure and consistent database schema
|
||||
- Always consider the compromises you need to make and explain them so I can make an educated decision. Follow up with the considerations make and explain them.
|
||||
- Enable RLS by default and propose the required RLS policies
|
||||
- `public.accounts` are the root tables for the application
|
||||
- Implement cascading deletes when appropriate
|
||||
- Ensure strong consistency considering triggers and constraints
|
||||
- Always use Postgres schemas explicitly (e.g., `public.accounts`)
|
||||
|
||||
## Forms Pattern
|
||||
|
||||
### 1. Schema Definition
|
||||
|
||||
```tsx
|
||||
// schema/note.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const NoteSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
content: z.string().min(1),
|
||||
category: z.enum(['work', 'personal']),
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Form Component
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
export function NoteForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(NoteSchema),
|
||||
defaultValues: { title: '', content: '', category: 'personal' },
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof NoteSchema>) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createNoteAction(data);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Other fields */}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Consider logging asynchronous requests in server code using the `@kit/shared/logger`
|
||||
- Handle promises and async/await gracefully
|
||||
- Consider the unhappy path and handle errors appropriately
|
||||
|
||||
### Structured Logging
|
||||
|
||||
```tsx
|
||||
const ctx = {
|
||||
name: 'create-note',
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Creating new note...');
|
||||
|
||||
try {
|
||||
await createNote();
|
||||
logger.info(ctx, 'Note created successfully');
|
||||
} catch (error) {
|
||||
logger.error(ctx, 'Failed to create note', { error });
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Context Management
|
||||
|
||||
In client components, we can use the `useUserWorkspace` hook to access the user's workspace data.
|
||||
|
||||
### Personal Account
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
function PersonalDashboard() {
|
||||
const { workspace, user } = useUserWorkspace();
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome, {user.email}</h1>
|
||||
<SubscriptionStatus status={workspace.subscription_status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Team Account
|
||||
|
||||
In client components, we can use the `useTeamAccountWorkspace` hook to access the team account's workspace data. It only works under the `/home/[account]` route.
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
function TeamDashboard() {
|
||||
const { account, user } = useTeamAccountWorkspace();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{account.name}</h1>
|
||||
<RoleDisplay role={account.role} />
|
||||
<PermissionsList permissions={account.permissions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
- Reusable UI components are defined in the "packages/ui" package named "@kit/ui".
|
||||
- By exporting the component from the "exports" field, we can import it using the "@kit/ui/{component-name}" format.
|
||||
|
||||
## Creating Pages
|
||||
|
||||
When creating new pages ensure:
|
||||
|
||||
- The page is exported using `withI18n(Page)` to enable i18n.
|
||||
- The page has the required and correct metadata using the `metadata` or `generateMetadata` function.
|
||||
- Don't worry about authentication, it's handled in the middleware.
|
||||
228
AGENTS.md
228
AGENTS.md
@@ -1,207 +1,61 @@
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
# Makerkit SaaS Starter
|
||||
|
||||
## Core Technologies
|
||||
## Tech Stack
|
||||
|
||||
- **Next.js 16** with App Router
|
||||
- **Supabase** for database, auth, and storage
|
||||
- **React 19**
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS 4**, Shadcn UI, Lucide React
|
||||
- **Turborepo**
|
||||
- **Next.js 16** (App Router) + **React 19** + **TypeScript**
|
||||
- **Supabase** (Postgres, Auth, Storage)
|
||||
- **Tailwind CSS 4** + Shadcn UI
|
||||
- **Turborepo** monorepo
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
- `apps/web` - Main Next.js SaaS application
|
||||
- `apps/web/supabase` - Supabase folder (migrations, schemas, tests)
|
||||
- `apps/e2e` - Playwright end-to-end tests
|
||||
- `packages/features/*` - Feature packages
|
||||
- `packages/` - Shared packages and utilities
|
||||
| Directory | Purpose | Details |
|
||||
|-----------|---------|---------|
|
||||
| `apps/web` | Main Next.js app | See `apps/web/AGENTS.md` |
|
||||
| `apps/web/supabase` | Database schemas & migrations | See `apps/web/supabase/AGENTS.md` |
|
||||
| `apps/e2e` | Playwright E2E tests | See `apps/e2e/AGENTS.md` |
|
||||
| `packages/ui` | UI components (@kit/ui) | See `packages/ui/AGENTS.md` |
|
||||
| `packages/supabase` | Supabase clients | See `packages/supabase/AGENTS.md` |
|
||||
| `packages/next` | Next.js utilities | See `packages/next/AGENTS.md` |
|
||||
| `packages/features` | Feature packages | See `packages/features/AGENTS.md` |
|
||||
|
||||
## Multi-Tenant Architecture
|
||||
|
||||
**Personal Accounts**: Individual user accounts (auth.users.id = accounts.id)
|
||||
**Team Accounts**: Shared workspaces with members, roles, and permissions
|
||||
|
||||
Data associates with accounts via foreign keys for proper access control.
|
||||
- **Personal Accounts**: `auth.users.id = accounts.id`
|
||||
- **Team Accounts**: Shared workspaces with members, roles, permissions
|
||||
- Data links to accounts via `account_id` foreign key
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
pnpm dev # Start all apps
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
pnpm supabase:web:start # Start Supabase locally
|
||||
pnpm --filter web supabase migrations up # Apply new migrations
|
||||
pnpm supabase:web:reset # Reset with latest schema (clean rebuild)
|
||||
pnpm dev # Start development
|
||||
pnpm supabase:web:start # Start local Supabase
|
||||
pnpm supabase:web:reset # Reset database
|
||||
pnpm supabase:web:typegen # Generate TypeScript types
|
||||
pnpm --filter web supabase:db:diff # Create migration
|
||||
pnpm typecheck # Type check
|
||||
pnpm lint:fix # Fix linting
|
||||
pnpm format:fix # Format code
|
||||
```
|
||||
|
||||
The typegen command must be run after applying migrations or resetting the database.
|
||||
## Key Patterns (Quick Reference)
|
||||
|
||||
## Typescript
|
||||
| Pattern | Import | Details |
|
||||
|---------|--------|---------|
|
||||
| Server Actions | `enhanceAction` from `@kit/next/actions` | `packages/next/AGENTS.md` |
|
||||
| Route Handlers | `enhanceRouteHandler` from `@kit/next/routes` | `packages/next/AGENTS.md` |
|
||||
| Server Client | `getSupabaseServerClient` from `@kit/supabase/server-client` | `packages/supabase/AGENTS.md` |
|
||||
| UI Components | `@kit/ui/{component}` | `packages/ui/AGENTS.md` |
|
||||
| Translations | `Trans` from `@kit/ui/trans` | `packages/ui/AGENTS.md` |
|
||||
|
||||
- Write clean, clear, well-designed, explicit TypeScript
|
||||
- Avoid obvious comments
|
||||
- Avoid unnecessary complexity or overly abstract code
|
||||
- Always use implicit type inference, unless impossible
|
||||
- You must avoid using `any`
|
||||
- Handle errors gracefully using try/catch and appropriate error types
|
||||
- Use service pattern for server-side APIs
|
||||
- Add `import 'server-only';` to code that is exclusively server-side
|
||||
- Never mix client and server imports from a file or a package
|
||||
- Extract self-contained classes/utilities (ex. algortihmic code) from classes that cross the network boundary
|
||||
## Authorization
|
||||
|
||||
## React
|
||||
- **RLS enforces access control** - no manual auth checks needed with standard client
|
||||
- **Admin client** (`getSupabaseServerAdminClient`) bypasses RLS - use sparingly with manual validation
|
||||
|
||||
- Encapsulate repeated blocks of code into reusable local components
|
||||
- Write small, composable, explicit, well-named components
|
||||
- Always use `react-hook-form` and `@kit/ui/form` for writing forms
|
||||
- Always use 'use client' directive for client components
|
||||
- Add `data-test` for E2E tests where appropriate
|
||||
- `useEffect` is a code smell and must be justified - avoid if possible
|
||||
- Do not write many (such as 4-5) separate `useState`, prefer single state object (unless required)
|
||||
- Prefer server-side data fetching using RSC
|
||||
- Display loading indicators (ex. with LoadingSpinner) component where appropriate
|
||||
## Verification
|
||||
|
||||
## Next.js
|
||||
|
||||
- Use `enhanceAction` for Server Actions
|
||||
- Use `use server` in server actions files
|
||||
- Use `enhanceRouteHandler` for API Routes
|
||||
- Export page components using the `withI18n` utility
|
||||
- Add well-written page metadata to pages
|
||||
- Redirect using `redirect` following a server action instead of using client-side router
|
||||
- Since `redirect` throws an error, handle `catch` block using `isRedirectError` from `next/dist/client/components/redirect-error` in client-side forms when calling the server action
|
||||
|
||||
|
||||
## Data Fetching Architecture
|
||||
|
||||
Makerkit uses a clear separation between data fetching and mutations:
|
||||
|
||||
### Server Components with Loaders (Reading Data)
|
||||
|
||||
**Pattern**: Use async server components that call loader functions for initial data fetching.
|
||||
|
||||
```typescript
|
||||
// Page component (apps/web/app/home/[account]/page.tsx)
|
||||
async function TeamAccountPage({ params }: Props) {
|
||||
const client = getSupabaseServerClient();
|
||||
const slug = (await params).account;
|
||||
|
||||
const [projects, workspace] = await loadProjectsPageData(client, slug);
|
||||
|
||||
return <ProjectsList projects={projects} />;
|
||||
}
|
||||
|
||||
// Loader function (_lib/server/projects-page.loader.ts)
|
||||
import 'server-only';
|
||||
|
||||
export async function loadProjectsPageData(
|
||||
client: SupabaseClient<Database>,
|
||||
slug: string,
|
||||
) {
|
||||
return Promise.all([
|
||||
loadProjects(client, slug),
|
||||
loadTeamWorkspace(slug),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadProjects(client: SupabaseClient<Database>, slug: string) {
|
||||
const { data, error } = await client.rpc('get_team_projects', {
|
||||
account_slug: slug,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
```
|
||||
|
||||
### Server Actions (Mutations Only)
|
||||
|
||||
**Pattern**: Use `enhanceAction` for create/update/delete operations with schema validation.
|
||||
|
||||
```typescript
|
||||
// server-actions.ts
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
export const createProject = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createProjectsService();
|
||||
|
||||
const response = await service.createProject(data);
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Authorization & RLS
|
||||
|
||||
- **Server Components**: RLS automatically enforces access control
|
||||
- **Server Actions**: RLS validates permissions on mutations
|
||||
- **No manual auth checks needed** when using standard Supabase client
|
||||
- **Admin client**: Only for bypassing RLS (rare cases, requires careful manual validation)
|
||||
|
||||
## File Organization Patterns
|
||||
|
||||
### Route Structure
|
||||
```
|
||||
apps/web/app/home/[account]/
|
||||
├── page.tsx # Team dashboard
|
||||
├── members/
|
||||
│ ├── page.tsx # Members listing
|
||||
│ └── _lib/server/ # Server-side utilities
|
||||
│ └── members-page.loader.ts
|
||||
├── projects/ # New feature example
|
||||
│ ├── page.tsx # Projects listing
|
||||
│ ├── [id]/ # Individual project
|
||||
│ │ └── page.tsx # Project detail page
|
||||
│ ├── _components/ # Feature-specific components
|
||||
│ │ ├── project-list.tsx
|
||||
│ │ └── create-project-form.tsx
|
||||
│ └── _lib/
|
||||
│ ├── server/ # Server-side logic
|
||||
│ │ ├── projects-page.loader.ts
|
||||
│ │ └── projects-server-actions.ts
|
||||
│ └── schemas/ # Zod validation schemas
|
||||
│ └── project.schema.ts
|
||||
└── _components/ # Shared team account components
|
||||
└── team-account-layout-page-header.tsx
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- **Pages**: `page.tsx` (Next.js convention)
|
||||
- **Loaders**: `{feature}-page.loader.ts`
|
||||
- **Actions**: `{feature}-server-actions.ts`
|
||||
- **Schemas**: `{feature}.schema.ts`
|
||||
- **Components**: `kebab-case.tsx`
|
||||
|
||||
## UI Components
|
||||
|
||||
UI Components are placed at `packages/ui`. Call MCP tool to list components to verify they exist.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After implementation:
|
||||
1. **Run `pnpm typecheck`** - Must pass without errors
|
||||
2. **Run `pnpm lint:fix`** - Auto-fix issues
|
||||
3. **Run `pnpm format:fix`** - Format code
|
||||
After implementation, always run:
|
||||
1. `pnpm typecheck`
|
||||
2. `pnpm lint:fix`
|
||||
3. `pnpm format:fix`
|
||||
4. Run code quality reviewer agent
|
||||
212
CLAUDE.md
212
CLAUDE.md
@@ -1,211 +1 @@
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
|
||||
## Core Technologies
|
||||
|
||||
- **Next.js 16** with App Router
|
||||
- **Supabase** for database, auth, and storage
|
||||
- **React 19**
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS 4**, Shadcn UI, Lucide React
|
||||
- **Turborepo**
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
- `apps/web` - Main Next.js SaaS application
|
||||
- `apps/web/supabase` - Supabase folder (migrations, schemas, tests)
|
||||
- `apps/e2e` - Playwright end-to-end tests
|
||||
- `packages/features/*` - Feature packages
|
||||
- `packages/` - Shared packages and utilities
|
||||
|
||||
## Multi-Tenant Architecture
|
||||
|
||||
**Personal Accounts**: Individual user accounts (auth.users.id = accounts.id)
|
||||
**Team Accounts**: Shared workspaces with members, roles, and permissions
|
||||
|
||||
Data associates with accounts via foreign keys for proper access control.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
pnpm dev # Start all apps
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
pnpm supabase:web:start # Start Supabase locally
|
||||
pnpm --filter web supabase migrations up # Apply new migrations
|
||||
pnpm supabase:web:reset # Reset with latest schema (clean rebuild)
|
||||
pnpm supabase:web:typegen # Generate TypeScript types
|
||||
pnpm --filter web supabase:db:diff # Create migration
|
||||
```
|
||||
|
||||
The typegen command must be run after applying migrations or resetting the database.
|
||||
|
||||
## Typescript
|
||||
|
||||
- Write clean, clear, well-designed, explicit TypeScript
|
||||
- Avoid obvious comments
|
||||
- Avoid unnecessary complexity or overly abstract code
|
||||
- Always use implicit type inference, unless impossible
|
||||
- You must avoid using `any`
|
||||
- Handle errors gracefully using try/catch and appropriate error types
|
||||
- Use service pattern for server-side APIs
|
||||
- Add `import 'server-only';` to code that is exclusively server-side
|
||||
- Never mix client and server imports from a file or a package
|
||||
- Extract self-contained classes/utilities (ex. algortihmic code) from classes that cross the network boundary
|
||||
|
||||
## React
|
||||
|
||||
- Encapsulate repeated blocks of code into reusable local components
|
||||
- Write small, composable, explicit, well-named components
|
||||
- Always use `react-hook-form` and `@kit/ui/form` for writing forms
|
||||
- Always use 'use client' directive for client components
|
||||
- Add `data-test` for E2E tests where appropriate
|
||||
- `useEffect` is a code smell and must be justified - avoid if possible
|
||||
- Do not write many (such as 4-5) separate `useState`, prefer single state object (unless required)
|
||||
- Prefer server-side data fetching using RSC
|
||||
- Display loading indicators (ex. with LoadingSpinner) component where appropriate
|
||||
|
||||
## Next.js
|
||||
|
||||
- Use `enhanceAction` for Server Actions
|
||||
- Use `use server` in server actions files
|
||||
- Use `enhanceRouteHandler` for API Routes
|
||||
- Export page components using the `withI18n` utility
|
||||
- Add well-written page metadata to pages
|
||||
- Redirect using `redirect` following a server action instead of using client-side router
|
||||
- Since `redirect` throws an error, handle `catch` block using `isRedirectError` from `next/dist/client/components/redirect-error` in client-side forms when calling the server action
|
||||
|
||||
|
||||
## Data Fetching Architecture
|
||||
|
||||
Makerkit uses a clear separation between data fetching and mutations:
|
||||
|
||||
### Server Components with Loaders (Reading Data)
|
||||
|
||||
**Pattern**: Use async server components that call loader functions for initial data fetching.
|
||||
|
||||
```typescript
|
||||
// Page component (apps/web/app/home/[account]/page.tsx)
|
||||
async function TeamAccountPage({ params }: Props) {
|
||||
const client = getSupabaseServerClient();
|
||||
const slug = (await params).account;
|
||||
|
||||
const [projects, workspace] = await loadProjectsPageData(client, slug);
|
||||
|
||||
return <ProjectsList projects={projects} />;
|
||||
}
|
||||
|
||||
// Loader function (_lib/server/projects-page.loader.ts)
|
||||
import 'server-only';
|
||||
|
||||
export async function loadProjectsPageData(
|
||||
client: SupabaseClient<Database>,
|
||||
slug: string,
|
||||
) {
|
||||
return Promise.all([
|
||||
loadProjects(client, slug),
|
||||
loadTeamWorkspace(slug),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadProjects(client: SupabaseClient<Database>, slug: string) {
|
||||
const { data, error } = await client.rpc('get_team_projects', {
|
||||
account_slug: slug,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
```
|
||||
|
||||
### Server Actions (Mutations Only)
|
||||
|
||||
**Pattern**: Use `enhanceAction` for create/update/delete operations with schema validation.
|
||||
|
||||
```typescript
|
||||
// server-actions.ts
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
export const createProject = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createProjectsService();
|
||||
|
||||
const response = await service.createProject(data);
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Authorization & RLS
|
||||
|
||||
- **Server Components**: RLS automatically enforces access control
|
||||
- **Server Actions**: RLS validates permissions on mutations
|
||||
- **No manual auth checks needed** when using standard Supabase client
|
||||
- **Admin client**: Only for bypassing RLS (rare cases, requires careful manual validation)
|
||||
|
||||
## File Organization Patterns
|
||||
|
||||
### Route Structure
|
||||
```
|
||||
apps/web/app/home/[account]/
|
||||
├── page.tsx # Team dashboard
|
||||
├── members/
|
||||
│ ├── page.tsx # Members listing
|
||||
│ └── _lib/server/ # Server-side utilities
|
||||
│ └── members-page.loader.ts
|
||||
├── projects/ # New feature example
|
||||
│ ├── page.tsx # Projects listing
|
||||
│ ├── [id]/ # Individual project
|
||||
│ │ └── page.tsx # Project detail page
|
||||
│ ├── _components/ # Feature-specific components
|
||||
│ │ ├── project-list.tsx
|
||||
│ │ └── create-project-form.tsx
|
||||
│ └── _lib/
|
||||
│ ├── server/ # Server-side logic
|
||||
│ │ ├── projects-page.loader.ts
|
||||
│ │ └── projects-server-actions.ts
|
||||
│ └── schemas/ # Zod validation schemas
|
||||
│ └── project.schema.ts
|
||||
└── _components/ # Shared team account components
|
||||
└── team-account-layout-page-header.tsx
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- **Pages**: `page.tsx` (Next.js convention)
|
||||
- **Loaders**: `{feature}-page.loader.ts`
|
||||
- **Actions**: `{feature}-server-actions.ts`
|
||||
- **Schemas**: `{feature}.schema.ts`
|
||||
- **Components**: `kebab-case.tsx`
|
||||
|
||||
## UI Components
|
||||
|
||||
UI Components are placed at `packages/ui`. Call MCP tool to list components to verify they exist.
|
||||
|
||||
## Delegate to Agents
|
||||
|
||||
Please use the Task tool to delegate suitable tasks to specialized sub-agents for best handling the task at hand.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After implementation:
|
||||
1. **Run `pnpm typecheck`** - Must pass without errors
|
||||
2. **Run `pnpm lint:fix`** - Auto-fix issues
|
||||
3. **Run `pnpm format:fix`** - Format code
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
# End-to-End Testing
|
||||
|
||||
## End-to-End Testing with Playwright
|
||||
## Skills
|
||||
|
||||
For E2E test implementation:
|
||||
- `/playwright-e2e` - Test patterns and Page Objects
|
||||
|
||||
## Running Tests
|
||||
|
||||
Running the tests for testing single file:
|
||||
```bash
|
||||
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||
```
|
||||
# Single file (preferred)
|
||||
pnpm --filter web-e2e exec playwright test <name> --workers=1
|
||||
|
||||
Example:
|
||||
```bash
|
||||
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||
```
|
||||
|
||||
This is useful for quickly testing a single file or a specific feature and should be your default choice.
|
||||
|
||||
Running all tests (rarely needed, only use if asked by the user):
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Page Object Pattern (Required)
|
||||
|
||||
Always use Page Objects for test organization and reusability:
|
||||
## Page Object Pattern (Required)
|
||||
|
||||
```typescript
|
||||
// Example: auth.po.ts
|
||||
export class AuthPageObject {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -35,91 +26,35 @@ export class AuthPageObject {
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reliability Patterns
|
||||
## Selectors
|
||||
|
||||
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
|
||||
Always use `data-test` attributes:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Reliable email/OTP operations
|
||||
await expect(async () => {
|
||||
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||
expect(otpCode).not.toBeNull();
|
||||
await this.enterOtpCode(otpCode);
|
||||
}).toPass();
|
||||
await this.page.click('[data-test="submit-button"]');
|
||||
await this.page.getByTestId('submit-button').click();
|
||||
```
|
||||
|
||||
// ✅ CORRECT - Network requests with validation
|
||||
## Reliability with `toPass()`
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
const response = await this.page.waitForResponse(resp =>
|
||||
resp.url().includes('auth/v1/user')
|
||||
const response = await this.page.waitForResponse(
|
||||
resp => resp.url().includes('auth/v1/user')
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
}).toPass();
|
||||
|
||||
// ✅ CORRECT - Complex operations with custom intervals
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||
}).toPass({
|
||||
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||
});
|
||||
```
|
||||
|
||||
### Test Data Management
|
||||
## Test Organization
|
||||
|
||||
**Email Testing**: Use `createRandomEmail()` for unique test emails:
|
||||
```typescript
|
||||
createRandomEmail() {
|
||||
const value = Math.random() * 10000000000000;
|
||||
return `${value.toFixed(0)}@makerkit.dev`;
|
||||
}
|
||||
```
|
||||
|
||||
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
|
||||
```typescript
|
||||
await auth.bootstrapUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
name: 'Test User'
|
||||
});
|
||||
tests/
|
||||
├── authentication/
|
||||
├── billing/
|
||||
├── *.po.ts # Page Objects
|
||||
└── utils/
|
||||
```
|
||||
|
||||
This method creates a user with an API call.
|
||||
|
||||
To sign in:
|
||||
|
||||
```tsx
|
||||
await auth.loginAsUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
```
|
||||
|
||||
### Test Selectors
|
||||
|
||||
**Always use `data-test` attributes** for reliable element selection:
|
||||
```typescript
|
||||
// ✅ CORRECT - Use data-test attributes
|
||||
await this.page.click('[data-test="submit-button"]');
|
||||
await this.page.fill('[data-test="email-input"]', email);
|
||||
|
||||
// ✅ OR
|
||||
await this.page.getByTestId('submit-button').click();
|
||||
|
||||
// ❌ AVOID - Fragile selectors
|
||||
await this.page.click('.btn-primary');
|
||||
await this.page.click('button:nth-child(2)');
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
|
||||
- **Page Objects**: `*.po.ts` files for reusable page interactions
|
||||
- **Setup files**: `auth.setup.ts` for global test setup
|
||||
- **Utility classes**: `/tests/utils/` for shared functionality
|
||||
@@ -1,125 +1 @@
|
||||
|
||||
## End-to-End Testing with Playwright
|
||||
|
||||
## Running Tests
|
||||
|
||||
Running the tests for testing single file:
|
||||
```bash
|
||||
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||
```
|
||||
|
||||
This is useful for quickly testing a single file or a specific feature and should be your default choice.
|
||||
|
||||
Running all tests (rarely needed, only use if asked by the user):
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Page Object Pattern (Required)
|
||||
|
||||
Always use Page Objects for test organization and reusability:
|
||||
|
||||
```typescript
|
||||
// Example: auth.po.ts
|
||||
export class AuthPageObject {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async signIn(params: { email: string; password: string }) {
|
||||
await this.page.fill('input[name="email"]', params.email);
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reliability Patterns
|
||||
|
||||
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Reliable email/OTP operations
|
||||
await expect(async () => {
|
||||
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||
expect(otpCode).not.toBeNull();
|
||||
await this.enterOtpCode(otpCode);
|
||||
}).toPass();
|
||||
|
||||
// ✅ CORRECT - Network requests with validation
|
||||
await expect(async () => {
|
||||
const response = await this.page.waitForResponse(resp =>
|
||||
resp.url().includes('auth/v1/user')
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
}).toPass();
|
||||
|
||||
// ✅ CORRECT - Complex operations with custom intervals
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||
}).toPass({
|
||||
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||
});
|
||||
```
|
||||
|
||||
### Test Data Management
|
||||
|
||||
**Email Testing**: Use `createRandomEmail()` for unique test emails:
|
||||
```typescript
|
||||
createRandomEmail() {
|
||||
const value = Math.random() * 10000000000000;
|
||||
return `${value.toFixed(0)}@makerkit.dev`;
|
||||
}
|
||||
```
|
||||
|
||||
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
|
||||
```typescript
|
||||
await auth.bootstrapUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
name: 'Test User'
|
||||
});
|
||||
```
|
||||
|
||||
This method creates a user with an API call.
|
||||
|
||||
To sign in:
|
||||
|
||||
```tsx
|
||||
await auth.loginAsUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
```
|
||||
|
||||
### Test Selectors
|
||||
|
||||
**Always use `data-test` attributes** for reliable element selection:
|
||||
```typescript
|
||||
// ✅ CORRECT - Use data-test attributes
|
||||
await this.page.click('[data-test="submit-button"]');
|
||||
await this.page.fill('[data-test="email-input"]', email);
|
||||
|
||||
// ✅ OR
|
||||
await this.page.getByTestId('submit-button').click();
|
||||
|
||||
// ❌ AVOID - Fragile selectors
|
||||
await this.page.click('.btn-primary');
|
||||
await this.page.click('button:nth-child(2)');
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
|
||||
- **Page Objects**: `*.po.ts` files for reusable page interactions
|
||||
- **Setup files**: `auth.setup.ts` for global test setup
|
||||
- **Utility classes**: `/tests/utils/` for shared functionality
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,329 +1,78 @@
|
||||
# Web Application Instructions
|
||||
# Web Application
|
||||
|
||||
This file contains instructions specific to the main Next.js web application.
|
||||
|
||||
## Application Structure
|
||||
|
||||
### Route Organization
|
||||
## Route Organization
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/ # Public pages (landing, blog, docs)
|
||||
├── (auth)/ # Authentication pages
|
||||
├── home/
|
||||
│ ├── (user)/ # Personal account context
|
||||
│ └── [account]/ # Team account context ([account] = team slug)
|
||||
├── admin/ # Super admin section
|
||||
├── (marketing)/ # Public pages
|
||||
├── (auth)/ # Authentication
|
||||
├── home/ # Authenticated routes
|
||||
│ ├── (user)/ # Personal account
|
||||
│ └── [account]/ # Team account (slug, not ID)
|
||||
├── admin/ # Super admin
|
||||
└── api/ # API routes
|
||||
```
|
||||
|
||||
Key Examples:
|
||||
## Component Organization
|
||||
|
||||
- Marketing layout: `app/(marketing)/layout.tsx`
|
||||
- Personal dashboard: `app/home/(user)/page.tsx`
|
||||
- Team workspace: `app/home/[account]/page.tsx`
|
||||
- Admin section: `app/admin/page.tsx`
|
||||
- Route-specific: `_components/`
|
||||
- Route utilities: `_lib/` (client), `_lib/server/` (server)
|
||||
|
||||
### Component Organization
|
||||
## Skills
|
||||
|
||||
- **Route-specific**: Use `_components/` directories
|
||||
- **Route utilities**: Use `_lib/` for client, `_lib/server/` for server-side
|
||||
- **Global components**: Root-level directories
|
||||
For specialized implementation:
|
||||
- `/feature-builder` - End-to-end feature implementation
|
||||
- `/server-action-builder` - Server actions
|
||||
- `/forms-builder` - Forms with validation
|
||||
- `/navigation-config` - Adding routes and menu items
|
||||
|
||||
Example:
|
||||
|
||||
- Team components: `app/home/[account]/_components/`
|
||||
- Team server utils: `app/home/[account]/_lib/server/`
|
||||
- Marketing components: `app/(marketing)/_components/`
|
||||
|
||||
The `[account]` parameter is the `accounts.slug` property, not the ID
|
||||
|
||||
## React Server Components - Async Pattern
|
||||
|
||||
**CRITICAL**: In Next.js 16, always await params directly in async server components:
|
||||
## Next.js 16 Params Pattern
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Don't use React.use() in async functions
|
||||
// CORRECT - await params directly
|
||||
async function Page({ params }: Props) {
|
||||
const { account } = use(params);
|
||||
}
|
||||
|
||||
// ✅ CORRECT - await params directly in Next.js 16
|
||||
async function Page({ params }: Props) {
|
||||
const { account } = await params; // ✅ Server component pattern
|
||||
}
|
||||
|
||||
// ✅ CORRECT - "use" in non-async functions in Next.js 16
|
||||
function Page({ params }: Props) {
|
||||
const { account } = use(params); // ✅ Server component pattern
|
||||
const { account } = await params;
|
||||
}
|
||||
```
|
||||
|
||||
## Data Fetching Strategy
|
||||
## Data Fetching
|
||||
|
||||
**Quick Decision Framework:**
|
||||
- **Server Components** (default): `getSupabaseServerClient()` - RLS enforced
|
||||
- **Client Components**: `useSupabase()` hook with React Query
|
||||
- **Admin Client**: Bypasses RLS - requires manual auth validation
|
||||
|
||||
- **Server Components**: Default choice for initial data loading
|
||||
- **Client Components**: For interactive features requiring hooks or real-time updates
|
||||
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
|
||||
|
||||
### Server Components (Preferred) ✅
|
||||
## Workspace Contexts
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
// Personal: app/home/(user)
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
// Team: app/home/[account]
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
```
|
||||
|
||||
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
|
||||
## Key Config Files
|
||||
|
||||
### Client Components (Interactive) 🖱️
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notes'],
|
||||
queryFn: () => supabase.from('notes').select('*')
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization - Parallel Data Fetching 🚀
|
||||
|
||||
**Sequential (Slow) Pattern ❌**
|
||||
|
||||
```typescript
|
||||
async function SlowDashboard() {
|
||||
const userData = await loadUserData();
|
||||
const notifications = await loadNotifications();
|
||||
const metrics = await loadMetrics();
|
||||
// Total time: sum of all requests
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel (Optimized) Pattern ✅**
|
||||
|
||||
```typescript
|
||||
async function FastDashboard() {
|
||||
// Execute all requests simultaneously
|
||||
const [userData, notifications, metrics] = await Promise.all([
|
||||
loadUserData(),
|
||||
loadNotifications(),
|
||||
loadMetrics()
|
||||
]);
|
||||
// Total time: longest single request
|
||||
|
||||
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
|
||||
|
||||
## Authorization Patterns - Critical Understanding 🔐
|
||||
|
||||
### RLS-Protected Data Fetching (Standard) ✅
|
||||
|
||||
```typescript
|
||||
async function getUserNotes(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// RLS automatically ensures user can only access their own notes
|
||||
// NO additional authorization checks needed!
|
||||
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
|
||||
|
||||
```typescript
|
||||
async function adminGetUserNotes(userId: string) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Additional validation: ensure current admin isn't targeting themselves
|
||||
if (currentUser.id === userId) {
|
||||
throw new Error('Cannot perform admin action on own account');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
|
||||
| Purpose | Location |
|
||||
|---------|----------|
|
||||
| Feature flags | `config/feature-flags.config.ts` |
|
||||
| Paths | `config/paths.config.ts` |
|
||||
| Personal nav | `config/personal-account-navigation.config.tsx` |
|
||||
| Team nav | `config/team-account-navigation.config.tsx` |
|
||||
| i18n | `lib/i18n/i18n.settings.ts` |
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `@kit/ui/trans`:
|
||||
Always use `Trans` component:
|
||||
|
||||
```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 }} />
|
||||
```
|
||||
|
||||
### Adding New Languages
|
||||
## Security
|
||||
|
||||
1. Add language code to `lib/i18n/i18n.settings.ts`
|
||||
2. Create translation files in `public/locales/[new-language]/`
|
||||
3. Copy structure from English files
|
||||
|
||||
### Adding new namespaces
|
||||
|
||||
1. Translation files: `public/locales/<locale>/<namespace>.json`
|
||||
2. Add namespace to `defaultI18nNamespaces` in `apps/web/lib/i18n/i18n.settings.ts`
|
||||
|
||||
## Workspace Contexts 🏢
|
||||
|
||||
### Personal Account Context (`app/home/(user)`)
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
// Personal account data
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context (`app/home/[account]`)
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
// Team account data with permissions
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- **Feature flags**: `config/feature-flags.config.ts`
|
||||
- **i18n settings**: `lib/i18n/i18n.settings.ts`
|
||||
- **Supabase config**: `supabase/config.toml`
|
||||
- **Middleware**: `middleware.ts`
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated, user available if auth: true
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Navigation Menu Configuration 🗺️
|
||||
|
||||
### Adding Sidebar Menu Items
|
||||
|
||||
**Config Files:**
|
||||
|
||||
- Personal: `config/personal-account-navigation.config.tsx`
|
||||
- Team: `config/team-account-navigation.config.tsx`
|
||||
|
||||
**Add to Personal Navigation:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
label: 'common:routes.yourFeature',
|
||||
path: pathsConfig.app.yourFeaturePath,
|
||||
Icon: <YourIcon className="w-4" />,
|
||||
end: true,
|
||||
},
|
||||
```
|
||||
|
||||
**Add to Team Navigation:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
label: 'common:routes.yourTeamFeature',
|
||||
path: createPath(pathsConfig.app.yourTeamFeaturePath, account),
|
||||
Icon: <YourIcon className="w-4" />,
|
||||
},
|
||||
```
|
||||
|
||||
**Add Paths:**
|
||||
|
||||
```typescript
|
||||
// config/paths.config.ts
|
||||
app: {
|
||||
yourFeaturePath: '/home/your-feature',
|
||||
yourTeamFeaturePath: '/home/[account]/your-feature',
|
||||
}
|
||||
```
|
||||
|
||||
**Add Translations:**
|
||||
|
||||
```json
|
||||
// public/locales/en/common.json
|
||||
"routes": {
|
||||
"yourFeature": "Your Feature"
|
||||
}
|
||||
```
|
||||
|
||||
## Security Guidelines 🛡️
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Authentication already enforced by middleware
|
||||
- Authorization handled by RLS at database level (in most cases)
|
||||
- Avoid defensive code - use RLS instead
|
||||
- When using the Supabase admin client, must enforce both authentication and authorization
|
||||
|
||||
### Passing data to the client
|
||||
|
||||
- **Never pass sensitive data** to Client Components
|
||||
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
|
||||
- Always validate user input
|
||||
- Authentication enforced by middleware
|
||||
- Authorization handled by RLS
|
||||
- Never pass sensitive data to Client Components
|
||||
- Never expose server env vars to client
|
||||
|
||||
@@ -1,329 +1 @@
|
||||
# Web Application Instructions
|
||||
|
||||
This file contains instructions specific to the main Next.js web application.
|
||||
|
||||
## Application Structure
|
||||
|
||||
### Route Organization
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/ # Public pages (landing, blog, docs)
|
||||
├── (auth)/ # Authentication pages
|
||||
├── home/
|
||||
│ ├── (user)/ # Personal account context
|
||||
│ └── [account]/ # Team account context ([account] = team slug)
|
||||
├── admin/ # Super admin section
|
||||
└── api/ # API routes
|
||||
```
|
||||
|
||||
Key Examples:
|
||||
|
||||
- Marketing layout: `app/(marketing)/layout.tsx`
|
||||
- Personal dashboard: `app/home/(user)/page.tsx`
|
||||
- Team workspace: `app/home/[account]/page.tsx`
|
||||
- Admin section: `app/admin/page.tsx`
|
||||
|
||||
### Component Organization
|
||||
|
||||
- **Route-specific**: Use `_components/` directories
|
||||
- **Route utilities**: Use `_lib/` for client, `_lib/server/` for server-side
|
||||
- **Global components**: Root-level directories
|
||||
|
||||
Example:
|
||||
|
||||
- Team components: `app/home/[account]/_components/`
|
||||
- Team server utils: `app/home/[account]/_lib/server/`
|
||||
- Marketing components: `app/(marketing)/_components/`
|
||||
|
||||
The `[account]` parameter is the `accounts.slug` property, not the ID
|
||||
|
||||
## React Server Components - Async Pattern
|
||||
|
||||
**CRITICAL**: In Next.js 16, always await params directly in async server components:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Don't use React.use() in async functions
|
||||
async function Page({ params }: Props) {
|
||||
const { account } = use(params);
|
||||
}
|
||||
|
||||
// ✅ CORRECT - await params directly in Next.js 16
|
||||
async function Page({ params }: Props) {
|
||||
const { account } = await params; // ✅ Server component pattern
|
||||
}
|
||||
|
||||
// ✅ CORRECT - "use" in non-async functions in Next.js 16
|
||||
function Page({ params }: Props) {
|
||||
const { account } = use(params); // ✅ Server component pattern
|
||||
}
|
||||
```
|
||||
|
||||
## Data Fetching Strategy
|
||||
|
||||
**Quick Decision Framework:**
|
||||
|
||||
- **Server Components**: Default choice for initial data loading
|
||||
- **Client Components**: For interactive features requiring hooks or real-time updates
|
||||
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
|
||||
|
||||
### 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} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
|
||||
|
||||
### Client Components (Interactive) 🖱️
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notes'],
|
||||
queryFn: () => supabase.from('notes').select('*')
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization - Parallel Data Fetching 🚀
|
||||
|
||||
**Sequential (Slow) Pattern ❌**
|
||||
|
||||
```typescript
|
||||
async function SlowDashboard() {
|
||||
const userData = await loadUserData();
|
||||
const notifications = await loadNotifications();
|
||||
const metrics = await loadMetrics();
|
||||
// Total time: sum of all requests
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel (Optimized) Pattern ✅**
|
||||
|
||||
```typescript
|
||||
async function FastDashboard() {
|
||||
// Execute all requests simultaneously
|
||||
const [userData, notifications, metrics] = await Promise.all([
|
||||
loadUserData(),
|
||||
loadNotifications(),
|
||||
loadMetrics()
|
||||
]);
|
||||
// Total time: longest single request
|
||||
|
||||
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
|
||||
|
||||
## Authorization Patterns - Critical Understanding 🔐
|
||||
|
||||
### RLS-Protected Data Fetching (Standard) ✅
|
||||
|
||||
```typescript
|
||||
async function getUserNotes(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// RLS automatically ensures user can only access their own notes
|
||||
// NO additional authorization checks needed!
|
||||
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
|
||||
|
||||
```typescript
|
||||
async function adminGetUserNotes(userId: string) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Additional validation: ensure current admin isn't targeting themselves
|
||||
if (currentUser.id === userId) {
|
||||
throw new Error('Cannot perform admin action on own account');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `@kit/ui/trans`:
|
||||
|
||||
```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" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
1. Add language code to `lib/i18n/i18n.settings.ts`
|
||||
2. Create translation files in `public/locales/[new-language]/`
|
||||
3. Copy structure from English files
|
||||
|
||||
### Adding new namespaces
|
||||
|
||||
1. Translation files: `public/locales/<locale>/<namespace>.json`
|
||||
2. Add namespace to `defaultI18nNamespaces` in `apps/web/lib/i18n/i18n.settings.ts`
|
||||
|
||||
## Workspace Contexts 🏢
|
||||
|
||||
### Personal Account Context (`app/home/(user)`)
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
// Personal account data
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context (`app/home/[account]`)
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
// Team account data with permissions
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- **Feature flags**: `config/feature-flags.config.ts`
|
||||
- **i18n settings**: `lib/i18n/i18n.settings.ts`
|
||||
- **Supabase config**: `supabase/config.toml`
|
||||
- **Middleware**: `middleware.ts`
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated, user available if auth: true
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Navigation Menu Configuration 🗺️
|
||||
|
||||
### Adding Sidebar Menu Items
|
||||
|
||||
**Config Files:**
|
||||
|
||||
- Personal: `config/personal-account-navigation.config.tsx`
|
||||
- Team: `config/team-account-navigation.config.tsx`
|
||||
|
||||
**Add to Personal Navigation:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
label: 'common:routes.yourFeature',
|
||||
path: pathsConfig.app.yourFeaturePath,
|
||||
Icon: <YourIcon className="w-4" />,
|
||||
end: true,
|
||||
},
|
||||
```
|
||||
|
||||
**Add to Team Navigation:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
label: 'common:routes.yourTeamFeature',
|
||||
path: createPath(pathsConfig.app.yourTeamFeaturePath, account),
|
||||
Icon: <YourIcon className="w-4" />,
|
||||
},
|
||||
```
|
||||
|
||||
**Add Paths:**
|
||||
|
||||
```typescript
|
||||
// config/paths.config.ts
|
||||
app: {
|
||||
yourFeaturePath: '/home/your-feature',
|
||||
yourTeamFeaturePath: '/home/[account]/your-feature',
|
||||
}
|
||||
```
|
||||
|
||||
**Add Translations:**
|
||||
|
||||
```json
|
||||
// public/locales/en/common.json
|
||||
"routes": {
|
||||
"yourFeature": "Your Feature"
|
||||
}
|
||||
```
|
||||
|
||||
## Security Guidelines 🛡️
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Authentication already enforced by middleware
|
||||
- Authorization handled by RLS at database level (in most cases)
|
||||
- Avoid defensive code - use RLS instead
|
||||
- When using the Supabase admin client, must enforce both authentication and authorization
|
||||
|
||||
### Passing data to the client
|
||||
|
||||
- **Never pass sensitive data** to Client Components
|
||||
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
|
||||
- Always validate user input
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,119 +1,55 @@
|
||||
# Super Admin
|
||||
|
||||
This file provides specific guidance for AI agents working in the super admin section of the application.
|
||||
## Critical Security Rules
|
||||
|
||||
## Core Admin Principles
|
||||
- **ALWAYS** use `AdminGuard` to protect pages
|
||||
- **ALWAYS** validate admin status before operations
|
||||
- **NEVER** bypass authentication or authorization
|
||||
- **ALWAYS** audit admin operations with logging
|
||||
|
||||
### Security-First Development
|
||||
## Page Structure
|
||||
|
||||
- **ALWAYS** use `AdminGuard` to protect admin pages
|
||||
- **NEVER** bypass authentication or authorization checks
|
||||
- **CRITICAL**: Use admin Supabase client with manual authorization validation
|
||||
- Validate permissions for every admin operation
|
||||
```typescript
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
### Admin Client Usage Pattern
|
||||
async function AdminPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Admin" />
|
||||
<PageBody>{/* Content */}</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AdminPage);
|
||||
```
|
||||
|
||||
## Admin Client Usage
|
||||
|
||||
```typescript
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function adminOperation() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Always validate admin status first
|
||||
const currentUser = await getCurrentUser();
|
||||
// CRITICAL: Validate first - admin client bypasses RLS
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient.from('accounts').select('*');
|
||||
return data;
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
// Safe to proceed
|
||||
}
|
||||
```
|
||||
|
||||
## Page Structure Patterns
|
||||
|
||||
### Standard Admin Page Template
|
||||
## Audit Logging
|
||||
|
||||
```typescript
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
|
||||
async function AdminPageComponent() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader description={<AppBreadcrumbs />}>
|
||||
{/* Page actions go here */}
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
{/* Main content */}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ALWAYS wrap with AdminGuard
|
||||
export default AdminGuard(AdminPageComponent);
|
||||
```
|
||||
|
||||
### Async Server Component Pattern
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Next.js 16 pattern
|
||||
async function AdminPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params; // ✅ await params directly
|
||||
|
||||
// Fetch admin data
|
||||
const data = await loadAdminData(id);
|
||||
|
||||
return <AdminContent data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
### Critical Security Rules
|
||||
|
||||
1. **NEVER** expose admin functionality to non-admin users
|
||||
2. **ALWAYS** validate admin status before operations
|
||||
3. **NEVER** trust client-side admin checks alone
|
||||
4. **ALWAYS** use server-side validation for admin actions
|
||||
5. **NEVER** log sensitive admin data
|
||||
6. **ALWAYS** audit admin operations
|
||||
|
||||
### Admin Action Auditing
|
||||
|
||||
```typescript
|
||||
async function auditedAdminAction(action: string, data: unknown) {
|
||||
const logger = await getLogger();
|
||||
|
||||
await logger.info(
|
||||
{
|
||||
const logger = await getLogger();
|
||||
logger.info({
|
||||
name: 'admin-audit',
|
||||
action,
|
||||
action: 'delete-user',
|
||||
adminId: currentUser.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
// Log only non-sensitive fields
|
||||
operation: action,
|
||||
targetId: data.id,
|
||||
},
|
||||
},
|
||||
'Admin action performed',
|
||||
);
|
||||
}
|
||||
targetId: userId,
|
||||
}, 'Admin action performed');
|
||||
```
|
||||
|
||||
## Common Patterns to Follow
|
||||
|
||||
1. **Always wrap admin pages with `AdminGuard`**
|
||||
2. **Use admin client only when RLS bypass is required**
|
||||
3. **Implement proper error boundaries for admin components**
|
||||
4. **Add comprehensive logging for admin operations**
|
||||
5. **Use TypeScript strictly for admin interfaces**
|
||||
6. **Follow the established admin component naming conventions**
|
||||
7. **Implement proper loading states for admin operations**
|
||||
8. **Add proper metadata to admin pages**
|
||||
|
||||
@@ -1,119 +1 @@
|
||||
# Super Admin
|
||||
|
||||
This file provides specific guidance for AI agents working in the super admin section of the application.
|
||||
|
||||
## Core Admin Principles
|
||||
|
||||
### Security-First Development
|
||||
|
||||
- **ALWAYS** use `AdminGuard` to protect admin pages
|
||||
- **NEVER** bypass authentication or authorization checks
|
||||
- **CRITICAL**: Use admin Supabase client with manual authorization validation
|
||||
- Validate permissions for every admin operation
|
||||
|
||||
### Admin Client Usage Pattern
|
||||
|
||||
```typescript
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function adminOperation() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Always validate admin status first
|
||||
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('accounts').select('*');
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
## Page Structure Patterns
|
||||
|
||||
### Standard Admin Page Template
|
||||
|
||||
```typescript
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
|
||||
async function AdminPageComponent() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader description={<AppBreadcrumbs />}>
|
||||
{/* Page actions go here */}
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
{/* Main content */}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ALWAYS wrap with AdminGuard
|
||||
export default AdminGuard(AdminPageComponent);
|
||||
```
|
||||
|
||||
### Async Server Component Pattern
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Next.js 16 pattern
|
||||
async function AdminPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params; // ✅ await params directly
|
||||
|
||||
// Fetch admin data
|
||||
const data = await loadAdminData(id);
|
||||
|
||||
return <AdminContent data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
### Critical Security Rules
|
||||
|
||||
1. **NEVER** expose admin functionality to non-admin users
|
||||
2. **ALWAYS** validate admin status before operations
|
||||
3. **NEVER** trust client-side admin checks alone
|
||||
4. **ALWAYS** use server-side validation for admin actions
|
||||
5. **NEVER** log sensitive admin data
|
||||
6. **ALWAYS** audit admin operations
|
||||
|
||||
### Admin Action Auditing
|
||||
|
||||
```typescript
|
||||
async function auditedAdminAction(action: string, data: unknown) {
|
||||
const logger = await getLogger();
|
||||
|
||||
await logger.info(
|
||||
{
|
||||
name: 'admin-audit',
|
||||
action,
|
||||
adminId: currentUser.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
// Log only non-sensitive fields
|
||||
operation: action,
|
||||
targetId: data.id,
|
||||
},
|
||||
},
|
||||
'Admin action performed',
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns to Follow
|
||||
|
||||
1. **Always wrap admin pages with `AdminGuard`**
|
||||
2. **Use admin client only when RLS bypass is required**
|
||||
3. **Implement proper error boundaries for admin components**
|
||||
4. **Add comprehensive logging for admin operations**
|
||||
5. **Use TypeScript strictly for admin interfaces**
|
||||
6. **Follow the established admin component naming conventions**
|
||||
7. **Implement proper loading states for admin operations**
|
||||
8. **Add proper metadata to admin pages**
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,265 +1,73 @@
|
||||
# Supabase Database Schema Management
|
||||
|
||||
This file contains guidance for working with database schemas, migrations, and Supabase development workflows.
|
||||
# Supabase Database
|
||||
|
||||
## Schema Organization
|
||||
|
||||
Schemas are organized in numbered files in the `schemas/` directory. Numbers are used to sort dependencies.
|
||||
Schemas in `schemas/` directory with numbered prefixes for dependency ordering.
|
||||
|
||||
Migrations are generated from schemas. If creating a new schema, the migration can be created using the exact same content.
|
||||
## Skills
|
||||
|
||||
If modifying an existing migration, use the `diff` command:
|
||||
For database implementation:
|
||||
- `/postgres-expert` - Schema design, RLS, migrations, testing
|
||||
|
||||
### 1. Creating new entities
|
||||
## Migration Workflow
|
||||
|
||||
When creating new entities (such as creating a new tabble), we can create a migration as is, just copying its content.
|
||||
### New Entities
|
||||
|
||||
```bash
|
||||
# Create new schema file
|
||||
touch apps/web/supabase/schemas/15-my-new-feature.sql
|
||||
# Create schema file
|
||||
touch schemas/20-feature.sql
|
||||
|
||||
# Create Migration
|
||||
pnpm --filter web run supabase migrations new my-new-feature
|
||||
# Create migration
|
||||
pnpm --filter web run supabase migrations new feature_name
|
||||
|
||||
# Copy content to migration
|
||||
cp apps/web/supabase/schemas/15-my-new-feature.sql apps/web/supabase/migrations/$(ls -t apps/web/supabase/migrations/ | head -n1)
|
||||
|
||||
# Apply migration
|
||||
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||
|
||||
# Generate TypeScript types
|
||||
# Copy content, apply, generate types
|
||||
pnpm --filter web supabase migrations up
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
### 2. Modifying existing entities
|
||||
|
||||
When modifying existing entities (such ass adding a field to an existing table), we can use the `diff` command to generate a migration following the changes:
|
||||
### Modify Existing
|
||||
|
||||
```bash
|
||||
# Edit schema file (e.g., schemas/03-accounts.sql)
|
||||
# Make your changes...
|
||||
# Edit schema, generate diff
|
||||
pnpm --filter web run supabase:db:diff -f update_feature
|
||||
|
||||
# Create migration for changes
|
||||
pnpm --filter web run supabase:db:diff -f update-accounts
|
||||
|
||||
# Apply and test
|
||||
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||
|
||||
# After resetting
|
||||
# Apply and regenerate
|
||||
pnpm --filter web supabase migrations up
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
## Security First Patterns
|
||||
## Security Rules
|
||||
|
||||
## Add permissions (if any)
|
||||
- **ALWAYS enable RLS** on new tables
|
||||
- **NEVER use SECURITY DEFINER** without explicit access controls
|
||||
- Use existing helper functions (see `/postgres-expert` skill)
|
||||
|
||||
## Table Template
|
||||
|
||||
```sql
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### Table Creation with RLS
|
||||
|
||||
```sql
|
||||
-- Create table
|
||||
create table if not exists public.notes (
|
||||
create table if not exists public.feature (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
-- ...
|
||||
created_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- CRITICAL: Always enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
alter table "public"."feature" enable row level security;
|
||||
revoke all on public.feature from authenticated, service_role;
|
||||
grant select, insert, update, delete on table public.feature to authenticated;
|
||||
|
||||
-- Revoke default permissions
|
||||
revoke all on public.notes from authenticated, service_role;
|
||||
|
||||
-- Grant specific permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Add RLS policies
|
||||
create policy "notes_read" on public.notes for select
|
||||
-- Use helper functions for policies
|
||||
create policy "feature_read" on public.feature 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 (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_update" on public.notes for update
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
)
|
||||
with check (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_delete" on public.notes for delete
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Storage Bucket Policies
|
||||
|
||||
```sql
|
||||
-- Create storage bucket
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('documents', 'documents', false);
|
||||
|
||||
-- RLS policy for storage
|
||||
create policy documents_policy on storage.objects for all using (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
-- File belongs to user's account
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
-- User has access to the account
|
||||
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
public.has_permission(
|
||||
auth.uid(),
|
||||
kit.get_storage_filename_as_uuid(name),
|
||||
'files.upload'::app_permissions
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Function Creation Patterns
|
||||
|
||||
### Safe Security Definer Functions
|
||||
|
||||
```sql
|
||||
-- NEVER create security definer functions without explicit access controls
|
||||
create or replace function public.create_team_account(account_name text)
|
||||
returns public.accounts
|
||||
language plpgsql
|
||||
security definer -- Elevated privileges
|
||||
set search_path = '' -- Prevent SQL injection
|
||||
as $$
|
||||
declare
|
||||
new_account public.accounts;
|
||||
begin
|
||||
-- CRITICAL: Validate permissions first
|
||||
if not public.is_set('enable_team_accounts') then
|
||||
raise exception 'Team accounts are not enabled';
|
||||
end if;
|
||||
|
||||
-- Additional validation can go here
|
||||
if length(account_name) < 3 then
|
||||
raise exception 'Account name must be at least 3 characters';
|
||||
end if;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
insert into public.accounts (name, is_personal_account)
|
||||
values (account_name, false)
|
||||
returning * into new_account;
|
||||
|
||||
return new_account;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Grant to authenticated users only
|
||||
grant execute on function public.create_team_account(text) to authenticated;
|
||||
```
|
||||
|
||||
### Security Invoker Functions (Safer)
|
||||
|
||||
```sql
|
||||
-- Preferred: Functions that inherit RLS policies
|
||||
create or replace function public.get_account_notes(target_account_id uuid)
|
||||
returns setof public.notes
|
||||
language plpgsql
|
||||
security invoker -- Inherits caller's permissions (RLS applies)
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
-- RLS policies will automatically restrict results
|
||||
return query
|
||||
select * from public.notes
|
||||
where account_id = target_account_id
|
||||
order by created_at desc;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.get_account_notes(uuid) to authenticated;
|
||||
```
|
||||
|
||||
### Safe Column Additions
|
||||
|
||||
```sql
|
||||
-- Safe: Add nullable columns
|
||||
alter table public.accounts
|
||||
add column if not exists description text;
|
||||
|
||||
-- Safe: Add columns with defaults
|
||||
alter table public.accounts
|
||||
add column if not exists is_verified boolean default false not null;
|
||||
|
||||
-- Unsafe: Adding non-null columns without defaults
|
||||
-- alter table public.accounts add column required_field text not null; -- DON'T DO THIS
|
||||
```
|
||||
|
||||
### Index Management
|
||||
|
||||
```sql
|
||||
-- Create indexes concurrently for large tables
|
||||
create index concurrently if not exists ix_accounts_created_at
|
||||
on public.accounts (created_at desc);
|
||||
|
||||
-- Drop unused indexes
|
||||
drop index if exists ix_old_unused_index;
|
||||
```
|
||||
|
||||
## Testing Database Changes
|
||||
|
||||
### Local Testing
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Test with fresh database
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Test your changes
|
||||
pnpm run supabase:web:test
|
||||
```
|
||||
|
||||
## Common Schema Patterns
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Add triggers if the properties exist and are appropriate:
|
||||
|
||||
- `public.trigger_set_timestamps()` - for tables with `created_at` and `updated_at`
|
||||
columns
|
||||
- `public.trigger_set_user_tracking()` - for tables with `created_by` and `updated_by`
|
||||
columns
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# View migration status
|
||||
pnpm --filter web supabase migrations list
|
||||
|
||||
# Reset database completely
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate migration from schema diff
|
||||
pnpm --filter web run supabase:db:diff -f migration-name
|
||||
|
||||
## Apply created migration
|
||||
pnpm --filter web supabase migrations up
|
||||
|
||||
# Apply specific migration
|
||||
pnpm --filter web supabase migrations up --include-schemas public
|
||||
pnpm supabase:web:reset # Reset database
|
||||
pnpm supabase:web:typegen # Generate TypeScript types
|
||||
pnpm --filter web supabase migrations list # View migrations
|
||||
```
|
||||
|
||||
@@ -1,265 +1 @@
|
||||
# Supabase Database Schema Management
|
||||
|
||||
This file contains guidance for working with database schemas, migrations, and Supabase development workflows.
|
||||
|
||||
## Schema Organization
|
||||
|
||||
Schemas are organized in numbered files in the `schemas/` directory. Numbers are used to sort dependencies.
|
||||
|
||||
Migrations are generated from schemas. If creating a new schema, the migration can be created using the exact same content.
|
||||
|
||||
If modifying an existing migration, use the `diff` command:
|
||||
|
||||
### 1. Creating new entities
|
||||
|
||||
When creating new entities (such as creating a new tabble), we can create a migration as is, just copying its content.
|
||||
|
||||
```bash
|
||||
# Create new schema file
|
||||
touch apps/web/supabase/schemas/15-my-new-feature.sql
|
||||
|
||||
# Create Migration
|
||||
pnpm --filter web supabase migrations new my-new-feature
|
||||
|
||||
# Copy content to migration
|
||||
cp apps/web/supabase/schemas/15-my-new-feature.sql apps/web/supabase/migrations/$(ls -t apps/web/supabase/migrations/ | head -n1)
|
||||
|
||||
# Apply migration
|
||||
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||
|
||||
# Generate TypeScript types
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
### 2. Modifying existing entities
|
||||
|
||||
When modifying existing entities (such ass adding a field to an existing table), we can use the `diff` command to generate a migration following the changes:
|
||||
|
||||
```bash
|
||||
# Edit schema file (e.g., schemas/03-accounts.sql)
|
||||
# Make your changes...
|
||||
|
||||
# Create migration for changes
|
||||
pnpm --filter web run supabase:db:diff -f update-accounts
|
||||
|
||||
# Apply and test
|
||||
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||
|
||||
# After resetting
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
## Security First Patterns
|
||||
|
||||
## Add permissions (if any)
|
||||
|
||||
```sql
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### Table Creation 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,
|
||||
-- ...
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- CRITICAL: Always enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
|
||||
-- Revoke default permissions
|
||||
revoke all on public.notes from authenticated, service_role;
|
||||
|
||||
-- Grant specific permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Add 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 (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_update" on public.notes for update
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
)
|
||||
with check (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_delete" on public.notes for delete
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Storage Bucket Policies
|
||||
|
||||
```sql
|
||||
-- Create storage bucket
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('documents', 'documents', false);
|
||||
|
||||
-- RLS policy for storage
|
||||
create policy documents_policy on storage.objects for all using (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
-- File belongs to user's account
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
-- User has access to the account
|
||||
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
public.has_permission(
|
||||
auth.uid(),
|
||||
kit.get_storage_filename_as_uuid(name),
|
||||
'files.upload'::app_permissions
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Function Creation Patterns
|
||||
|
||||
### Safe Security Definer Functions
|
||||
|
||||
```sql
|
||||
-- NEVER create security definer functions without explicit access controls
|
||||
create or replace function public.create_team_account(account_name text)
|
||||
returns public.accounts
|
||||
language plpgsql
|
||||
security definer -- Elevated privileges
|
||||
set search_path = '' -- Prevent SQL injection
|
||||
as $$
|
||||
declare
|
||||
new_account public.accounts;
|
||||
begin
|
||||
-- CRITICAL: Validate permissions first
|
||||
if not public.is_set('enable_team_accounts') then
|
||||
raise exception 'Team accounts are not enabled';
|
||||
end if;
|
||||
|
||||
-- Additional validation can go here
|
||||
if length(account_name) < 3 then
|
||||
raise exception 'Account name must be at least 3 characters';
|
||||
end if;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
insert into public.accounts (name, is_personal_account)
|
||||
values (account_name, false)
|
||||
returning * into new_account;
|
||||
|
||||
return new_account;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Grant to authenticated users only
|
||||
grant execute on function public.create_team_account(text) to authenticated;
|
||||
```
|
||||
|
||||
### Security Invoker Functions (Safer)
|
||||
|
||||
```sql
|
||||
-- Preferred: Functions that inherit RLS policies
|
||||
create or replace function public.get_account_notes(target_account_id uuid)
|
||||
returns setof public.notes
|
||||
language plpgsql
|
||||
security invoker -- Inherits caller's permissions (RLS applies)
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
-- RLS policies will automatically restrict results
|
||||
return query
|
||||
select * from public.notes
|
||||
where account_id = target_account_id
|
||||
order by created_at desc;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.get_account_notes(uuid) to authenticated;
|
||||
```
|
||||
|
||||
### Safe Column Additions
|
||||
|
||||
```sql
|
||||
-- Safe: Add nullable columns
|
||||
alter table public.accounts
|
||||
add column if not exists description text;
|
||||
|
||||
-- Safe: Add columns with defaults
|
||||
alter table public.accounts
|
||||
add column if not exists is_verified boolean default false not null;
|
||||
|
||||
-- Unsafe: Adding non-null columns without defaults
|
||||
-- alter table public.accounts add column required_field text not null; -- DON'T DO THIS
|
||||
```
|
||||
|
||||
### Index Management
|
||||
|
||||
```sql
|
||||
-- Create indexes concurrently for large tables
|
||||
create index concurrently if not exists ix_accounts_created_at
|
||||
on public.accounts (created_at desc);
|
||||
|
||||
-- Drop unused indexes
|
||||
drop index if exists ix_old_unused_index;
|
||||
```
|
||||
|
||||
## Testing Database Changes
|
||||
|
||||
### Local Testing
|
||||
|
||||
```bash
|
||||
# Test with fresh database
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Test your changes
|
||||
pnpm run supabase:web:test
|
||||
```
|
||||
|
||||
## Common Schema Patterns
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Add triggers if the properties exist and are appropriate:
|
||||
|
||||
- `public.trigger_set_timestamps()` - for tables with `created_at` and `updated_at`
|
||||
columns
|
||||
- `public.trigger_set_user_tracking()` - for tables with `created_by` and `updated_by`
|
||||
columns
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# View migration status
|
||||
pnpm --filter web supabase migrations list
|
||||
|
||||
# Reset database completely
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate migration from schema diff
|
||||
pnpm --filter web run supabase:db:diff -f migration-name
|
||||
|
||||
## Apply created migration
|
||||
pnpm --filter web supabase migrations up
|
||||
|
||||
# Apply specific migration
|
||||
pnpm --filter web supabase migrations up --include-schemas public
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-supabase-saas-kit-turbo",
|
||||
"version": "2.23.5",
|
||||
"version": "2.23.7",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '/',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' : ''}`;
|
||||
}
|
||||
|
||||
@@ -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' : ''}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user