Skip to content

Commit 93698af

Browse files
authored
feat(organization): additionalFields for org, member, invitation & team (#3447)
* feat(org): additionalFields for org, member, invitation & team * fix: tests * chore: lint * add: docs * update(docs): improve wording * feat: support client side inference * chore: lint
1 parent 5a24661 commit 93698af

File tree

14 files changed

+2291
-1553
lines changed

14 files changed

+2291
-1553
lines changed

docs/content/docs/plugins/organization.mdx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,6 +1474,14 @@ const auth = betterAuth({
14741474
modelName: "organizations", //map the organization table to organizations
14751475
fields: {
14761476
name: "title" //map the name field to title
1477+
},
1478+
additionalFields: {
1479+
// Add a new field to the organization table
1480+
myCustomField: {
1481+
type: "string",
1482+
input: true,
1483+
required: false
1484+
}
14771485
}
14781486
}
14791487
}
@@ -1482,6 +1490,33 @@ const auth = betterAuth({
14821490
})
14831491
```
14841492

1493+
#### Additional Fields
1494+
1495+
Starting with Better Auth v1.3, you can easily add custom fields to the `organization`, `invitation`, `member`, and `team` tables.
1496+
1497+
Refer to the [example above](#customizing-the-schema) to learn how to define additional fields in your schema configuration.
1498+
1499+
When you add extra fields to a model, the relevant API endpoints will automatically
1500+
accept and return these new properties. For instance, if you add a custom field to the `organization` table,
1501+
the `createOrganization` endpoint will include this field in its request and response payloads as needed.
1502+
1503+
<Callout>
1504+
What about the `metadata` field?
1505+
1506+
The `metadata` field is still supported for backward compatibility,
1507+
and provides a convenient way to attach arbitrary data to a row.
1508+
</Callout>
1509+
1510+
To infer additional fields on the client, you must pass the `auth` instance to the `organizationClient` function.
1511+
1512+
```ts title="auth-client.ts"
1513+
createAuthClient({
1514+
plugins: [organizationClient<typeof auth>()]
1515+
})
1516+
```
1517+
1518+
1519+
14851520
## Options
14861521

14871522
**allowUserToCreateOrganization**: `boolean` | `((user: User) => Promise<boolean> | boolean)` - A function that determines whether a user can create an organization. By default, it's `true`. You can set it to `false` to restrict users from creating organizations.

packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { logger } from "../../utils";
12
import {
23
createAdapter,
34
type AdapterDebugLogs,
@@ -30,8 +31,16 @@ export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
3031
return props.data;
3132
},
3233
},
33-
adapter: ({ getFieldName, options }) => {
34-
function convertWhereClause(where: CleanedWhere[], table: any[]) {
34+
adapter: ({ getFieldName, options, debugLog }) => {
35+
function convertWhereClause(where: CleanedWhere[], model: string) {
36+
const table = db[model];
37+
if (!table) {
38+
logger.error(
39+
`[MemoryAdapter] Model ${model} not found in the DB`,
40+
Object.keys(db),
41+
);
42+
throw new Error(`Model ${model} not found`);
43+
}
3544
return table.filter((record) => {
3645
return where.every((clause) => {
3746
let { field, value, operator } = clause;
@@ -60,19 +69,21 @@ export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
6069
// @ts-ignore
6170
data.id = db[model].length + 1;
6271
}
72+
if (!db[model]) {
73+
db[model] = [];
74+
}
6375
db[model].push(data);
6476
return data;
6577
},
6678
findOne: async ({ model, where }) => {
67-
const table = db[model];
68-
const res = convertWhereClause(where, table);
79+
const res = convertWhereClause(where, model);
6980
const record = res[0] || null;
7081
return record;
7182
},
7283
findMany: async ({ model, where, sortBy, limit, offset }) => {
7384
let table = db[model];
7485
if (where) {
75-
table = convertWhereClause(where, table);
86+
table = convertWhereClause(where, model);
7687
}
7788
if (sortBy) {
7889
table = table.sort((a, b) => {
@@ -96,21 +107,20 @@ export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
96107
return db[model].length;
97108
},
98109
update: async ({ model, where, update }) => {
99-
const table = db[model];
100-
const res = convertWhereClause(where, table);
110+
const res = convertWhereClause(where, model);
101111
res.forEach((record) => {
102112
Object.assign(record, update);
103113
});
104114
return res[0] || null;
105115
},
106116
delete: async ({ model, where }) => {
107117
const table = db[model];
108-
const res = convertWhereClause(where, table);
118+
const res = convertWhereClause(where, model);
109119
db[model] = table.filter((record) => !res.includes(record));
110120
},
111121
deleteMany: async ({ model, where }) => {
112122
const table = db[model];
113-
const res = convertWhereClause(where, table);
123+
const res = convertWhereClause(where, model);
114124
let count = 0;
115125
db[model] = table.filter((record) => {
116126
if (res.includes(record)) {
@@ -122,8 +132,7 @@ export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
122132
return count;
123133
},
124134
updateMany({ model, where, update }) {
125-
const table = db[model];
126-
const res = convertWhereClause(where, table);
135+
const res = convertWhereClause(where, model);
127136
res.forEach((record) => {
128137
Object.assign(record, update);
129138
});

packages/better-auth/src/db/field.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,61 @@ type InferFieldOutput<T extends FieldAttribute> = T["returned"] extends false
200200
? InferValueType<T["type"]> | undefined | null
201201
: InferValueType<T["type"]>;
202202

203+
/**
204+
* Converts a Record<string, FieldAttribute> to an object type
205+
* with keys and value types inferred from FieldAttribute["type"].
206+
*/
207+
export type FieldAttributeToObject<
208+
Fields extends Record<string, FieldAttribute>,
209+
> = AddOptionalFields<
210+
{
211+
[K in keyof Fields]: InferValueType<Fields[K]["type"]>;
212+
},
213+
Fields
214+
>;
215+
216+
type AddOptionalFields<
217+
T extends Record<string, any>,
218+
Fields extends Record<keyof T, FieldAttribute>,
219+
> = {
220+
// Required fields: required === true
221+
[K in keyof T as Fields[K] extends { required: true } ? K : never]: T[K];
222+
} & {
223+
// Optional fields: required !== true
224+
[K in keyof T as Fields[K] extends { required: true } ? never : K]?: T[K];
225+
};
226+
227+
/**
228+
* Infer the additional fields from the plugin options.
229+
* For example, you can infer the additional fields of the org plugin's organization schema like this:
230+
* ```ts
231+
* type AdditionalFields = InferAdditionalFieldsFromPluginOptions<"organization", OrganizationOptions>
232+
* ```
233+
*/
234+
export type InferAdditionalFieldsFromPluginOptions<
235+
SchemaName extends string,
236+
Options extends {
237+
schema?: {
238+
[key in SchemaName]?: {
239+
additionalFields?: Record<string, FieldAttribute>;
240+
};
241+
};
242+
},
243+
isClientSide extends boolean = true,
244+
> = Options["schema"] extends {
245+
[key in SchemaName]?: {
246+
additionalFields: infer Field extends Record<string, FieldAttribute>;
247+
};
248+
}
249+
? isClientSide extends true
250+
? FieldAttributeToObject<RemoveFieldsWithInputFalse<Field>>
251+
: FieldAttributeToObject<Field>
252+
: {};
253+
254+
type RemoveFieldsWithInputFalse<T extends Record<string, FieldAttribute>> = {
255+
[K in keyof T as T[K]["input"] extends false ? never : K]: T[K];
256+
};
257+
203258
type InferFieldInput<T extends FieldAttribute> = InferValueType<T["type"]>;
204259

205260
export type PluginFieldAttribute = Omit<

packages/better-auth/src/db/to-zod.ts

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,101 @@ import * as z from "zod/v4";
22
import type { ZodSchema } from "zod/v4";
33
import type { FieldAttribute } from ".";
44

5-
export function toZodSchema(fields: Record<string, FieldAttribute>) {
6-
const schema = z.object({
7-
...Object.keys(fields).reduce((acc, key) => {
8-
const field = fields[key];
9-
if (!field) {
10-
return acc;
11-
}
12-
if (field.type === "string[]" || field.type === "number[]") {
13-
return {
14-
...acc,
15-
[key]: z.array(field.type === "string[]" ? z.string() : z.number()),
16-
};
17-
}
18-
if (Array.isArray(field.type)) {
19-
return {
20-
...acc,
21-
[key]: z.any(),
22-
};
23-
}
24-
let schema: ZodSchema = z[field.type]();
25-
if (field?.required === false) {
26-
schema = schema.optional();
27-
}
28-
if (field?.returned === false) {
29-
return acc;
30-
}
5+
export function toZodSchema<
6+
Fields extends Record<string, FieldAttribute | never>,
7+
IsClientSide extends boolean,
8+
>({
9+
fields,
10+
isClientSide,
11+
}: {
12+
fields: Fields;
13+
/**
14+
* If true, then any fields that have `input: false` will be removed from the schema to prevent user input.
15+
*/
16+
isClientSide: IsClientSide;
17+
}) {
18+
const zodFields = Object.keys(fields).reduce((acc, key) => {
19+
const field = fields[key];
20+
if (!field) {
21+
return acc;
22+
}
23+
if (isClientSide && field.input === false) {
24+
return acc;
25+
}
26+
if (field.type === "string[]" || field.type === "number[]") {
3127
return {
3228
...acc,
33-
[key]: schema,
29+
[key]: z.array(field.type === "string[]" ? z.string() : z.number()),
3430
};
35-
}, {}),
36-
});
37-
return schema;
31+
}
32+
if (Array.isArray(field.type)) {
33+
return {
34+
...acc,
35+
[key]: z.any(),
36+
};
37+
}
38+
let schema: ZodSchema = z[field.type]();
39+
if (field?.required === false) {
40+
schema = schema.optional();
41+
}
42+
if (field?.returned === false) {
43+
return acc;
44+
}
45+
return {
46+
...acc,
47+
[key]: schema,
48+
};
49+
}, {});
50+
const schema = z.object(zodFields);
51+
return schema as z.ZodObject<
52+
RemoveNeverProps<{
53+
[key in keyof Fields]: FieldAttributeToSchema<Fields[key], IsClientSide>;
54+
}>,
55+
z.core.$strip
56+
>;
57+
}
58+
59+
export type FieldAttributeToSchema<
60+
Field extends FieldAttribute | Record<string, never>,
61+
// if it's client side, then field attributes of `input` that are false should be removed
62+
isClientSide extends boolean = false,
63+
> = Field extends { type: any }
64+
? GetInput<isClientSide, Field, GetRequired<Field, GetType<Field>>>
65+
: Record<string, never>;
66+
67+
type GetType<F extends FieldAttribute> = F extends {
68+
type: "string";
3869
}
70+
? z.ZodString
71+
: F extends { type: "number" }
72+
? z.ZodNumber
73+
: F extends { type: "boolean" }
74+
? z.ZodBoolean
75+
: F extends { type: "date" }
76+
? z.ZodDate
77+
: z.ZodAny;
78+
79+
type GetRequired<
80+
F extends FieldAttribute,
81+
Schema extends z.core.SomeType,
82+
> = F extends {
83+
required: true;
84+
}
85+
? Schema
86+
: z.ZodOptional<Schema>;
87+
88+
type GetInput<
89+
isClientSide extends boolean,
90+
Field extends FieldAttribute,
91+
Schema extends z.core.SomeType,
92+
> = Field extends {
93+
input: false;
94+
}
95+
? isClientSide extends true
96+
? never
97+
: Schema
98+
: Schema;
99+
100+
type RemoveNeverProps<T> = {
101+
[K in keyof T as [T[K]] extends [never] ? never : K]: T[K];
102+
};

0 commit comments

Comments
 (0)