kitcn 0.0.1 → 0.12.1
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/bin/intent.js +3 -0
- package/dist/aggregate/index.d.ts +388 -0
- package/dist/aggregate/index.js +37 -0
- package/dist/api-entry-BckXqaLb.js +66 -0
- package/dist/auth/client/index.d.ts +37 -0
- package/dist/auth/client/index.js +217 -0
- package/dist/auth/config/index.d.ts +45 -0
- package/dist/auth/config/index.js +24 -0
- package/dist/auth/generated/index.d.ts +2 -0
- package/dist/auth/generated/index.js +3 -0
- package/dist/auth/http/index.d.ts +64 -0
- package/dist/auth/http/index.js +461 -0
- package/dist/auth/index.d.ts +221 -0
- package/dist/auth/index.js +1398 -0
- package/dist/auth/nextjs/index.d.ts +50 -0
- package/dist/auth/nextjs/index.js +81 -0
- package/dist/auth-store-Cljlmdmi.js +197 -0
- package/dist/builder-CBdG5W6A.js +1974 -0
- package/dist/caller-factory-cTXNvYdz.js +216 -0
- package/dist/cli.mjs +13264 -0
- package/dist/codegen-lF80HSWu.mjs +3416 -0
- package/dist/context-utils-HPC5nXzx.d.ts +17 -0
- package/dist/create-schema-odyF4kCy.js +156 -0
- package/dist/create-schema-orm-DOyiNDCx.js +246 -0
- package/dist/crpc/index.d.ts +105 -0
- package/dist/crpc/index.js +169 -0
- package/dist/customFunctions-C0voKmtx.js +144 -0
- package/dist/error-BZEnI7Sq.js +41 -0
- package/dist/generated-contract-disabled-Cih4eITO.js +50 -0
- package/dist/generated-contract-disabled-D-sOFy92.d.ts +354 -0
- package/dist/http-types-DqJubRPJ.d.ts +292 -0
- package/dist/meta-utils-0Pu0Nrap.js +117 -0
- package/dist/middleware-BUybuv9n.d.ts +34 -0
- package/dist/middleware-C2qTZ3V7.js +84 -0
- package/dist/orm/index.d.ts +17 -0
- package/dist/orm/index.js +10713 -0
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.js +3 -0
- package/dist/procedure-caller-DtxLmGwA.d.ts +1467 -0
- package/dist/procedure-caller-MWcxhQDv.js +349 -0
- package/dist/query-context-B8o6-8kC.js +1518 -0
- package/dist/query-context-CFZqIvD7.d.ts +42 -0
- package/dist/query-options-Dw7cOyXl.js +121 -0
- package/dist/ratelimit/index.d.ts +269 -0
- package/dist/ratelimit/index.js +856 -0
- package/dist/ratelimit/react/index.d.ts +76 -0
- package/dist/ratelimit/react/index.js +183 -0
- package/dist/react/index.d.ts +1284 -0
- package/dist/react/index.js +2526 -0
- package/dist/rsc/index.d.ts +276 -0
- package/dist/rsc/index.js +233 -0
- package/dist/runtime-CtvJPkur.js +2453 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +6 -0
- package/dist/solid/index.d.ts +1221 -0
- package/dist/solid/index.js +2940 -0
- package/dist/transformer-DtDhR3Lc.js +194 -0
- package/dist/types-BTb_4BaU.d.ts +42 -0
- package/dist/types-BiJE7qxR.d.ts +4 -0
- package/dist/types-DEJpkIhw.d.ts +88 -0
- package/dist/types-HhO_R6pd.d.ts +213 -0
- package/dist/validators-B7oIJCAp.js +279 -0
- package/dist/validators-vzRKjBJC.d.ts +88 -0
- package/dist/watcher.mjs +96 -0
- package/dist/where-clause-compiler-DdjN63Io.d.ts +4756 -0
- package/package.json +107 -34
- package/skills/convex/SKILL.md +486 -0
- package/skills/convex/references/features/aggregates.md +353 -0
- package/skills/convex/references/features/auth-admin.md +446 -0
- package/skills/convex/references/features/auth-organizations.md +1141 -0
- package/skills/convex/references/features/auth-polar.md +579 -0
- package/skills/convex/references/features/auth.md +470 -0
- package/skills/convex/references/features/create-plugins.md +153 -0
- package/skills/convex/references/features/http.md +676 -0
- package/skills/convex/references/features/migrations.md +162 -0
- package/skills/convex/references/features/orm.md +1166 -0
- package/skills/convex/references/features/react.md +657 -0
- package/skills/convex/references/features/scheduling.md +267 -0
- package/skills/convex/references/features/testing.md +209 -0
- package/skills/convex/references/setup/auth.md +501 -0
- package/skills/convex/references/setup/biome.md +190 -0
- package/skills/convex/references/setup/doc-guidelines.md +145 -0
- package/skills/convex/references/setup/index.md +761 -0
- package/skills/convex/references/setup/next.md +116 -0
- package/skills/convex/references/setup/react.md +175 -0
- package/skills/convex/references/setup/server.md +473 -0
- package/skills/convex/references/setup/start.md +67 -0
- package/LICENSE +0 -21
- package/README.md +0 -0
- package/dist/index.d.mts +0 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -6
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
# Auth Organizations Plugin
|
|
2
|
+
|
|
3
|
+
Multi-tenant organization features via Better Auth plugin. Organizations, members, invitations, teams, RBAC, lifecycle hooks.
|
|
4
|
+
|
|
5
|
+
Prerequisites: `setup/auth.md`, `setup/server.md`.
|
|
6
|
+
|
|
7
|
+
See [Better Auth Organization Plugin](https://www.better-auth.com/docs/plugins/organization) for full API reference.
|
|
8
|
+
|
|
9
|
+
## Server Config
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// convex/functions/auth.ts
|
|
13
|
+
import { organization } from "better-auth/plugins";
|
|
14
|
+
import type { ActionCtx } from "./generated/server";
|
|
15
|
+
import { defineAuth } from "./generated/auth";
|
|
16
|
+
|
|
17
|
+
export default defineAuth((ctx) => ({
|
|
18
|
+
plugins: [
|
|
19
|
+
convex({ authConfig, jwks: process.env.JWKS }),
|
|
20
|
+
admin(),
|
|
21
|
+
organization({
|
|
22
|
+
ac,
|
|
23
|
+
roles,
|
|
24
|
+
allowUserToCreateOrganization: true,
|
|
25
|
+
organizationLimit: 5,
|
|
26
|
+
membershipLimit: 100,
|
|
27
|
+
creatorRole: "owner",
|
|
28
|
+
invitationExpiresIn: 48 * 60 * 60, // 48 hours
|
|
29
|
+
teams: { enabled: true, maximumTeams: 10 },
|
|
30
|
+
sendInvitationEmail: async (data) => {
|
|
31
|
+
const inviterName = data.inviter.user.name || "Team Admin";
|
|
32
|
+
const organizationName = data.organization.name;
|
|
33
|
+
const roleSuffix = data.role ? ` as ${data.role}` : "";
|
|
34
|
+
const acceptUrl = `${process.env.SITE_URL!}/w/${data.organization.slug}?invite=${data.id}`;
|
|
35
|
+
|
|
36
|
+
await (ctx as ActionCtx).scheduler.runAfter(
|
|
37
|
+
0,
|
|
38
|
+
internal.plugins.email.sendTemplatedEmail,
|
|
39
|
+
{
|
|
40
|
+
to: data.email,
|
|
41
|
+
subject: `${inviterName} invited you to join ${organizationName}`,
|
|
42
|
+
title: `Invitation to join ${organizationName}`,
|
|
43
|
+
body: `${inviterName} (${data.inviter.user.email}) invited you to join ${organizationName}${roleSuffix}.`,
|
|
44
|
+
ctaLabel: "Accept invitation",
|
|
45
|
+
ctaUrl: acceptUrl,
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
}));
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Client Config
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// src/lib/convex/auth-client.ts
|
|
58
|
+
import { organizationClient } from "better-auth/client/plugins";
|
|
59
|
+
import { ac, roles } from "@convex/auth-shared";
|
|
60
|
+
|
|
61
|
+
export const authClient = createAuthClient({
|
|
62
|
+
plugins: [
|
|
63
|
+
inferAdditionalFields<Auth>(),
|
|
64
|
+
convexClient(),
|
|
65
|
+
organizationClient({ ac, roles, teams: { enabled: true } }),
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Schema
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// convex/functions/schema.ts
|
|
74
|
+
import {
|
|
75
|
+
convexTable,
|
|
76
|
+
defineSchema,
|
|
77
|
+
id,
|
|
78
|
+
index,
|
|
79
|
+
integer,
|
|
80
|
+
json,
|
|
81
|
+
text,
|
|
82
|
+
timestamp,
|
|
83
|
+
} from "kitcn/orm";
|
|
84
|
+
|
|
85
|
+
export const organization = convexTable(
|
|
86
|
+
"organization",
|
|
87
|
+
{
|
|
88
|
+
name: text().notNull(),
|
|
89
|
+
slug: text().notNull(),
|
|
90
|
+
logo: text(),
|
|
91
|
+
createdAt: timestamp().notNull().defaultNow(),
|
|
92
|
+
metadata: json<Record<string, unknown>>(),
|
|
93
|
+
},
|
|
94
|
+
(t) => [index("slug").on(t.slug), index("name").on(t.name)]
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
export const member = convexTable(
|
|
98
|
+
"member",
|
|
99
|
+
{
|
|
100
|
+
organizationId: id("organization").notNull(),
|
|
101
|
+
userId: id("user").notNull(),
|
|
102
|
+
role: text().notNull(),
|
|
103
|
+
createdAt: timestamp().notNull().defaultNow(),
|
|
104
|
+
},
|
|
105
|
+
(t) => [
|
|
106
|
+
index("userId").on(t.userId),
|
|
107
|
+
index("organizationId_userId").on(t.organizationId, t.userId),
|
|
108
|
+
index("organizationId_role").on(t.organizationId, t.role),
|
|
109
|
+
]
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
export const invitation = convexTable(
|
|
113
|
+
"invitation",
|
|
114
|
+
{
|
|
115
|
+
organizationId: id("organization").notNull(),
|
|
116
|
+
inviterId: id("user").notNull(),
|
|
117
|
+
email: text().notNull(),
|
|
118
|
+
role: text(),
|
|
119
|
+
status: text().notNull(),
|
|
120
|
+
expiresAt: integer().notNull(),
|
|
121
|
+
createdAt: timestamp().notNull().defaultNow(),
|
|
122
|
+
},
|
|
123
|
+
(t) => [
|
|
124
|
+
index("email").on(t.email),
|
|
125
|
+
index("status").on(t.status),
|
|
126
|
+
index("email_organizationId_status").on(
|
|
127
|
+
t.email,
|
|
128
|
+
t.organizationId,
|
|
129
|
+
t.status
|
|
130
|
+
),
|
|
131
|
+
index("organizationId_status").on(t.organizationId, t.status),
|
|
132
|
+
]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Add to existing session table
|
|
136
|
+
export const session = convexTable("session", {
|
|
137
|
+
// ... existing session fields
|
|
138
|
+
activeOrganizationId: id("organization"),
|
|
139
|
+
activeTeamId: id("team"),
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Teams (Optional)
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
export const team = convexTable(
|
|
147
|
+
"team",
|
|
148
|
+
{
|
|
149
|
+
name: text().notNull(),
|
|
150
|
+
organizationId: id("organization").notNull(),
|
|
151
|
+
createdAt: timestamp().notNull().defaultNow(),
|
|
152
|
+
updatedAt: integer(),
|
|
153
|
+
},
|
|
154
|
+
(t) => [index("organizationId").on(t.organizationId)]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
export const teamMember = convexTable(
|
|
158
|
+
"teamMember",
|
|
159
|
+
{
|
|
160
|
+
teamId: id("team").notNull(),
|
|
161
|
+
userId: id("user").notNull(),
|
|
162
|
+
createdAt: timestamp(),
|
|
163
|
+
},
|
|
164
|
+
(t) => [index("teamId").on(t.teamId), index("userId").on(t.userId)]
|
|
165
|
+
);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Additional Fields
|
|
169
|
+
|
|
170
|
+
Extend organization tables with custom fields in plugin config:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
organization({
|
|
174
|
+
schema: {
|
|
175
|
+
organization: { fields: { description: v.optional(v.string()), website: v.optional(v.string()) } },
|
|
176
|
+
member: { fields: { title: v.optional(v.string()), department: v.optional(v.string()) } },
|
|
177
|
+
invitation: { fields: { message: v.optional(v.string()) } },
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Then add matching columns in your schema.
|
|
183
|
+
|
|
184
|
+
## Access Control
|
|
185
|
+
|
|
186
|
+
### Basic Setup
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// convex/shared/auth-shared.ts
|
|
190
|
+
import { createAccessControl } from "better-auth/plugins/access";
|
|
191
|
+
import {
|
|
192
|
+
defaultStatements,
|
|
193
|
+
memberAc,
|
|
194
|
+
ownerAc,
|
|
195
|
+
} from "better-auth/plugins/organization/access";
|
|
196
|
+
|
|
197
|
+
const statement = { ...defaultStatements } as const;
|
|
198
|
+
export const ac = createAccessControl(statement);
|
|
199
|
+
|
|
200
|
+
const member = ac.newRole({ ...memberAc.statements });
|
|
201
|
+
const owner = ac.newRole({ ...ownerAc.statements });
|
|
202
|
+
export const roles = { member, owner };
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Custom Permissions
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const statement = {
|
|
209
|
+
...defaultStatements,
|
|
210
|
+
project: ["create", "read", "update", "delete"],
|
|
211
|
+
billing: ["read", "update"],
|
|
212
|
+
analytics: ["read"],
|
|
213
|
+
} as const;
|
|
214
|
+
|
|
215
|
+
export const ac = createAccessControl(statement);
|
|
216
|
+
|
|
217
|
+
const viewer = ac.newRole({ project: ["read"], analytics: ["read"] });
|
|
218
|
+
const editor = ac.newRole({
|
|
219
|
+
...memberAc.statements,
|
|
220
|
+
project: ["create", "read", "update"],
|
|
221
|
+
analytics: ["read"],
|
|
222
|
+
});
|
|
223
|
+
const admin = ac.newRole({
|
|
224
|
+
...ownerAc.statements,
|
|
225
|
+
project: ["create", "read", "update", "delete"],
|
|
226
|
+
billing: ["read", "update"],
|
|
227
|
+
analytics: ["read"],
|
|
228
|
+
});
|
|
229
|
+
export const roles = { viewer, editor, admin };
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Check Role Permission
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const canEdit = ac.checkRolePermission({
|
|
236
|
+
role: "editor",
|
|
237
|
+
permission: { project: ["update"] },
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Dynamic Access Control
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
organization({
|
|
245
|
+
ac: {
|
|
246
|
+
...ac,
|
|
247
|
+
resolveRole: async ({ role, organizationId }) => {
|
|
248
|
+
if (roles[role]) return roles[role];
|
|
249
|
+
const customRole = await ctx.orm.query.customRole.findFirst({ where: { name: role, organizationId } });
|
|
250
|
+
if (customRole) return ac.newRole(customRole.permissions);
|
|
251
|
+
return null;
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Permission Helper
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
// convex/lib/auth/auth-helpers.ts
|
|
261
|
+
import { CRPCError } from "kitcn/server";
|
|
262
|
+
import type { AuthCtx } from "../crpc";
|
|
263
|
+
|
|
264
|
+
export const hasPermission = async (
|
|
265
|
+
ctx: AuthCtx,
|
|
266
|
+
body: { permissions: Record<string, string[]> },
|
|
267
|
+
shouldThrow = true
|
|
268
|
+
) => {
|
|
269
|
+
const result = await ctx.auth.api.hasPermission({
|
|
270
|
+
body,
|
|
271
|
+
headers: ctx.auth.headers,
|
|
272
|
+
});
|
|
273
|
+
if (shouldThrow && !result.success) {
|
|
274
|
+
throw new CRPCError({
|
|
275
|
+
code: "FORBIDDEN",
|
|
276
|
+
message: "Insufficient permissions",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return result.success;
|
|
280
|
+
};
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Organization Functions
|
|
284
|
+
|
|
285
|
+
**Pattern:** Better Auth API for multi-table ops (create, delete, invitations). `ctx.orm` for simple reads/updates.
|
|
286
|
+
|
|
287
|
+
Example-parity helper module:
|
|
288
|
+
|
|
289
|
+
- `convex/lib/organization-helpers.ts` for shared organization listing and personal-organization bootstrap logic.
|
|
290
|
+
|
|
291
|
+
### Check Slug
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
export const checkSlug = authQuery
|
|
295
|
+
.input(z.object({ slug: z.string() }))
|
|
296
|
+
.output(z.object({ available: z.boolean() }))
|
|
297
|
+
.query(async ({ ctx, input }) => {
|
|
298
|
+
const existing = await ctx.orm.query.organization.findFirst({
|
|
299
|
+
where: { slug: input.slug },
|
|
300
|
+
});
|
|
301
|
+
return { available: !existing };
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### List Organizations
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
export const listOrganizations = authQuery
|
|
309
|
+
.output(
|
|
310
|
+
z.object({
|
|
311
|
+
canCreateOrganization: z.boolean(),
|
|
312
|
+
organizations: z.array(
|
|
313
|
+
z.object({
|
|
314
|
+
id: z.string(),
|
|
315
|
+
createdAt: z.date(),
|
|
316
|
+
isPersonal: z.boolean(),
|
|
317
|
+
logo: z.string().nullish(),
|
|
318
|
+
name: z.string(),
|
|
319
|
+
plan: z.string(),
|
|
320
|
+
slug: z.string(),
|
|
321
|
+
})
|
|
322
|
+
),
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
.query(async ({ ctx }) => {
|
|
326
|
+
const orgs = await listUserOrganizations(ctx, ctx.userId);
|
|
327
|
+
if (!orgs?.length)
|
|
328
|
+
return { canCreateOrganization: true, organizations: [] };
|
|
329
|
+
|
|
330
|
+
const activeOrgId = ctx.user.activeOrganization?.id;
|
|
331
|
+
const organizations = orgs
|
|
332
|
+
.filter((org) => org.id !== activeOrgId)
|
|
333
|
+
.map((org) => ({
|
|
334
|
+
id: org.id,
|
|
335
|
+
createdAt: org.createdAt,
|
|
336
|
+
isPersonal: org.id === ctx.user.personalOrganizationId,
|
|
337
|
+
logo: org.logo || null,
|
|
338
|
+
name: org.name,
|
|
339
|
+
plan: DEFAULT_PLAN,
|
|
340
|
+
slug: org.slug,
|
|
341
|
+
}));
|
|
342
|
+
return { canCreateOrganization: true, organizations };
|
|
343
|
+
});
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Create Organization
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
export const createOrganization = authMutation
|
|
350
|
+
.meta({ ratelimit: "organization/create" })
|
|
351
|
+
.input(z.object({ name: z.string().min(1).max(100) }))
|
|
352
|
+
.output(z.object({ id: z.string(), slug: z.string() }))
|
|
353
|
+
.mutation(async ({ ctx, input }) => {
|
|
354
|
+
let slug = input.name;
|
|
355
|
+
let attempt = 0;
|
|
356
|
+
while (attempt < 10) {
|
|
357
|
+
const existing = await ctx.orm.query.organization.findFirst({
|
|
358
|
+
where: { slug },
|
|
359
|
+
});
|
|
360
|
+
if (!existing) break;
|
|
361
|
+
slug = `${slug}-${Math.random().toString(36).slice(2, 10)}`;
|
|
362
|
+
attempt++;
|
|
363
|
+
}
|
|
364
|
+
if (attempt >= 10)
|
|
365
|
+
throw new CRPCError({
|
|
366
|
+
code: "BAD_REQUEST",
|
|
367
|
+
message: "Could not generate unique slug",
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const org = await ctx.auth.api.createOrganization({
|
|
371
|
+
body: { name: input.name, slug },
|
|
372
|
+
headers: ctx.auth.headers,
|
|
373
|
+
});
|
|
374
|
+
if (!org)
|
|
375
|
+
throw new CRPCError({
|
|
376
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
377
|
+
message: "Failed to create organization",
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
await setActiveOrganizationHandler(ctx, { organizationId: org.id });
|
|
381
|
+
return { id: org.id, slug: org.slug };
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Update Organization
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
export const updateOrganization = authMutation
|
|
389
|
+
.meta({ ratelimit: "organization/update" })
|
|
390
|
+
.input(
|
|
391
|
+
z.object({
|
|
392
|
+
organizationId: z.string(),
|
|
393
|
+
logo: z.string().url().optional(),
|
|
394
|
+
name: z.string().min(1).max(100).optional(),
|
|
395
|
+
slug: z.string().optional(),
|
|
396
|
+
})
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
.mutation(async ({ ctx, input }) => {
|
|
400
|
+
await hasPermission(ctx, {
|
|
401
|
+
organizationId: input.organizationId,
|
|
402
|
+
permissions: { organization: ["update"] },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
let slug = input.slug;
|
|
406
|
+
if (input.slug) {
|
|
407
|
+
if (input.organizationId === ctx.user.personalOrganizationId) {
|
|
408
|
+
slug = undefined;
|
|
409
|
+
} else {
|
|
410
|
+
slugSchema.parse(input.slug);
|
|
411
|
+
const existing = await ctx.orm.query.organization.findFirst({
|
|
412
|
+
where: { slug: input.slug },
|
|
413
|
+
});
|
|
414
|
+
if (existing && existing.id !== input.organizationId) {
|
|
415
|
+
throw new CRPCError({
|
|
416
|
+
code: "BAD_REQUEST",
|
|
417
|
+
message: "This slug is already taken",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const data: { logo?: string; name?: string; slug?: string } = {};
|
|
424
|
+
if (input.logo !== undefined) data.logo = input.logo;
|
|
425
|
+
if (input.name !== undefined) data.name = input.name;
|
|
426
|
+
if (slug !== undefined) data.slug = slug;
|
|
427
|
+
|
|
428
|
+
await ctx.auth.api.updateOrganization({
|
|
429
|
+
body: { data, organizationId: input.organizationId },
|
|
430
|
+
headers: ctx.auth.headers,
|
|
431
|
+
});
|
|
432
|
+
return null;
|
|
433
|
+
});
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Delete Organization
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
export const deleteOrganization = authMutation
|
|
440
|
+
.input(z.object({ organizationId: z.string() }))
|
|
441
|
+
|
|
442
|
+
.mutation(async ({ ctx, input }) => {
|
|
443
|
+
await hasPermission(ctx, {
|
|
444
|
+
organizationId: input.organizationId,
|
|
445
|
+
permissions: { organization: ["delete"] },
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (input.organizationId === ctx.user.personalOrganizationId) {
|
|
449
|
+
throw new CRPCError({
|
|
450
|
+
code: "FORBIDDEN",
|
|
451
|
+
message:
|
|
452
|
+
"Personal organizations can be deleted only by deleting your account.",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (input.organizationId === ctx.user.activeOrganization?.id) {
|
|
456
|
+
await setActiveOrganizationHandler(ctx, {
|
|
457
|
+
organizationId: ctx.user.personalOrganizationId!,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
await ctx.auth.api.deleteOrganization({
|
|
462
|
+
body: { organizationId: input.organizationId },
|
|
463
|
+
headers: ctx.auth.headers,
|
|
464
|
+
});
|
|
465
|
+
return null;
|
|
466
|
+
});
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Set Active Organization
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
export const setActiveOrganization = authMutation
|
|
473
|
+
.meta({ ratelimit: "organization/setActive" })
|
|
474
|
+
.input(z.object({ organizationId: z.string() }))
|
|
475
|
+
|
|
476
|
+
.mutation(async ({ ctx, input }) => setActiveOrganizationHandler(ctx, input));
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
## Invitation Functions
|
|
480
|
+
|
|
481
|
+
### Send Invitation
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
export const inviteMember = authMutation
|
|
485
|
+
.meta({ ratelimit: "organization/invite" })
|
|
486
|
+
.input(
|
|
487
|
+
z.object({
|
|
488
|
+
email: z.string().email(),
|
|
489
|
+
organizationId: z.string(),
|
|
490
|
+
role: z.enum(["owner", "member"]),
|
|
491
|
+
})
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
.mutation(async ({ ctx, input }) => {
|
|
495
|
+
await hasPermission(ctx, {
|
|
496
|
+
organizationId: input.organizationId,
|
|
497
|
+
permissions: { invitation: ["create"] },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Check member limit
|
|
501
|
+
const members = await ctx.orm.query.member.findMany({
|
|
502
|
+
where: { organizationId: input.organizationId },
|
|
503
|
+
limit: DEFAULT_LIST_LIMIT,
|
|
504
|
+
});
|
|
505
|
+
const pending = await ctx.orm.query.invitation.findMany({
|
|
506
|
+
where: { organizationId: input.organizationId, status: "pending" },
|
|
507
|
+
limit: DEFAULT_LIST_LIMIT,
|
|
508
|
+
});
|
|
509
|
+
if (members.length + pending.length >= MEMBER_LIMIT) {
|
|
510
|
+
throw new CRPCError({
|
|
511
|
+
code: "FORBIDDEN",
|
|
512
|
+
message: `Organization member limit reached. Maximum ${MEMBER_LIMIT} members allowed.`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Cancel existing pending invites for same email
|
|
517
|
+
const existing = await ctx.orm.query.invitation.findMany({
|
|
518
|
+
where: {
|
|
519
|
+
email: input.email,
|
|
520
|
+
organizationId: input.organizationId,
|
|
521
|
+
status: "pending",
|
|
522
|
+
},
|
|
523
|
+
limit: DEFAULT_LIST_LIMIT,
|
|
524
|
+
});
|
|
525
|
+
for (const inv of existing) {
|
|
526
|
+
await ctx.orm
|
|
527
|
+
.update(invitation)
|
|
528
|
+
.set({ status: "canceled" })
|
|
529
|
+
.where(eq(invitation.id, inv.id));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await ctx.auth.api.createInvitation({
|
|
533
|
+
body: {
|
|
534
|
+
email: input.email,
|
|
535
|
+
organizationId: input.organizationId,
|
|
536
|
+
role: input.role,
|
|
537
|
+
},
|
|
538
|
+
headers: ctx.auth.headers,
|
|
539
|
+
});
|
|
540
|
+
return null;
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### Accept / Reject / Cancel
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
export const acceptInvitation = authMutation
|
|
548
|
+
.input(z.object({ invitationId: z.string() }))
|
|
549
|
+
|
|
550
|
+
.mutation(async ({ ctx, input }) => {
|
|
551
|
+
const inv = await ctx.orm.query.invitation
|
|
552
|
+
.findFirstOrThrow({
|
|
553
|
+
where: { id: input.invitationId, email: ctx.user.email },
|
|
554
|
+
})
|
|
555
|
+
.catch(() => {
|
|
556
|
+
throw new CRPCError({
|
|
557
|
+
code: "FORBIDDEN",
|
|
558
|
+
message: "Invitation not found for your email",
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
if (inv.status !== "pending")
|
|
563
|
+
throw new CRPCError({
|
|
564
|
+
code: "BAD_REQUEST",
|
|
565
|
+
message: "Invitation already processed",
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
await ctx.auth.api.acceptInvitation({
|
|
569
|
+
body: { invitationId: input.invitationId },
|
|
570
|
+
headers: ctx.auth.headers,
|
|
571
|
+
});
|
|
572
|
+
return null;
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
export const rejectInvitation = authMutation
|
|
576
|
+
.meta({ ratelimit: "organization/rejectInvite" })
|
|
577
|
+
.input(z.object({ invitationId: z.string() }))
|
|
578
|
+
|
|
579
|
+
.mutation(async ({ ctx, input }) => {
|
|
580
|
+
const inv = await ctx.orm.query.invitation
|
|
581
|
+
.findFirstOrThrow({
|
|
582
|
+
where: { id: input.invitationId, email: ctx.user.email },
|
|
583
|
+
})
|
|
584
|
+
.catch(() => {
|
|
585
|
+
throw new CRPCError({
|
|
586
|
+
code: "FORBIDDEN",
|
|
587
|
+
message: "Invitation not found for your email",
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
if (inv.status !== "pending")
|
|
592
|
+
throw new CRPCError({
|
|
593
|
+
code: "BAD_REQUEST",
|
|
594
|
+
message: "Invitation already processed",
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
await ctx.auth.api.rejectInvitation({
|
|
598
|
+
body: { invitationId: input.invitationId },
|
|
599
|
+
headers: ctx.auth.headers,
|
|
600
|
+
});
|
|
601
|
+
return null;
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
export const cancelInvitation = authMutation
|
|
605
|
+
.meta({ ratelimit: "organization/cancelInvite" })
|
|
606
|
+
.input(z.object({ invitationId: z.string() }))
|
|
607
|
+
|
|
608
|
+
.mutation(async ({ ctx, input }) => {
|
|
609
|
+
const inv = await ctx.orm.query.invitation.findFirstOrThrow({
|
|
610
|
+
where: { id: input.invitationId },
|
|
611
|
+
});
|
|
612
|
+
await hasPermission(ctx, {
|
|
613
|
+
organizationId: inv.organizationId,
|
|
614
|
+
permissions: { invitation: ["cancel"] },
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
await ctx.auth.api.cancelInvitation({
|
|
619
|
+
body: { invitationId: input.invitationId },
|
|
620
|
+
headers: ctx.auth.headers,
|
|
621
|
+
});
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (error instanceof Error && error.message?.includes("not found")) {
|
|
624
|
+
throw new CRPCError({
|
|
625
|
+
code: "NOT_FOUND",
|
|
626
|
+
message: "Invitation not found or already processed",
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
throw new CRPCError({
|
|
630
|
+
code: "BAD_REQUEST",
|
|
631
|
+
message: `Failed: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
});
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### List User Invitations
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
export const listUserInvitations = authQuery
|
|
642
|
+
.output(
|
|
643
|
+
z.array(
|
|
644
|
+
z.object({
|
|
645
|
+
id: z.string(),
|
|
646
|
+
expiresAt: z.date(),
|
|
647
|
+
inviterName: z.string().nullable(),
|
|
648
|
+
organizationName: z.string(),
|
|
649
|
+
organizationSlug: z.string(),
|
|
650
|
+
role: z.string(),
|
|
651
|
+
})
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
.query(async ({ ctx }) => {
|
|
655
|
+
const invitations = await ctx.orm.query.invitation.findMany({
|
|
656
|
+
where: { email: ctx.user.email, status: "pending" },
|
|
657
|
+
limit: DEFAULT_LIST_LIMIT,
|
|
658
|
+
columns: {
|
|
659
|
+
id: true,
|
|
660
|
+
expiresAt: true,
|
|
661
|
+
organizationId: true,
|
|
662
|
+
inviterId: true,
|
|
663
|
+
role: true,
|
|
664
|
+
},
|
|
665
|
+
with: {
|
|
666
|
+
organization: { columns: { name: true, slug: true } },
|
|
667
|
+
inviter: { columns: { name: true } },
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
return invitations.map((inv) => {
|
|
672
|
+
const org = inv.organization;
|
|
673
|
+
if (!org)
|
|
674
|
+
throw new CRPCError({
|
|
675
|
+
code: "NOT_FOUND",
|
|
676
|
+
message: "Organization not found",
|
|
677
|
+
});
|
|
678
|
+
return {
|
|
679
|
+
id: inv.id,
|
|
680
|
+
expiresAt: inv.expiresAt,
|
|
681
|
+
inviterName: inv.inviter?.name ?? null,
|
|
682
|
+
organizationName: org.name,
|
|
683
|
+
organizationSlug: org.slug,
|
|
684
|
+
role: inv.role || "member",
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### List Pending Invitations
|
|
691
|
+
|
|
692
|
+
```ts
|
|
693
|
+
export const listPendingInvitations = authQuery
|
|
694
|
+
.input(z.object({ slug: z.string() }))
|
|
695
|
+
.output(
|
|
696
|
+
z.array(
|
|
697
|
+
z.object({
|
|
698
|
+
id: z.string(),
|
|
699
|
+
createdAt: z.date(),
|
|
700
|
+
email: z.string(),
|
|
701
|
+
expiresAt: z.date(),
|
|
702
|
+
organizationId: z.string(),
|
|
703
|
+
role: z.string(),
|
|
704
|
+
status: z.string(),
|
|
705
|
+
})
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
.query(async ({ ctx, input }) => {
|
|
709
|
+
const org = await ctx.orm.query.organization.findFirst({
|
|
710
|
+
where: { slug: input.slug },
|
|
711
|
+
});
|
|
712
|
+
if (!org) return [];
|
|
713
|
+
|
|
714
|
+
const canManage = await hasPermission(
|
|
715
|
+
ctx,
|
|
716
|
+
{ organizationId: org.id, permissions: { invitation: ["create"] } },
|
|
717
|
+
false
|
|
718
|
+
);
|
|
719
|
+
if (!canManage) return [];
|
|
720
|
+
|
|
721
|
+
const invitations = await ctx.orm.query.invitation.findMany({
|
|
722
|
+
where: { organizationId: org.id, status: "pending" },
|
|
723
|
+
limit: DEFAULT_LIST_LIMIT,
|
|
724
|
+
columns: {
|
|
725
|
+
id: true,
|
|
726
|
+
createdAt: true,
|
|
727
|
+
email: true,
|
|
728
|
+
expiresAt: true,
|
|
729
|
+
organizationId: true,
|
|
730
|
+
role: true,
|
|
731
|
+
status: true,
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
return invitations.map((inv) => ({
|
|
736
|
+
id: inv.id,
|
|
737
|
+
createdAt: inv.createdAt,
|
|
738
|
+
email: inv.email,
|
|
739
|
+
expiresAt: inv.expiresAt,
|
|
740
|
+
organizationId: inv.organizationId,
|
|
741
|
+
role: inv.role || "member",
|
|
742
|
+
status: inv.status,
|
|
743
|
+
}));
|
|
744
|
+
});
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
## Member Functions
|
|
748
|
+
|
|
749
|
+
### Get Active Member
|
|
750
|
+
|
|
751
|
+
```ts
|
|
752
|
+
export const getActiveMember = authQuery
|
|
753
|
+
.output(
|
|
754
|
+
z
|
|
755
|
+
.object({ id: z.string(), createdAt: z.date(), role: z.string() })
|
|
756
|
+
.nullable()
|
|
757
|
+
)
|
|
758
|
+
.query(async ({ ctx }) => {
|
|
759
|
+
if (!ctx.user.activeOrganization) return null;
|
|
760
|
+
const m = await ctx.orm.query.member.findFirst({
|
|
761
|
+
where: {
|
|
762
|
+
organizationId: ctx.user.activeOrganization.id,
|
|
763
|
+
userId: ctx.userId,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
if (!m) return null;
|
|
767
|
+
return { id: m.id, createdAt: m.createdAt, role: m.role };
|
|
768
|
+
});
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
### Add Member Directly
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
export const addMember = authMutation
|
|
775
|
+
.meta({ ratelimit: "organization/addMember" })
|
|
776
|
+
.input(z.object({ role: z.enum(["owner", "member"]), userId: z.string() }))
|
|
777
|
+
|
|
778
|
+
.mutation(async ({ ctx, input }) => {
|
|
779
|
+
await hasPermission(ctx, { permissions: { member: ["create"] } });
|
|
780
|
+
await ctx.auth.api.addMember({
|
|
781
|
+
body: {
|
|
782
|
+
userId: input.userId,
|
|
783
|
+
organizationId: ctx.user.activeOrganization!.id,
|
|
784
|
+
role: input.role,
|
|
785
|
+
},
|
|
786
|
+
headers: ctx.auth.headers,
|
|
787
|
+
});
|
|
788
|
+
return null;
|
|
789
|
+
});
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### List Members
|
|
793
|
+
|
|
794
|
+
```ts
|
|
795
|
+
export const listMembers = authQuery
|
|
796
|
+
.input(z.object({ slug: z.string() }))
|
|
797
|
+
.output(
|
|
798
|
+
z.object({
|
|
799
|
+
currentUserRole: z.string().optional(),
|
|
800
|
+
isPersonal: z.boolean(),
|
|
801
|
+
members: z.array(
|
|
802
|
+
z.object({
|
|
803
|
+
id: z.string(),
|
|
804
|
+
createdAt: z.date(),
|
|
805
|
+
organizationId: z.string(),
|
|
806
|
+
role: z.string(),
|
|
807
|
+
user: z.object({
|
|
808
|
+
id: z.string(),
|
|
809
|
+
email: z.string(),
|
|
810
|
+
image: z.string().nullish(),
|
|
811
|
+
name: z.string().nullable(),
|
|
812
|
+
}),
|
|
813
|
+
userId: z.string(),
|
|
814
|
+
})
|
|
815
|
+
),
|
|
816
|
+
})
|
|
817
|
+
)
|
|
818
|
+
.query(async ({ ctx, input }) => {
|
|
819
|
+
const org = await ctx.orm.query.organization.findFirst({
|
|
820
|
+
where: { slug: input.slug },
|
|
821
|
+
});
|
|
822
|
+
if (!org) return { isPersonal: false, members: [] };
|
|
823
|
+
|
|
824
|
+
const currentMember = await ctx.orm.query.member.findFirst({
|
|
825
|
+
where: { organizationId: org.id, userId: ctx.userId },
|
|
826
|
+
});
|
|
827
|
+
if (!currentMember)
|
|
828
|
+
return {
|
|
829
|
+
isPersonal: org.id === ctx.user.personalOrganizationId,
|
|
830
|
+
members: [],
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const members = await ctx.orm.query.member.findMany({
|
|
834
|
+
where: { organizationId: org.id },
|
|
835
|
+
limit: DEFAULT_LIST_LIMIT,
|
|
836
|
+
with: { user: true },
|
|
837
|
+
});
|
|
838
|
+
if (!members?.length)
|
|
839
|
+
return {
|
|
840
|
+
isPersonal: org.id === ctx.user.personalOrganizationId,
|
|
841
|
+
members: [],
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
const enriched = members
|
|
845
|
+
.map((m) => {
|
|
846
|
+
if (!m.user) return null;
|
|
847
|
+
return {
|
|
848
|
+
id: m.id,
|
|
849
|
+
createdAt: m.createdAt,
|
|
850
|
+
organizationId: org.id,
|
|
851
|
+
role: m.role,
|
|
852
|
+
user: {
|
|
853
|
+
id: m.user.id,
|
|
854
|
+
email: m.user.email,
|
|
855
|
+
image: m.user.image,
|
|
856
|
+
name: m.user.name,
|
|
857
|
+
},
|
|
858
|
+
userId: m.userId,
|
|
859
|
+
};
|
|
860
|
+
})
|
|
861
|
+
.filter((row): row is NonNullable<typeof row> => row !== null);
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
currentUserRole: currentMember.role,
|
|
865
|
+
isPersonal: org.id === ctx.user.personalOrganizationId,
|
|
866
|
+
members: enriched,
|
|
867
|
+
};
|
|
868
|
+
});
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
### Update Member Role
|
|
872
|
+
|
|
873
|
+
```ts
|
|
874
|
+
export const updateMemberRole = authMutation
|
|
875
|
+
.meta({ ratelimit: "organization/updateRole" })
|
|
876
|
+
.input(z.object({ memberId: z.string(), role: z.enum(["owner", "member"]) }))
|
|
877
|
+
|
|
878
|
+
.mutation(async ({ ctx, input }) => {
|
|
879
|
+
const m = await ctx.orm.query.member.findFirstOrThrow({
|
|
880
|
+
where: { id: input.memberId },
|
|
881
|
+
});
|
|
882
|
+
await hasPermission(ctx, {
|
|
883
|
+
organizationId: m.organizationId,
|
|
884
|
+
permissions: { member: ["update"] },
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
await ctx.auth.api.updateMemberRole({
|
|
888
|
+
body: {
|
|
889
|
+
memberId: input.memberId,
|
|
890
|
+
organizationId: m.organizationId,
|
|
891
|
+
role: input.role,
|
|
892
|
+
},
|
|
893
|
+
headers: ctx.auth.headers,
|
|
894
|
+
});
|
|
895
|
+
return null;
|
|
896
|
+
});
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
### Remove Member
|
|
900
|
+
|
|
901
|
+
```ts
|
|
902
|
+
export const removeMember = authMutation
|
|
903
|
+
.meta({ ratelimit: "organization/removeMember" })
|
|
904
|
+
.input(z.object({ memberId: z.string() }))
|
|
905
|
+
|
|
906
|
+
.mutation(async ({ ctx, input }) => {
|
|
907
|
+
const m = await ctx.orm.query.member.findFirstOrThrow({
|
|
908
|
+
where: { id: input.memberId },
|
|
909
|
+
});
|
|
910
|
+
await hasPermission(ctx, {
|
|
911
|
+
organizationId: m.organizationId,
|
|
912
|
+
permissions: { member: ["delete"] },
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
await ctx.auth.api.removeMember({
|
|
916
|
+
body: {
|
|
917
|
+
memberIdOrEmail: input.memberId,
|
|
918
|
+
organizationId: m.organizationId,
|
|
919
|
+
},
|
|
920
|
+
headers: ctx.auth.headers,
|
|
921
|
+
});
|
|
922
|
+
return null;
|
|
923
|
+
});
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### Leave Organization
|
|
927
|
+
|
|
928
|
+
```ts
|
|
929
|
+
export const leaveOrganization = authMutation
|
|
930
|
+
.meta({ ratelimit: "organization/leave" })
|
|
931
|
+
.input(z.object({ organizationId: z.string() }))
|
|
932
|
+
|
|
933
|
+
.mutation(async ({ ctx, input }) => {
|
|
934
|
+
if (input.organizationId === ctx.user.personalOrganizationId) {
|
|
935
|
+
throw new CRPCError({
|
|
936
|
+
code: "BAD_REQUEST",
|
|
937
|
+
message: "Cannot leave personal organization",
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const currentMember = await ctx.orm.query.member
|
|
942
|
+
.findFirstOrThrow({
|
|
943
|
+
where: { organizationId: input.organizationId, userId: ctx.userId },
|
|
944
|
+
})
|
|
945
|
+
.catch(() => {
|
|
946
|
+
throw new CRPCError({ code: "FORBIDDEN", message: "Not a member" });
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
if (currentMember.role === "owner") {
|
|
950
|
+
const owners = await ctx.orm.query.member.findMany({
|
|
951
|
+
where: { organizationId: input.organizationId, role: "owner" },
|
|
952
|
+
limit: 2,
|
|
953
|
+
});
|
|
954
|
+
if (owners.length <= 1) {
|
|
955
|
+
throw new CRPCError({
|
|
956
|
+
code: "FORBIDDEN",
|
|
957
|
+
message: "Cannot leave as the only owner. Transfer ownership first.",
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
await ctx.auth.api.leaveOrganization({
|
|
963
|
+
body: { organizationId: input.organizationId },
|
|
964
|
+
headers: ctx.auth.headers,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
if (input.organizationId === ctx.user.activeOrganization?.id) {
|
|
968
|
+
await setActiveOrganizationHandler(ctx, {
|
|
969
|
+
organizationId: ctx.user.personalOrganizationId!,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return null;
|
|
973
|
+
});
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
## Teams
|
|
977
|
+
|
|
978
|
+
Use Better Auth team APIs directly:
|
|
979
|
+
|
|
980
|
+
```ts
|
|
981
|
+
// List teams
|
|
982
|
+
const teams = await ctx.auth.api.listTeams({
|
|
983
|
+
query: { organizationId: ctx.user.activeOrganization!.id },
|
|
984
|
+
headers: ctx.auth.headers,
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// Add/remove member
|
|
988
|
+
await ctx.auth.api.addTeamMember({
|
|
989
|
+
body: { teamId, userId },
|
|
990
|
+
headers: ctx.auth.headers,
|
|
991
|
+
});
|
|
992
|
+
await ctx.auth.api.removeTeamMember({
|
|
993
|
+
body: { teamId, userId },
|
|
994
|
+
headers: ctx.auth.headers,
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// List team members
|
|
998
|
+
const members = await ctx.auth.api.listTeamMembers({
|
|
999
|
+
body: { teamId },
|
|
1000
|
+
headers: ctx.auth.headers,
|
|
1001
|
+
});
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
## Hooks
|
|
1005
|
+
|
|
1006
|
+
### Organization Hooks
|
|
1007
|
+
|
|
1008
|
+
```ts
|
|
1009
|
+
organization({
|
|
1010
|
+
organizationCreation: {
|
|
1011
|
+
beforeCreate: async ({ organization, user }) => { return { data: organization }; },
|
|
1012
|
+
afterCreate: async ({ organization, member, user }) => { /* setup defaults */ },
|
|
1013
|
+
},
|
|
1014
|
+
organizationDeletion: {
|
|
1015
|
+
beforeDelete: async (data) => { /* cleanup */ },
|
|
1016
|
+
afterDelete: async (data) => { /* post-cleanup */ },
|
|
1017
|
+
},
|
|
1018
|
+
}),
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
### Member Hooks
|
|
1022
|
+
|
|
1023
|
+
```ts
|
|
1024
|
+
organization({
|
|
1025
|
+
membershipManagement: {
|
|
1026
|
+
beforeAddMember: async ({ organization, member, user }) => { return { data: member }; },
|
|
1027
|
+
afterAddMember: async ({ organization, member, user }) => { /* notifications */ },
|
|
1028
|
+
beforeRemoveMember: async ({ organization, member, user }) => { /* cleanup */ },
|
|
1029
|
+
afterRemoveMember: async ({ organization, member, user }) => { /* post-removal */ },
|
|
1030
|
+
beforeUpdateRole: async ({ organization, member, role }) => { return { data: { role } }; },
|
|
1031
|
+
afterUpdateRole: async ({ organization, member, role }) => { /* notifications */ },
|
|
1032
|
+
},
|
|
1033
|
+
}),
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
### Invitation Hooks
|
|
1037
|
+
|
|
1038
|
+
```ts
|
|
1039
|
+
organization({
|
|
1040
|
+
invitationManagement: {
|
|
1041
|
+
beforeCreateInvitation: async ({ invitation, organization, inviter }) => { return { data: invitation }; },
|
|
1042
|
+
afterCreateInvitation: async ({ invitation, organization, inviter }) => { /* notify */ },
|
|
1043
|
+
beforeAcceptInvitation: async ({ invitation, user }) => { return { data: invitation }; },
|
|
1044
|
+
afterAcceptInvitation: async ({ invitation, member, user }) => { /* welcome */ },
|
|
1045
|
+
},
|
|
1046
|
+
}),
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
### Team Hooks
|
|
1050
|
+
|
|
1051
|
+
```ts
|
|
1052
|
+
organization({
|
|
1053
|
+
teamManagement: {
|
|
1054
|
+
beforeCreateTeam: async ({ team, organization }) => { return { data: team }; },
|
|
1055
|
+
afterCreateTeam: async ({ team, organization }) => {},
|
|
1056
|
+
beforeAddTeamMember: async ({ team, user }) => { return { data: { team, user } }; },
|
|
1057
|
+
afterAddTeamMember: async ({ team, user }) => {},
|
|
1058
|
+
},
|
|
1059
|
+
}),
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
## Client Usage
|
|
1063
|
+
|
|
1064
|
+
### React Hooks
|
|
1065
|
+
|
|
1066
|
+
```tsx
|
|
1067
|
+
const { data: activeOrg } = authClient.useActiveOrganization();
|
|
1068
|
+
const { data: orgs } = authClient.useListOrganizations();
|
|
1069
|
+
authClient.organization.setActive({ organizationId: orgId });
|
|
1070
|
+
authClient.organization.create({ name: "New Org", slug: "new-org" });
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
### Get Full Organization
|
|
1074
|
+
|
|
1075
|
+
```ts
|
|
1076
|
+
const { data: fullOrg } = await authClient.organization.getFullOrganization({
|
|
1077
|
+
query: { organizationId: orgId },
|
|
1078
|
+
});
|
|
1079
|
+
// Returns: organization + members + invitations
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
### Invitation Operations (Client)
|
|
1083
|
+
|
|
1084
|
+
```ts
|
|
1085
|
+
const { data: invitations } = await authClient.organization.listInvitations();
|
|
1086
|
+
await authClient.organization.acceptInvitation({ invitationId });
|
|
1087
|
+
await authClient.organization.rejectInvitation({ invitationId });
|
|
1088
|
+
await authClient.organization.cancelInvitation({ invitationId });
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
### Member Operations (Client)
|
|
1092
|
+
|
|
1093
|
+
```ts
|
|
1094
|
+
const { data: member } = await authClient.organization.getActiveMember();
|
|
1095
|
+
await authClient.organization.leave();
|
|
1096
|
+
await authClient.organization.removeMember({ memberIdOrEmail });
|
|
1097
|
+
await authClient.organization.updateMemberRole({ memberId, role: "admin" });
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
### Permission Check (Client)
|
|
1101
|
+
|
|
1102
|
+
```ts
|
|
1103
|
+
const { data } = await authClient.organization.hasPermission({
|
|
1104
|
+
permissions: { organization: ["delete"] },
|
|
1105
|
+
});
|
|
1106
|
+
if (data?.success) {
|
|
1107
|
+
/* show delete button */
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
### Team Operations (Client)
|
|
1112
|
+
|
|
1113
|
+
```ts
|
|
1114
|
+
const { data: teams } = await authClient.organization.listTeams();
|
|
1115
|
+
await authClient.organization.createTeam({ name: "Engineering" });
|
|
1116
|
+
await authClient.organization.setActiveTeam({ teamId });
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
## API Reference
|
|
1120
|
+
|
|
1121
|
+
| Operation | Method | Multi-table |
|
|
1122
|
+
| ------------------ | --------------- | ----------- |
|
|
1123
|
+
| Create org | Better Auth API | Yes |
|
|
1124
|
+
| Update org | Better Auth API | No |
|
|
1125
|
+
| Delete org | Better Auth API | Yes |
|
|
1126
|
+
| List orgs | ORM | No |
|
|
1127
|
+
| Check slug | ORM | No |
|
|
1128
|
+
| Invite member | Better Auth API | Yes |
|
|
1129
|
+
| Accept invite | Better Auth API | Yes |
|
|
1130
|
+
| Reject invite | Better Auth API | Yes |
|
|
1131
|
+
| Cancel invite | Better Auth API | Yes |
|
|
1132
|
+
| List user invites | ORM | No |
|
|
1133
|
+
| Add member | Better Auth API | Yes |
|
|
1134
|
+
| Update role | Better Auth API | Yes |
|
|
1135
|
+
| Remove member | Better Auth API | Yes |
|
|
1136
|
+
| Leave org | Better Auth API | Yes |
|
|
1137
|
+
| Create team | Better Auth API | Yes |
|
|
1138
|
+
| Add team member | Better Auth API | Yes |
|
|
1139
|
+
| Remove team member | Better Auth API | Yes |
|
|
1140
|
+
|
|
1141
|
+
Use Better Auth API for multi-table operations. Use `ctx.orm` for simple single-table reads/updates.
|