@mars-stack/cli 0.2.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# Skill: Configure Multi-Tenancy
|
|
2
|
+
|
|
3
|
+
Add teams and organizations to a MARS application, enabling multi-tenant data isolation, role-based org access, and member invitation flows.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add organizations, teams, workspaces, multi-tenancy, or team-based access control (e.g., "add teams", "add organizations", "support multiple workspaces").
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Auth feature is already working (users can sign up and log in).
|
|
12
|
+
- Read `src/config/app.config.ts` to check current feature flags.
|
|
13
|
+
- Decide on scoping strategy: **org-level** (simpler — all members share one flat namespace) vs. **team-level** (nested teams within an org for department-style isolation). Start with org-level unless the user explicitly needs teams.
|
|
14
|
+
|
|
15
|
+
## Step 1: Prisma Schema
|
|
16
|
+
|
|
17
|
+
Create `prisma/schema/organization.prisma`:
|
|
18
|
+
|
|
19
|
+
```prisma
|
|
20
|
+
model Organization {
|
|
21
|
+
id String @id @default(cuid())
|
|
22
|
+
name String
|
|
23
|
+
slug String @unique
|
|
24
|
+
plan String @default("free")
|
|
25
|
+
createdAt DateTime @default(now())
|
|
26
|
+
updatedAt DateTime @updatedAt
|
|
27
|
+
|
|
28
|
+
memberships Membership[]
|
|
29
|
+
invitations Invitation[]
|
|
30
|
+
teams Team[]
|
|
31
|
+
|
|
32
|
+
@@index([slug])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
model Team {
|
|
36
|
+
id String @id @default(cuid())
|
|
37
|
+
name String
|
|
38
|
+
orgId String
|
|
39
|
+
|
|
40
|
+
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
41
|
+
|
|
42
|
+
@@index([orgId])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
model Membership {
|
|
46
|
+
id String @id @default(cuid())
|
|
47
|
+
userId String
|
|
48
|
+
orgId String
|
|
49
|
+
role String @default("member") // owner | admin | member | viewer
|
|
50
|
+
joinedAt DateTime @default(now())
|
|
51
|
+
|
|
52
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
53
|
+
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
54
|
+
|
|
55
|
+
@@unique([userId, orgId])
|
|
56
|
+
@@index([orgId])
|
|
57
|
+
@@index([userId])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
model Invitation {
|
|
61
|
+
id String @id @default(cuid())
|
|
62
|
+
orgId String
|
|
63
|
+
email String
|
|
64
|
+
role String @default("member")
|
|
65
|
+
token String @unique @default(cuid())
|
|
66
|
+
expiresAt DateTime
|
|
67
|
+
acceptedAt DateTime?
|
|
68
|
+
createdAt DateTime @default(now())
|
|
69
|
+
|
|
70
|
+
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
71
|
+
|
|
72
|
+
@@index([orgId])
|
|
73
|
+
@@index([token])
|
|
74
|
+
@@index([email])
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add the relation to the User model in `prisma/schema/auth.prisma`:
|
|
79
|
+
|
|
80
|
+
```prisma
|
|
81
|
+
model User {
|
|
82
|
+
// ... existing fields
|
|
83
|
+
memberships Membership[]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run `yarn db:push` to sync.
|
|
88
|
+
|
|
89
|
+
## Step 2: Types
|
|
90
|
+
|
|
91
|
+
Create `src/features/organizations/types.ts`:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
export type OrgRole = 'owner' | 'admin' | 'member' | 'viewer';
|
|
95
|
+
|
|
96
|
+
export const ORG_ROLE_HIERARCHY: Record<OrgRole, number> = {
|
|
97
|
+
owner: 40,
|
|
98
|
+
admin: 30,
|
|
99
|
+
member: 20,
|
|
100
|
+
viewer: 10,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export interface OrgContext {
|
|
104
|
+
orgId: string;
|
|
105
|
+
orgSlug: string;
|
|
106
|
+
role: OrgRole;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Step 3: Validation Schemas
|
|
111
|
+
|
|
112
|
+
Create `src/features/organizations/validation/schemas.ts`:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { z } from 'zod';
|
|
116
|
+
|
|
117
|
+
export const organizationSchemas = {
|
|
118
|
+
create: z.object({
|
|
119
|
+
name: z.string().min(1, 'Name is required').max(100),
|
|
120
|
+
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
|
|
121
|
+
}),
|
|
122
|
+
update: z.object({
|
|
123
|
+
name: z.string().min(1).max(100).optional(),
|
|
124
|
+
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/).optional(),
|
|
125
|
+
plan: z.string().max(50).optional(),
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const membershipSchemas = {
|
|
130
|
+
updateRole: z.object({
|
|
131
|
+
role: z.enum(['admin', 'member', 'viewer']),
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const invitationSchemas = {
|
|
136
|
+
create: z.object({
|
|
137
|
+
email: z.string().email('Valid email required'),
|
|
138
|
+
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type CreateOrganizationInput = z.infer<typeof organizationSchemas.create>;
|
|
143
|
+
export type UpdateOrganizationInput = z.infer<typeof organizationSchemas.update>;
|
|
144
|
+
export type CreateInvitationInput = z.infer<typeof invitationSchemas.create>;
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Step 4: Server Functions
|
|
148
|
+
|
|
149
|
+
Create `src/features/organizations/server/index.ts`:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
import 'server-only';
|
|
153
|
+
|
|
154
|
+
import { prisma } from '@/lib/prisma';
|
|
155
|
+
import type { CreateOrganizationInput, UpdateOrganizationInput, CreateInvitationInput } from '../validation/schemas';
|
|
156
|
+
import type { OrgRole } from '../types';
|
|
157
|
+
|
|
158
|
+
// --- Organization CRUD ---
|
|
159
|
+
|
|
160
|
+
export async function findOrganizationsByUserId(userId: string) {
|
|
161
|
+
return prisma.organization.findMany({
|
|
162
|
+
where: { memberships: { some: { userId } } },
|
|
163
|
+
include: {
|
|
164
|
+
memberships: { where: { userId }, select: { role: true } },
|
|
165
|
+
_count: { select: { memberships: true } },
|
|
166
|
+
},
|
|
167
|
+
orderBy: { createdAt: 'desc' },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function findOrganizationById(orgId: string) {
|
|
172
|
+
return prisma.organization.findUnique({
|
|
173
|
+
where: { id: orgId },
|
|
174
|
+
include: { _count: { select: { memberships: true, teams: true } } },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function createOrganization(userId: string, data: CreateOrganizationInput) {
|
|
179
|
+
return prisma.$transaction(async (tx) => {
|
|
180
|
+
const org = await tx.organization.create({ data });
|
|
181
|
+
await tx.membership.create({
|
|
182
|
+
data: { userId, orgId: org.id, role: 'owner' },
|
|
183
|
+
});
|
|
184
|
+
return org;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function updateOrganization(orgId: string, data: UpdateOrganizationInput) {
|
|
189
|
+
return prisma.organization.update({ where: { id: orgId }, data });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function deleteOrganization(orgId: string) {
|
|
193
|
+
return prisma.organization.delete({ where: { id: orgId } });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Membership ---
|
|
197
|
+
|
|
198
|
+
export async function findMembershipsByOrgId(orgId: string) {
|
|
199
|
+
return prisma.membership.findMany({
|
|
200
|
+
where: { orgId },
|
|
201
|
+
include: { user: { select: { id: true, email: true, name: true } } },
|
|
202
|
+
orderBy: { joinedAt: 'asc' },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function getUserMembership(userId: string, orgId: string) {
|
|
207
|
+
return prisma.membership.findUnique({
|
|
208
|
+
where: { userId_orgId: { userId, orgId } },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function updateMemberRole(membershipId: string, role: OrgRole) {
|
|
213
|
+
return prisma.membership.update({ where: { id: membershipId }, data: { role } });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function removeMember(membershipId: string) {
|
|
217
|
+
return prisma.membership.delete({ where: { id: membershipId } });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Invitations ---
|
|
221
|
+
|
|
222
|
+
export async function findInvitationsByOrgId(orgId: string) {
|
|
223
|
+
return prisma.invitation.findMany({
|
|
224
|
+
where: { orgId, acceptedAt: null, expiresAt: { gt: new Date() } },
|
|
225
|
+
orderBy: { createdAt: 'desc' },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function createInvitation(orgId: string, data: CreateInvitationInput) {
|
|
230
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
231
|
+
return prisma.invitation.create({
|
|
232
|
+
data: { orgId, email: data.email, role: data.role, expiresAt },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function acceptInvitation(token: string, userId: string) {
|
|
237
|
+
return prisma.$transaction(async (tx) => {
|
|
238
|
+
const invitation = await tx.invitation.findUnique({ where: { token } });
|
|
239
|
+
|
|
240
|
+
if (!invitation || invitation.acceptedAt || invitation.expiresAt < new Date()) {
|
|
241
|
+
throw new Error('Invitation is invalid or expired');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await tx.membership.create({
|
|
245
|
+
data: { userId, orgId: invitation.orgId, role: invitation.role as OrgRole },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await tx.invitation.update({
|
|
249
|
+
where: { id: invitation.id },
|
|
250
|
+
data: { acceptedAt: new Date() },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return invitation;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function revokeInvitation(invitationId: string) {
|
|
258
|
+
return prisma.invitation.delete({ where: { id: invitationId } });
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Step 5: Auth Middleware — `withOrgAccess`
|
|
263
|
+
|
|
264
|
+
Create `src/features/organizations/server/middleware.ts`:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import 'server-only';
|
|
268
|
+
|
|
269
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
270
|
+
import { verifySessionForAPI } from '@/lib/mars';
|
|
271
|
+
import { getUserMembership } from './index';
|
|
272
|
+
import { ORG_ROLE_HIERARCHY, type OrgRole } from '../types';
|
|
273
|
+
|
|
274
|
+
interface OrgAuthenticatedRequest extends NextRequest {
|
|
275
|
+
session: { userId: string; email: string };
|
|
276
|
+
org: { orgId: string; role: OrgRole };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
type OrgRouteParams = { params: Promise<{ orgId: string }> };
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Verifies the user has the required role (or higher) in the organization.
|
|
283
|
+
* Extracts orgId from route params.
|
|
284
|
+
*/
|
|
285
|
+
export function withOrgAccess(
|
|
286
|
+
requiredRoles: OrgRole[],
|
|
287
|
+
handler: (request: OrgAuthenticatedRequest, context: OrgRouteParams) => Promise<NextResponse>,
|
|
288
|
+
) {
|
|
289
|
+
return async (request: NextRequest, context: OrgRouteParams) => {
|
|
290
|
+
const session = await verifySessionForAPI(request);
|
|
291
|
+
if (!session) {
|
|
292
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { orgId } = await context.params;
|
|
296
|
+
const membership = await getUserMembership(session.userId, orgId);
|
|
297
|
+
|
|
298
|
+
if (!membership) {
|
|
299
|
+
return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const userLevel = ORG_ROLE_HIERARCHY[membership.role as OrgRole];
|
|
303
|
+
const requiredLevel = Math.min(...requiredRoles.map((r) => ORG_ROLE_HIERARCHY[r]));
|
|
304
|
+
|
|
305
|
+
if (userLevel < requiredLevel) {
|
|
306
|
+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const authedRequest = request as OrgAuthenticatedRequest;
|
|
310
|
+
authedRequest.session = session;
|
|
311
|
+
authedRequest.org = { orgId, role: membership.role as OrgRole };
|
|
312
|
+
|
|
313
|
+
return handler(authedRequest, context);
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Step 6: API Routes
|
|
319
|
+
|
|
320
|
+
### List user's organizations — `src/app/api/protected/organizations/route.ts`
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
324
|
+
import { findOrganizationsByUserId, createOrganization } from '@/features/organizations/server';
|
|
325
|
+
import { organizationSchemas } from '@/features/organizations/validation/schemas';
|
|
326
|
+
import { NextResponse } from 'next/server';
|
|
327
|
+
|
|
328
|
+
export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
329
|
+
try {
|
|
330
|
+
const orgs = await findOrganizationsByUserId(request.session.userId);
|
|
331
|
+
return NextResponse.json(orgs);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations' });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
338
|
+
try {
|
|
339
|
+
const body = organizationSchemas.create.parse(await request.json());
|
|
340
|
+
const org = await createOrganization(request.session.userId, body);
|
|
341
|
+
return NextResponse.json(org, { status: 201 });
|
|
342
|
+
} catch (error) {
|
|
343
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations' });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Organization details — `src/app/api/protected/organizations/[orgId]/route.ts`
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
import { handleApiError } from '@/lib/mars';
|
|
352
|
+
import { findOrganizationById, updateOrganization, deleteOrganization } from '@/features/organizations/server';
|
|
353
|
+
import { withOrgAccess } from '@/features/organizations/server/middleware';
|
|
354
|
+
import { organizationSchemas } from '@/features/organizations/validation/schemas';
|
|
355
|
+
import { NextResponse } from 'next/server';
|
|
356
|
+
|
|
357
|
+
export const GET = withOrgAccess(['viewer'], async (request, context) => {
|
|
358
|
+
try {
|
|
359
|
+
const org = await findOrganizationById(request.org.orgId);
|
|
360
|
+
if (!org) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
361
|
+
return NextResponse.json(org);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]' });
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
export const PATCH = withOrgAccess(['admin'], async (request, context) => {
|
|
368
|
+
try {
|
|
369
|
+
const body = organizationSchemas.update.parse(await request.json());
|
|
370
|
+
const org = await updateOrganization(request.org.orgId, body);
|
|
371
|
+
return NextResponse.json(org);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]' });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
export const DELETE = withOrgAccess(['owner'], async (request, context) => {
|
|
378
|
+
try {
|
|
379
|
+
await deleteOrganization(request.org.orgId);
|
|
380
|
+
return NextResponse.json({ success: true });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]' });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Members — `src/app/api/protected/organizations/[orgId]/members/route.ts`
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
import { handleApiError } from '@/lib/mars';
|
|
391
|
+
import { findMembershipsByOrgId, updateMemberRole, removeMember } from '@/features/organizations/server';
|
|
392
|
+
import { withOrgAccess } from '@/features/organizations/server/middleware';
|
|
393
|
+
import { membershipSchemas } from '@/features/organizations/validation/schemas';
|
|
394
|
+
import { NextResponse } from 'next/server';
|
|
395
|
+
|
|
396
|
+
export const GET = withOrgAccess(['viewer'], async (request) => {
|
|
397
|
+
try {
|
|
398
|
+
const members = await findMembershipsByOrgId(request.org.orgId);
|
|
399
|
+
return NextResponse.json(members);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]/members' });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Invitations — `src/app/api/protected/organizations/[orgId]/invitations/route.ts`
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
import { handleApiError } from '@/lib/mars';
|
|
410
|
+
import { findInvitationsByOrgId, createInvitation, revokeInvitation } from '@/features/organizations/server';
|
|
411
|
+
import { withOrgAccess } from '@/features/organizations/server/middleware';
|
|
412
|
+
import { invitationSchemas } from '@/features/organizations/validation/schemas';
|
|
413
|
+
import { NextResponse } from 'next/server';
|
|
414
|
+
|
|
415
|
+
export const GET = withOrgAccess(['admin'], async (request) => {
|
|
416
|
+
try {
|
|
417
|
+
const invitations = await findInvitationsByOrgId(request.org.orgId);
|
|
418
|
+
return NextResponse.json(invitations);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]/invitations' });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
export const POST = withOrgAccess(['admin'], async (request) => {
|
|
425
|
+
try {
|
|
426
|
+
const body = invitationSchemas.create.parse(await request.json());
|
|
427
|
+
const invitation = await createInvitation(request.org.orgId, body);
|
|
428
|
+
return NextResponse.json(invitation, { status: 201 });
|
|
429
|
+
} catch (error) {
|
|
430
|
+
return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]/invitations' });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## Step 7: Add Org Context to Session
|
|
436
|
+
|
|
437
|
+
Extend the JWT payload to include the user's active organization. In `src/features/auth/server/session.ts` (or wherever sessions are built):
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
interface SessionPayload {
|
|
441
|
+
userId: string;
|
|
442
|
+
email: string;
|
|
443
|
+
role: string;
|
|
444
|
+
activeOrgId?: string; // Add this
|
|
445
|
+
activeOrgRole?: string; // Add this
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
When the user switches orgs, call a dedicated endpoint to update the session cookie with the new `activeOrgId` and `activeOrgRole`. This keeps the JWT lean while letting middleware make fast decisions.
|
|
450
|
+
|
|
451
|
+
Alternatively, look up org membership on each request via the `withOrgAccess` middleware (no JWT change needed). This is safer for role changes that need to take effect immediately, but adds a DB query per request.
|
|
452
|
+
|
|
453
|
+
**Recommendation:** Use `withOrgAccess` middleware for API routes (always fresh from DB) and store `activeOrgId` in the JWT only for UI convenience (org switcher default selection).
|
|
454
|
+
|
|
455
|
+
## Step 8: Migration Path — Single-User to Multi-Tenant
|
|
456
|
+
|
|
457
|
+
For existing projects that started as single-user:
|
|
458
|
+
|
|
459
|
+
1. Run the Prisma migration to add the Organization, Team, Membership, and Invitation models.
|
|
460
|
+
2. Create a backfill script:
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
// scripts/backfill-orgs.ts
|
|
464
|
+
import { prisma } from '@/lib/prisma';
|
|
465
|
+
|
|
466
|
+
async function backfill() {
|
|
467
|
+
const users = await prisma.user.findMany();
|
|
468
|
+
|
|
469
|
+
for (const user of users) {
|
|
470
|
+
const org = await prisma.organization.create({
|
|
471
|
+
data: {
|
|
472
|
+
name: `${user.name ?? user.email}'s Organization`,
|
|
473
|
+
slug: `user-${user.id.slice(0, 8)}`,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await prisma.membership.create({
|
|
478
|
+
data: { userId: user.id, orgId: org.id, role: 'owner' },
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
console.log(`Backfilled ${users.length} users into personal organizations.`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
backfill();
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
3. Add `orgId` to existing data models that need scoping, then backfill with the user's personal org ID.
|
|
489
|
+
4. Update queries to scope by `orgId` instead of (or in addition to) `userId`.
|
|
490
|
+
|
|
491
|
+
## Step 9: UI Patterns
|
|
492
|
+
|
|
493
|
+
### Org Switcher Component
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
// src/features/organizations/components/org-switcher.tsx
|
|
497
|
+
'use client';
|
|
498
|
+
|
|
499
|
+
import { Select } from '@mars-stack/ui';
|
|
500
|
+
import { useRouter } from 'next/navigation';
|
|
501
|
+
|
|
502
|
+
interface OrgSwitcherProps {
|
|
503
|
+
organizations: Array<{ id: string; name: string; slug: string }>;
|
|
504
|
+
activeOrgId: string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function OrgSwitcher({ organizations, activeOrgId }: OrgSwitcherProps) {
|
|
508
|
+
const router = useRouter();
|
|
509
|
+
|
|
510
|
+
async function handleSwitch(orgId: string) {
|
|
511
|
+
await fetch('/api/protected/organizations/switch', {
|
|
512
|
+
method: 'POST',
|
|
513
|
+
headers: { 'Content-Type': 'application/json' },
|
|
514
|
+
body: JSON.stringify({ orgId }),
|
|
515
|
+
});
|
|
516
|
+
router.refresh();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<Select
|
|
521
|
+
value={activeOrgId}
|
|
522
|
+
onValueChange={handleSwitch}
|
|
523
|
+
options={organizations.map((org) => ({
|
|
524
|
+
label: org.name,
|
|
525
|
+
value: org.id,
|
|
526
|
+
}))}
|
|
527
|
+
/>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Invitation Flow
|
|
533
|
+
|
|
534
|
+
1. Admin creates invitation via `POST /api/protected/organizations/[orgId]/invitations`.
|
|
535
|
+
2. Send invitation email with a link: `{APP_URL}/invite/{token}`.
|
|
536
|
+
3. Invite page calls `POST /api/invitations/accept` with the token and the authenticated user's session.
|
|
537
|
+
4. On success, redirect to the organization dashboard.
|
|
538
|
+
|
|
539
|
+
## Decision Notes
|
|
540
|
+
|
|
541
|
+
- **Org-level vs. team-level scoping:** Start with org-level scoping (all data belongs to the org, all members can access). Add teams only when the user needs sub-org isolation (e.g., departments that shouldn't see each other's data). Teams add a second join in every query.
|
|
542
|
+
- **Owner role is non-transferable by default.** Build a dedicated "transfer ownership" flow if needed rather than letting admins promote to owner.
|
|
543
|
+
- **Slug uniqueness** is enforced at the database level. Expose a `/api/protected/organizations/check-slug` endpoint so the UI can validate before submission.
|
|
544
|
+
- **Soft delete vs. hard delete:** The schema above uses hard delete (cascading). For audit trails, add a `deletedAt` column and filter with `where: { deletedAt: null }`.
|
|
545
|
+
|
|
546
|
+
## Feature Flag
|
|
547
|
+
|
|
548
|
+
Add to `src/config/app.config.ts`:
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
features: {
|
|
552
|
+
// ... existing flags
|
|
553
|
+
organizations: true,
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Gate the org switcher, invitation routes, and org-scoped queries behind this flag.
|
|
558
|
+
|
|
559
|
+
## Tests
|
|
560
|
+
|
|
561
|
+
Create `route.test.ts` next to each API route. Mock Prisma and auth:
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
565
|
+
import { GET, POST } from './route';
|
|
566
|
+
import { mockAuth } from '@mars-stack/core/test-utils';
|
|
567
|
+
|
|
568
|
+
vi.mock('@/lib/prisma', () => ({
|
|
569
|
+
prisma: {
|
|
570
|
+
organization: { findMany: vi.fn(), create: vi.fn() },
|
|
571
|
+
membership: { create: vi.fn(), findUnique: vi.fn() },
|
|
572
|
+
$transaction: vi.fn((fn) => fn({
|
|
573
|
+
organization: { create: vi.fn() },
|
|
574
|
+
membership: { create: vi.fn() },
|
|
575
|
+
})),
|
|
576
|
+
},
|
|
577
|
+
}));
|
|
578
|
+
|
|
579
|
+
vi.mock('@/lib/mars', () => ({
|
|
580
|
+
verifySessionForAPI: vi.fn(() => Promise.resolve(mockAuth)),
|
|
581
|
+
handleApiError: vi.fn((error) => {
|
|
582
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500 });
|
|
583
|
+
}),
|
|
584
|
+
}));
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Test the `withOrgAccess` middleware specifically:
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
describe('withOrgAccess', () => {
|
|
591
|
+
it('returns 403 when user is not a member', async () => { /* ... */ });
|
|
592
|
+
it('returns 403 when user role is below required level', async () => { /* ... */ });
|
|
593
|
+
it('passes request with org context when authorized', async () => { /* ... */ });
|
|
594
|
+
});
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
## Checklist
|
|
598
|
+
|
|
599
|
+
- [ ] Prisma schema created (`organization.prisma`) with Organization, Team, Membership, Invitation models
|
|
600
|
+
- [ ] User model updated with `memberships` relation
|
|
601
|
+
- [ ] `yarn db:push` run to sync schema
|
|
602
|
+
- [ ] Types and validation schemas created
|
|
603
|
+
- [ ] Server functions for org CRUD, membership, and invitations
|
|
604
|
+
- [ ] `withOrgAccess` middleware created
|
|
605
|
+
- [ ] API routes created under `/api/protected/organizations/`
|
|
606
|
+
- [ ] Session/JWT extended with `activeOrgId` (or using middleware-only approach)
|
|
607
|
+
- [ ] Feature flag added to `app.config.ts`
|
|
608
|
+
- [ ] Org switcher component built
|
|
609
|
+
- [ ] Invitation flow (create, email, accept) implemented
|
|
610
|
+
- [ ] Backfill script for existing single-user data (if migrating)
|
|
611
|
+
- [ ] Tests written for API routes and `withOrgAccess` middleware
|