From 8014648dffa6799990eb29983d713c0cf48db954 Mon Sep 17 00:00:00 2001
From: Bereket Engida <86073083+Bekacru@users.noreply.github.com>
Date: Mon, 18 Nov 2024 14:26:16 +0300
Subject: [PATCH] feat: custom roles (#565)
---
demo/nextjs/lib/auth.ts | 7 +
docs/content/docs/plugins/organization.mdx | 81 ++++++-
packages/better-auth/src/cookies/index.ts | 4 +-
.../better-auth/src/integrations/next-js.ts | 3 -
.../better-auth/src/plugins/access/access.ts | 97 ---------
.../better-auth/src/plugins/access/types.ts | 21 --
.../better-auth/src/plugins/access/utils.ts | 5 -
.../src/plugins/multi-session/client.ts | 1 -
.../better-auth/src/plugins/one-tap/index.ts | 1 +
.../src/plugins/organization/access/access.ts | 73 +++++++
.../{ => organization}/access/index.ts | 1 -
.../{ => organization}/access/statement.ts | 9 +-
.../src/plugins/organization/access/types.ts | 18 ++
.../src/plugins/organization/adapter.ts | 3 +-
.../src/plugins/organization/call.ts | 2 +-
.../src/plugins/organization/client.ts | 58 +++--
.../plugins/organization/organization.test.ts | 93 ++++++++
.../src/plugins/organization/organization.ts | 33 +--
.../organization/routes/crud-invites.ts | 202 +++++++++---------
.../organization/routes/crud-members.ts | 160 +++++++-------
.../plugins/organization/routes/crud-org.ts | 4 +
.../src/plugins/organization/schema.ts | 11 +-
packages/better-auth/tsup.config.ts | 2 +-
23 files changed, 532 insertions(+), 357 deletions(-)
delete mode 100644 packages/better-auth/src/plugins/access/access.ts
delete mode 100644 packages/better-auth/src/plugins/access/types.ts
delete mode 100644 packages/better-auth/src/plugins/access/utils.ts
create mode 100644 packages/better-auth/src/plugins/organization/access/access.ts
rename packages/better-auth/src/plugins/{ => organization}/access/index.ts (76%)
rename packages/better-auth/src/plugins/{ => organization}/access/statement.ts (75%)
create mode 100644 packages/better-auth/src/plugins/organization/access/types.ts
diff --git a/demo/nextjs/lib/auth.ts b/demo/nextjs/lib/auth.ts
index 6406d20a59..2b3f3b7a1c 100644
--- a/demo/nextjs/lib/auth.ts
+++ b/demo/nextjs/lib/auth.ts
@@ -16,6 +16,7 @@ import { resend } from "./email/resend";
import { MysqlDialect } from "kysely";
import { createPool } from "mysql2/promise";
import { nextCookies } from "better-auth/next-js";
+import * as ac from "./access-control";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const to = process.env.TEST_EMAIL || "";
@@ -111,6 +112,12 @@ export const auth = betterAuth({
},
plugins: [
organization({
+ ac: ac.ac,
+ roles: {
+ admin: ac.admin,
+ owner: ac.owner,
+ member: ac.member,
+ },
async sendInvitationEmail(data) {
const res = await resend.emails.send({
from,
diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx
index ad3c6c9110..8cd4801a48 100644
--- a/docs/content/docs/plugins/organization.mdx
+++ b/docs/content/docs/plugins/organization.mdx
@@ -400,7 +400,7 @@ The organization plugin providers a very flexible access control system. You can
### Roles
-Currently only three roles are supported:
+By default, there are three roles in the organization:
`owner`: The user who created the organization by default. The owner has full control over the organization and can perform any action.
@@ -408,9 +408,6 @@ Currently only three roles are supported:
`member`: Users with the member role have limited control over the organization. They can create projects, invite users, and manage projects they have created.
-
- Currently, you can't create custom roles
-
### Permissions
@@ -434,7 +431,6 @@ The owner have full control over all the resources and actions. The admin have f
the plugin providers easy way to define your own set of permission for each role.
-
#### Create Access Control
@@ -479,6 +475,29 @@ the plugin providers easy way to define your own set of permission for each role
const owner = ac.newRole({ // [!code highlight]
project: ["create", "update", "delete"], // [!code highlight]
}); // [!code highlight]
+
+ const myCustomRole = ac.newRole({ // [!code highlight]
+ project: ["create", "update", "delete"], // [!code highlight]
+ organization: ["update"], // [!code highlight]
+ }); // [!code highlight]
+ ```
+
+ When you create custom roles for existing roles, the predefined permissions for those roles will be overridden. To add the existing permissions to the custom role, you need to import `defaultStatement` and merge it with your new statement, plus merge the roles' permissions set with the default roles.
+
+ ```ts title="permissions.ts"
+ import { createAccessControl, defaultStatement, adminAc } from "better-auth/plugins/access";
+
+ const statement = {
+ ...defaultStatements, // [!code highlight]
+ project: ["create", "share", "update", "delete"],
+ } as const;
+
+ const ac = createAccessControl(statement);
+
+ const admin = ac.newRole({
+ project: ["create", "update"],
+ ...adminAc.statements, // [!code highlight]
+ });
```
@@ -500,14 +519,15 @@ the plugin providers easy way to define your own set of permission for each role
roles: {
owner,
admin,
- member
+ member,
+ myCustomRole
}
}),
],
});
```
- You also need to pass the access controller to the client plugin. This is useful for type checking and auto-completion.
+ You also need to pass the access controller and the roles to the client plugin.
```ts title="auth-client"
import { createAuthClient } from "better-auth/client"
@@ -518,6 +538,12 @@ the plugin providers easy way to define your own set of permission for each role
plugins: [
organizationClient({
ac: ac,
+ roles: {
+ owner,
+ admin,
+ member,
+ myCustomRole
+ }
})
]
})
@@ -526,6 +552,47 @@ the plugin providers easy way to define your own set of permission for each role
+### Access Control Usage
+
+**Has Permisson**:
+
+You can use the `hasPermission` action provided by the `api` to check the permission of the user.
+
+```ts title="api.ts"
+import { auth } from "@/auth";
+
+auth.api.hasPermission({
+ role: "admin",
+ permission: {
+ project: ["create"]
+ }
+})
+```
+
+If you want to check the permission of the user on the client from the server you can use the `hasPermission` function provided by the client.
+
+```ts title="client.ts"
+const canCreateProject = await client.organization.hasPermission({
+ role: "admin",
+ permission: {
+ project: ["create"]
+ }
+})
+```
+
+**Check Role Permission**:
+
+Once you have defined the roles and permissions to avoid checking the permission from the server you can use the `checkRolePermission` function provided by the client.
+
+```ts title="client.ts"
+const canCreateProject = client.organization.checkRolePermission({
+ permission: {
+ organization: ["delete"],
+ },
+ role: "admin",
+});
+```
+
## Schema
The organization plugin adds the following tables to the database:
diff --git a/packages/better-auth/src/cookies/index.ts b/packages/better-auth/src/cookies/index.ts
index 96ea25ca06..2d497ce04f 100644
--- a/packages/better-auth/src/cookies/index.ts
+++ b/packages/better-auth/src/cookies/index.ts
@@ -102,16 +102,16 @@ export async function setSessionCookie(
overrides?: Partial,
) {
const options = ctx.context.authCookies.sessionToken.options;
- options.maxAge = dontRememberMe
+ const maxAge = dontRememberMe
? undefined
: ctx.context.sessionConfig.expiresIn;
-
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
session.session.id,
ctx.context.secret,
{
...options,
+ maxAge,
...overrides,
},
);
diff --git a/packages/better-auth/src/integrations/next-js.ts b/packages/better-auth/src/integrations/next-js.ts
index 1772ed8cc6..f4f7559a49 100644
--- a/packages/better-auth/src/integrations/next-js.ts
+++ b/packages/better-auth/src/integrations/next-js.ts
@@ -51,9 +51,6 @@ export const nextCookies = () => {
if (process.env.NODE_ENV !== "development") {
return;
}
- ctx.context.logger.warn(
- `Unable to set cookie "${key}". This likely occurred while calling "auth.api" action that is supposed to set cookies in an unsupported context, such as a Server Component. Make sure this function is called within a Server Action, a proper server-side context, or use the client SDK. Ignore this message if intentional. (Development mode only).`,
- );
}
});
return;
diff --git a/packages/better-auth/src/plugins/access/access.ts b/packages/better-auth/src/plugins/access/access.ts
deleted file mode 100644
index 582b131b93..0000000000
--- a/packages/better-auth/src/plugins/access/access.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import type { StatementsPrimitive as Statements, Subset } from "./types";
-
-export class ParsingError extends Error {
- public readonly path: string;
- constructor(message: string, path: string) {
- super(message);
- this.path = path;
- }
-}
-
-type Connector = "OR" | "AND";
-
-export class AccessControl {
- private readonly statements: TStatements;
- constructor(private readonly s: TStatements) {
- this.statements = s;
- }
- public newRole(
- statements: Subset,
- ) {
- return new Role>(statements);
- }
-}
-
-export type AuthortizeResponse =
- | { success: false; error: string }
- | { success: true; error?: never };
-
-export class Role {
- public readonly statements: TStatements;
-
- constructor(statements: TStatements) {
- this.statements = statements;
- }
-
- public authorize(
- request: Subset,
- connector?: Connector,
- ): AuthortizeResponse {
- for (const [requestedResource, requestedActions] of Object.entries(
- request,
- )) {
- const allowedActions = this.statements[requestedResource];
- if (!allowedActions) {
- return {
- success: false,
- error: `You are not allowed to access resource: ${requestedResource}`,
- };
- }
- const success =
- connector === "OR"
- ? (requestedActions as string[]).some((requestedAction) =>
- allowedActions.includes(requestedAction),
- )
- : (requestedActions as string[]).every((requestedAction) =>
- allowedActions.includes(requestedAction),
- );
- if (success) {
- return { success };
- }
- return {
- success: false,
- error: `unauthorized to access resource "${requestedResource}"`,
- };
- }
- return {
- success: false,
- error: "Not authorized",
- };
- }
-
- static fromString(s: string) {
- const statements = JSON.parse(s) as TStatements;
-
- if (typeof statements !== "object") {
- throw new ParsingError("statements is not an object", ".");
- }
- for (const [resource, actions] of Object.entries(statements)) {
- if (typeof resource !== "string") {
- throw new ParsingError("invalid resource identifier", resource);
- }
- if (!Array.isArray(actions)) {
- throw new ParsingError("actions is not an array", resource);
- }
- for (let i = 0; i < actions.length; i++) {
- if (typeof actions[i] !== "string") {
- throw new ParsingError("action is not a string", `${resource}[${i}]`);
- }
- }
- }
- return new Role(statements);
- }
-
- public toString(): string {
- return JSON.stringify(this.statements);
- }
-}
diff --git a/packages/better-auth/src/plugins/access/types.ts b/packages/better-auth/src/plugins/access/types.ts
deleted file mode 100644
index 0917bad30a..0000000000
--- a/packages/better-auth/src/plugins/access/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { LiteralString } from "../../types/helper";
-
-// Transforms an array into any combination of 0 or more of its members
-export type SubArray =
- T[number][];
-
-// Defines a subset similar to Partial, but keys are not optional
-// instead they are either present or not
-export type Subset<
- K extends keyof R,
- R extends Record<
- string | LiteralString,
- readonly string[] | readonly LiteralString[]
- >,
-> = {
- [P in K]: SubArray;
-};
-
-export type StatementsPrimitive = {
- readonly [resource: string]: readonly LiteralString[]; //| undefined
-};
diff --git a/packages/better-auth/src/plugins/access/utils.ts b/packages/better-auth/src/plugins/access/utils.ts
deleted file mode 100644
index 53c95a28c8..0000000000
--- a/packages/better-auth/src/plugins/access/utils.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Role } from "./access";
-
-export const permissionFromString = (permission?: string) => {
- return Role.fromString(permission ?? "");
-};
diff --git a/packages/better-auth/src/plugins/multi-session/client.ts b/packages/better-auth/src/plugins/multi-session/client.ts
index d15b0036b6..ab4aa5e01e 100644
--- a/packages/better-auth/src/plugins/multi-session/client.ts
+++ b/packages/better-auth/src/plugins/multi-session/client.ts
@@ -5,7 +5,6 @@ export const multiSessionClient = () => {
return {
id: "multi-session",
$InferServerPlugin: {} as ReturnType,
-
atomListeners: [
{
matcher(path) {
diff --git a/packages/better-auth/src/plugins/one-tap/index.ts b/packages/better-auth/src/plugins/one-tap/index.ts
index 012831c730..1e3fbe4b10 100644
--- a/packages/better-auth/src/plugins/one-tap/index.ts
+++ b/packages/better-auth/src/plugins/one-tap/index.ts
@@ -83,6 +83,7 @@ export const oneTap = (options?: OneTapOptions) =>
user.user.id,
c.request,
);
+
await setSessionCookie(c, {
user: user.user,
session,
diff --git a/packages/better-auth/src/plugins/organization/access/access.ts b/packages/better-auth/src/plugins/organization/access/access.ts
new file mode 100644
index 0000000000..3234daec0c
--- /dev/null
+++ b/packages/better-auth/src/plugins/organization/access/access.ts
@@ -0,0 +1,73 @@
+import type { StatementsPrimitive as Statements, Subset } from "./types";
+
+type Connector = "OR" | "AND";
+
+type AuthorizeResponse =
+ | { success: false; error: string }
+ | { success: true; error?: undefined };
+
+function parsingError(message: string, path: string): Error {
+ const error = new Error(message);
+ (error as any).path = path;
+ return error;
+}
+
+export function createAccessControl<
+ TStatements extends Statements = Statements,
+>(s: TStatements) {
+ const statements = s;
+ return {
+ newRole(statements: Subset) {
+ return role>(statements);
+ },
+ };
+}
+
+export function role(statements: TStatements) {
+ return {
+ statements,
+ authorize(
+ request: Subset,
+ connector?: Connector,
+ ): AuthorizeResponse {
+ for (const [requestedResource, requestedActions] of Object.entries(
+ request,
+ )) {
+ const allowedActions = statements[requestedResource];
+ if (!allowedActions) {
+ return {
+ success: false,
+ error: `You are not allowed to access resource: ${requestedResource}`,
+ };
+ }
+ const success =
+ connector === "OR"
+ ? (requestedActions as string[]).some((requestedAction) =>
+ allowedActions.includes(requestedAction),
+ )
+ : (requestedActions as string[]).every((requestedAction) =>
+ allowedActions.includes(requestedAction),
+ );
+ if (success) {
+ return { success: true };
+ }
+ return {
+ success: false,
+ error: `Unauthorized to access resource "${requestedResource}"`,
+ };
+ }
+ return {
+ success: false,
+ error: "Not authorized",
+ };
+ },
+ };
+}
+
+export type AccessControl =
+ ReturnType>;
+
+export type Role> = {
+ authorize: (request: any, connector?: Connector) => AuthorizeResponse;
+ statements: TStatements;
+};
diff --git a/packages/better-auth/src/plugins/access/index.ts b/packages/better-auth/src/plugins/organization/access/index.ts
similarity index 76%
rename from packages/better-auth/src/plugins/access/index.ts
rename to packages/better-auth/src/plugins/organization/access/index.ts
index 3c715c6c06..d70c8dab56 100644
--- a/packages/better-auth/src/plugins/access/index.ts
+++ b/packages/better-auth/src/plugins/organization/access/index.ts
@@ -1,4 +1,3 @@
export * from "./access";
export * from "./types";
export * from "./statement";
-export * from "./utils";
diff --git a/packages/better-auth/src/plugins/access/statement.ts b/packages/better-auth/src/plugins/organization/access/statement.ts
similarity index 75%
rename from packages/better-auth/src/plugins/access/statement.ts
rename to packages/better-auth/src/plugins/organization/access/statement.ts
index 530b96d285..6c9be70847 100644
--- a/packages/better-auth/src/plugins/access/statement.ts
+++ b/packages/better-auth/src/plugins/organization/access/statement.ts
@@ -1,11 +1,4 @@
-import { AccessControl } from "./access";
-import type { StatementsPrimitive } from "./types";
-
-export const createAccessControl = (
- statements: S,
-) => {
- return new AccessControl(statements);
-};
+import { createAccessControl } from "./access";
export const defaultStatements = {
organization: ["update", "delete"],
diff --git a/packages/better-auth/src/plugins/organization/access/types.ts b/packages/better-auth/src/plugins/organization/access/types.ts
new file mode 100644
index 0000000000..c4fc9e67df
--- /dev/null
+++ b/packages/better-auth/src/plugins/organization/access/types.ts
@@ -0,0 +1,18 @@
+import type { LiteralString } from "../../../types/helper";
+
+export type SubArray =
+ T[number][];
+
+export type Subset<
+ K extends keyof R,
+ R extends Record<
+ string | LiteralString,
+ readonly string[] | readonly LiteralString[]
+ >,
+> = {
+ [P in K]: SubArray;
+};
+
+export type StatementsPrimitive = {
+ readonly [resource: string]: readonly LiteralString[];
+};
diff --git a/packages/better-auth/src/plugins/organization/adapter.ts b/packages/better-auth/src/plugins/organization/adapter.ts
index d426c82f2a..3840f80335 100644
--- a/packages/better-auth/src/plugins/organization/adapter.ts
+++ b/packages/better-auth/src/plugins/organization/adapter.ts
@@ -1,4 +1,3 @@
-import type { Kysely } from "kysely";
import type { Session, User } from "../../db/schema";
import { getDate } from "../../utils/date";
import { generateId } from "../../utils/id";
@@ -382,7 +381,7 @@ export const getOrgAdapter = (
}: {
invitation: {
email: string;
- role: "admin" | "member" | "owner";
+ role: string;
organizationId: string;
};
user: User;
diff --git a/packages/better-auth/src/plugins/organization/call.ts b/packages/better-auth/src/plugins/organization/call.ts
index 0f94e59416..fe55153886 100644
--- a/packages/better-auth/src/plugins/organization/call.ts
+++ b/packages/better-auth/src/plugins/organization/call.ts
@@ -2,7 +2,7 @@ import { type Context } from "better-call";
import type { Session, User } from "../../db/schema";
import { createAuthMiddleware } from "../../api/call";
import { sessionMiddleware } from "../../api";
-import type { Role, defaultRoles } from "../access";
+import type { Role, defaultRoles } from "./access";
import type { OrganizationOptions } from "./organization";
export const orgMiddleware = createAuthMiddleware(async (ctx) => {
diff --git a/packages/better-auth/src/plugins/organization/client.ts b/packages/better-auth/src/plugins/organization/client.ts
index c72f3ca04f..345c7a934d 100644
--- a/packages/better-auth/src/plugins/organization/client.ts
+++ b/packages/better-auth/src/plugins/organization/client.ts
@@ -5,16 +5,22 @@ import type {
Organization,
} from "../../plugins/organization/schema";
import type { Prettify } from "../../types/helper";
-import { defaultStatements, type AccessControl, type Role } from "../access";
+import {
+ adminAc,
+ defaultStatements,
+ memberAc,
+ ownerAc,
+ type AccessControl,
+ type Role,
+} from "./access";
import type { BetterAuthClientPlugin } from "../../client/types";
import type { organization } from "./organization";
-import type { BetterFetchOption } from "@better-fetch/fetch";
import { useAuthQuery } from "../../client";
interface OrganizationClientOptions {
ac: AccessControl;
- roles?: {
- [key in "admin" | "member" | "owner"]?: Role;
+ roles: {
+ [key in string]: Role;
};
}
@@ -31,9 +37,28 @@ export const organizationClient = (
? S & DefaultStatements
: DefaultStatements
: DefaultStatements;
+ const roles = {
+ admin: adminAc,
+ member: memberAc,
+ owner: ownerAc,
+ ...options?.roles,
+ };
return {
id: "organization",
- $InferServerPlugin: {} as ReturnType,
+ $InferServerPlugin: {} as ReturnType<
+ typeof organization<{
+ ac: O["ac"] extends AccessControl
+ ? O["ac"]
+ : AccessControl;
+ roles: O["roles"] extends Record
+ ? O["roles"]
+ : {
+ admin: Role;
+ member: Role;
+ owner: Role;
+ };
+ }>
+ >,
getActions: ($fetch) => ({
$Infer: {
ActiveOrganization: {} as Prettify<
@@ -56,22 +81,23 @@ export const organizationClient = (
Member: {} as Member,
},
organization: {
- hasPermission: async (data: {
+ checkRolePermission: <
+ R extends O extends { roles: any }
+ ? keyof O["roles"]
+ : "admin" | "member" | "owner",
+ >(data: {
+ role: R;
permission: Partial<{
//@ts-expect-error fix this later
[key in keyof Statements]: Statements[key][number][];
}>;
- fetchOptions?: BetterFetchOption;
}) => {
- return await $fetch<{
- success: boolean;
- }>("/organization/has-permission", {
- method: "POST",
- body: {
- permission: data.permission,
- },
- ...data.fetchOptions,
- });
+ const role = roles[data.role as unknown as "admin"];
+ if (!role) {
+ return false;
+ }
+ const isAuthorized = role?.authorize(data.permission as any);
+ return isAuthorized.success;
},
},
}),
diff --git a/packages/better-auth/src/plugins/organization/organization.test.ts b/packages/better-auth/src/plugins/organization/organization.test.ts
index 58b4b5f2a0..fd21508e66 100644
--- a/packages/better-auth/src/plugins/organization/organization.test.ts
+++ b/packages/better-auth/src/plugins/organization/organization.test.ts
@@ -3,6 +3,7 @@ import { getTestInstance } from "../../test-utils/test-instance";
import { organization } from "./organization";
import { createAuthClient } from "../../client";
import { organizationClient } from "./client";
+import { createAccessControl } from "./access";
describe("organization", async (it) => {
const { auth, signInWithTestUser, signInWithUser } = await getTestInstance({
@@ -296,3 +297,95 @@ describe("organization", async (it) => {
expectTypeOf(auth.api.getInvitation).toBeFunction();
});
});
+
+describe("access control", async (it) => {
+ const ac = createAccessControl({
+ project: ["create", "read", "update", "delete"],
+ });
+ const owner = ac.newRole({
+ project: ["create", "delete", "update", "read"],
+ });
+ const admin = ac.newRole({
+ project: ["create", "read"],
+ });
+ const member = ac.newRole({
+ project: ["read"],
+ });
+ const { auth, customFetchImpl, sessionSetter, signInWithTestUser } =
+ await getTestInstance({
+ plugins: [
+ organization({
+ ac,
+ roles: {
+ admin,
+ member,
+ owner,
+ },
+ }),
+ ],
+ });
+
+ const {
+ organization: { checkRolePermission, hasPermission, create },
+ } = createAuthClient({
+ baseURL: "http://localhost:3000",
+ plugins: [
+ organizationClient({
+ ac,
+ roles: {
+ admin,
+ member,
+ owner,
+ },
+ }),
+ ],
+ fetchOptions: {
+ customFetchImpl,
+ },
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ const org = await create(
+ {
+ name: "test",
+ slug: "test",
+ metadata: {
+ test: "test",
+ },
+ },
+ {
+ onSuccess: sessionSetter(headers),
+ headers,
+ },
+ );
+
+ it("should return success", async () => {
+ const canCreateProject = checkRolePermission({
+ role: "admin",
+ permission: {
+ project: ["create"],
+ },
+ });
+ expect(canCreateProject).toBe(true);
+ const canCreateProjectServer = await hasPermission({
+ permission: {
+ project: ["create"],
+ },
+ fetchOptions: {
+ headers,
+ },
+ });
+ expect(canCreateProjectServer.data?.success).toBe(true);
+ });
+
+ it("should return not success", async () => {
+ const canCreateProject = checkRolePermission({
+ role: "admin",
+ permission: {
+ project: ["delete"],
+ },
+ });
+ expect(canCreateProject).toBe(false);
+ });
+});
diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts
index 9089a7c709..6af965d5d1 100644
--- a/packages/better-auth/src/plugins/organization/organization.ts
+++ b/packages/better-auth/src/plugins/organization/organization.ts
@@ -15,9 +15,10 @@ import { shimContext } from "../../utils/shim";
import {
type AccessControl,
type Role,
+ createAccessControl,
defaultRoles,
type defaultStatements,
-} from "../access";
+} from "./access";
import { getOrgAdapter } from "./adapter";
import { orgSessionMiddleware } from "./call";
import {
@@ -67,11 +68,12 @@ export interface OrganizationOptions {
*/
organizationLimit?: number | ((user: User) => Promise | boolean);
/**
- * The role that is assigned to the creator of the organization.
+ * The role that is assigned to the creator of the
+ * organization.
*
- * @default "admin"
+ * @default "owner"
*/
- creatorRole?: "admin" | "owner";
+ creatorRole?: string;
/**
* The number of memberships a user can have in an organization.
*
@@ -79,15 +81,15 @@ export interface OrganizationOptions {
*/
membershipLimit?: number;
/**
- * Configure the roles and permissions for the organization plugin.
- *
+ * Configure the roles and permissions for the
+ * organization plugin.
*/
ac?: AccessControl;
/**
* Custom permissions for roles.
*/
roles?: {
- [key in "admin" | "member" | "owner"]?: Role;
+ [key in string]?: Role;
};
/**
* The expiration time for the invitation link.
@@ -128,13 +130,13 @@ export interface OrganizationOptions {
/**
* the role of the user
*/
- role: "admin" | "owner" | "member";
+ role: string;
/**
* the email of the user
*/
email: string;
/**
- * the organization the user is invited to
+ * the organization the user is invited to join
*/
organization: Organization;
/**
@@ -193,6 +195,13 @@ export interface OrganizationOptions {
* });
* ```
*/
+
+const ac = createAccessControl({
+ name: ["action"],
+});
+const a = ac.newRole({
+ name: ["action"],
+});
export const organization = (options?: O) => {
const endpoints = {
createOrganization,
@@ -201,13 +210,13 @@ export const organization = (options?: O) => {
setActiveOrganization,
getFullOrganization,
listOrganization,
- createInvitation,
+ createInvitation: createInvitation(options as O),
cancelInvitation,
acceptInvitation,
getInvitation,
rejectInvitation,
removeMember,
- updateMemberRole,
+ updateMemberRole: updateMemberRole(options as O),
getActiveMember,
};
@@ -269,7 +278,7 @@ export const organization = (options?: O) => {
message: "You are not a member of this organization",
});
}
- const role = roles[member.role];
+ const role = roles[member.role as keyof typeof roles];
const result = role.authorize(ctx.body.permission as any);
if (result.error) {
return ctx.json(
diff --git a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts
index 5eb6c601ae..135f3353e4 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts
@@ -4,114 +4,118 @@ import { getSessionFromCtx } from "../../../api/routes";
import { generateId } from "../../../utils/id";
import { getOrgAdapter } from "../adapter";
import { orgMiddleware, orgSessionMiddleware } from "../call";
-import { role } from "../schema";
+import { type InferRolesFromOption } from "../schema";
import { APIError } from "better-call";
+import type { OrganizationOptions } from "../organization";
-export const createInvitation = createAuthEndpoint(
- "/organization/invite-member",
- {
- method: "POST",
- use: [orgMiddleware, orgSessionMiddleware],
- body: z.object({
- email: z.string(),
- role: role,
- organizationId: z.string().optional(),
- resend: z.boolean().optional(),
- }),
- },
- async (ctx) => {
- if (!ctx.context.orgOptions.sendInvitationEmail) {
- ctx.context.logger.warn(
- "Invitation email is not enabled. Pass `sendInvitationEmail` to the plugin options to enable it.",
- );
- throw new APIError("BAD_REQUEST", {
- message: "Invitation email is not enabled",
- });
- }
+export const createInvitation = (
+ option: O,
+) =>
+ createAuthEndpoint(
+ "/organization/invite-member",
+ {
+ method: "POST",
+ use: [orgMiddleware, orgSessionMiddleware],
+ body: z.object({
+ email: z.string(),
+ role: z.string() as unknown as InferRolesFromOption,
+ organizationId: z.string().optional(),
+ resend: z.boolean().optional(),
+ }),
+ },
+ async (ctx) => {
+ if (!ctx.context.orgOptions.sendInvitationEmail) {
+ ctx.context.logger.warn(
+ "Invitation email is not enabled. Pass `sendInvitationEmail` to the plugin options to enable it.",
+ );
+ throw new APIError("BAD_REQUEST", {
+ message: "Invitation email is not enabled",
+ });
+ }
- const session = ctx.context.session;
- const organizationId =
- ctx.body.organizationId || session.session.activeOrganizationId;
- if (!organizationId) {
- throw new APIError("BAD_REQUEST", {
- message: "Organization not found",
+ const session = ctx.context.session;
+ const organizationId =
+ ctx.body.organizationId || session.session.activeOrganizationId;
+ if (!organizationId) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Organization not found",
+ });
+ }
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
+ const member = await adapter.findMemberByOrgId({
+ userId: session.user.id,
+ organizationId: organizationId,
});
- }
- const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
- const member = await adapter.findMemberByOrgId({
- userId: session.user.id,
- organizationId: organizationId,
- });
- if (!member) {
- throw new APIError("BAD_REQUEST", {
- message: "Member not found!",
+ if (!member) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Member not found!",
+ });
+ }
+ const role = ctx.context.roles[member.role];
+ if (!role) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Role not found!",
+ });
+ }
+ const canInvite = role.authorize({
+ invitation: ["create"],
});
- }
- const role = ctx.context.roles[member.role];
- if (!role) {
- throw new APIError("BAD_REQUEST", {
- message: "Role not found!",
- });
- }
- const canInvite = role.authorize({
- invitation: ["create"],
- });
- if (canInvite.error) {
- throw new APIError("FORBIDDEN", {
- message: "You are not allowed to invite members",
- });
- }
- const alreadyMember = await adapter.findMemberByEmail({
- email: ctx.body.email,
- organizationId: organizationId,
- });
- if (alreadyMember) {
- throw new APIError("BAD_REQUEST", {
- message: "User is already a member of this organization",
- });
- }
- const alreadyInvited = await adapter.findPendingInvitation({
- email: ctx.body.email,
- organizationId: organizationId,
- });
- if (alreadyInvited.length && !ctx.body.resend) {
- throw new APIError("BAD_REQUEST", {
- message: "User is already invited to this organization",
- });
- }
- const invitation = await adapter.createInvitation({
- invitation: {
- role: ctx.body.role,
+ if (canInvite.error) {
+ throw new APIError("FORBIDDEN", {
+ message: "You are not allowed to invite members",
+ });
+ }
+ const alreadyMember = await adapter.findMemberByEmail({
email: ctx.body.email,
organizationId: organizationId,
- },
- user: session.user,
- });
-
- const organization = await adapter.findOrganizationById(organizationId);
-
- if (!organization) {
- throw new APIError("BAD_REQUEST", {
- message: "Organization not found",
});
- }
-
- await ctx.context.orgOptions.sendInvitationEmail?.(
- {
- id: invitation.id,
- role: invitation.role,
- email: invitation.email,
- organization: organization,
- inviter: {
- ...member,
- user: session.user,
+ if (alreadyMember) {
+ throw new APIError("BAD_REQUEST", {
+ message: "User is already a member of this organization",
+ });
+ }
+ const alreadyInvited = await adapter.findPendingInvitation({
+ email: ctx.body.email,
+ organizationId: organizationId,
+ });
+ if (alreadyInvited.length && !ctx.body.resend) {
+ throw new APIError("BAD_REQUEST", {
+ message: "User is already invited to this organization",
+ });
+ }
+ const invitation = await adapter.createInvitation({
+ invitation: {
+ role: ctx.body.role as string,
+ email: ctx.body.email,
+ organizationId: organizationId,
},
- },
- ctx.request,
- );
- return ctx.json(invitation);
- },
-);
+ user: session.user,
+ });
+
+ const organization = await adapter.findOrganizationById(organizationId);
+
+ if (!organization) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Organization not found",
+ });
+ }
+
+ await ctx.context.orgOptions.sendInvitationEmail?.(
+ {
+ id: invitation.id,
+ role: invitation.role as string,
+ email: invitation.email,
+ organization: organization,
+ inviter: {
+ ...member,
+ user: session.user,
+ },
+ },
+ ctx.request,
+ );
+ return ctx.json(invitation);
+ },
+ );
export const acceptInvitation = createAuthEndpoint(
"/organization/accept-invitation",
diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.ts
index 7a1fa8682e..a2c6fa06dd 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts
@@ -2,8 +2,9 @@ import { z } from "zod";
import { createAuthEndpoint } from "../../../api/call";
import { getOrgAdapter } from "../adapter";
import { orgMiddleware, orgSessionMiddleware } from "../call";
-import type { Member } from "../schema";
+import type { InferRolesFromOption, Member } from "../schema";
import { APIError } from "better-call";
+import type { OrganizationOptions } from "../organization";
export const removeMember = createAuthEndpoint(
"/organization/remove-member",
@@ -95,87 +96,88 @@ export const removeMember = createAuthEndpoint(
},
);
-export const updateMemberRole = createAuthEndpoint(
- "/organization/update-member-role",
- {
- method: "POST",
- body: z.object({
- role: z.enum(["admin", "member", "owner"]),
- memberId: z.string(),
+export const updateMemberRole = (option: O) =>
+ createAuthEndpoint(
+ "/organization/update-member-role",
+ {
+ method: "POST",
+ body: z.object({
+ role: z.string() as unknown as InferRolesFromOption,
+ memberId: z.string(),
+ /**
+ * If not provided, the active organization will be used
+ */
+ organizationId: z.string().optional(),
+ }),
+ use: [orgMiddleware, orgSessionMiddleware],
+ },
+ async (ctx) => {
+ const session = ctx.context.session;
+ const organizationId =
+ ctx.body.organizationId || session.session.activeOrganizationId;
+ if (!organizationId) {
+ return ctx.json(null, {
+ status: 400,
+ body: {
+ message: "No active organization found!",
+ },
+ });
+ }
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
+ const member = await adapter.findMemberByOrgId({
+ userId: session.user.id,
+ organizationId: organizationId,
+ });
+ if (!member) {
+ return ctx.json(null, {
+ status: 400,
+ body: {
+ message: "Member not found!",
+ },
+ });
+ }
+ const role = ctx.context.roles[member.role];
+ if (!role) {
+ return ctx.json(null, {
+ status: 400,
+ body: {
+ message: "Role not found!",
+ },
+ });
+ }
/**
- * If not provided, the active organization will be used
+ * If the member is not an owner, they cannot update the role of another member
+ * as an owner.
*/
- organizationId: z.string().optional(),
- }),
- use: [orgMiddleware, orgSessionMiddleware],
- },
- async (ctx) => {
- const session = ctx.context.session;
- const organizationId =
- ctx.body.organizationId || session.session.activeOrganizationId;
- if (!organizationId) {
- return ctx.json(null, {
- status: 400,
- body: {
- message: "No active organization found!",
- },
- });
- }
- const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
- const member = await adapter.findMemberByOrgId({
- userId: session.user.id,
- organizationId: organizationId,
- });
- if (!member) {
- return ctx.json(null, {
- status: 400,
- body: {
- message: "Member not found!",
- },
- });
- }
- const role = ctx.context.roles[member.role];
- if (!role) {
- return ctx.json(null, {
- status: 400,
- body: {
- message: "Role not found!",
- },
- });
- }
- /**
- * If the member is not an owner, they cannot update the role of another member
- * as an owner.
- */
- const canUpdateMember =
- role.authorize({
- member: ["update"],
- }).error ||
- (ctx.body.role === "owner" && member.role !== "owner");
- if (canUpdateMember) {
- return ctx.json(null, {
- body: {
- message: "You are not allowed to update this member",
- },
- status: 403,
- });
- }
+ const canUpdateMember =
+ role.authorize({
+ member: ["update"],
+ }).error ||
+ (ctx.body.role === "owner" && member.role !== "owner");
+ if (canUpdateMember) {
+ return ctx.json(null, {
+ body: {
+ message: "You are not allowed to update this member",
+ },
+ status: 403,
+ });
+ }
- const updatedMember = await adapter.updateMember(
- ctx.body.memberId,
- ctx.body.role,
- );
- if (!updatedMember) {
- return ctx.json(null, {
- status: 400,
- body: {
- message: "Member not found!",
- },
- });
- }
- return ctx.json(updatedMember);
- },
-);
+ const updatedMember = await adapter.updateMember(
+ ctx.body.memberId,
+ ctx.body.role as string,
+ );
+ if (!updatedMember) {
+ return ctx.json(null, {
+ status: 400,
+ body: {
+ message: "Member not found!",
+ },
+ });
+ }
+ return ctx.json(updatedMember);
+ },
+ );
export const getActiveMember = createAuthEndpoint(
"/organization/get-active-member",
diff --git a/packages/better-auth/src/plugins/organization/routes/crud-org.ts b/packages/better-auth/src/plugins/organization/routes/crud-org.ts
index ba5fbda064..c5dc1ee97b 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-org.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-org.ts
@@ -73,6 +73,10 @@ export const createOrganization = createAuthEndpoint(
},
user,
});
+ await adapter.setActiveOrganization(
+ ctx.context.session.session.id,
+ organization.id,
+ );
return ctx.json(organization);
},
);
diff --git a/packages/better-auth/src/plugins/organization/schema.ts b/packages/better-auth/src/plugins/organization/schema.ts
index 5b7f56e787..e84d3fa870 100644
--- a/packages/better-auth/src/plugins/organization/schema.ts
+++ b/packages/better-auth/src/plugins/organization/schema.ts
@@ -1,9 +1,11 @@
-import { z } from "zod";
+import { z, ZodLiteral } from "zod";
+import type { OrganizationOptions } from "./organization";
-export const role = z.enum(["admin", "member", "owner"]);
+export const role = z.string();
export const invitationStatus = z
.enum(["pending", "accepted", "rejected", "canceled"])
.default("pending");
+
export const organizationSchema = z.object({
id: z.string(),
name: z.string(),
@@ -40,3 +42,8 @@ export const invitationSchema = z.object({
export type Organization = z.infer;
export type Member = z.infer;
export type Invitation = z.infer;
+
+export type InferRolesFromOption =
+ ZodLiteral<
+ O extends { roles: any } ? keyof O["roles"] : "admin" | "member" | "owner"
+ >;
diff --git a/packages/better-auth/tsup.config.ts b/packages/better-auth/tsup.config.ts
index 9294a7a8b5..006ae8d77f 100644
--- a/packages/better-auth/tsup.config.ts
+++ b/packages/better-auth/tsup.config.ts
@@ -22,7 +22,7 @@ export default defineConfig((env) => {
svelte: "./src/client/svelte/index.ts",
solid: "./src/client/solid/index.ts",
plugins: "./src/plugins/index.ts",
- "plugins/access": "./src/plugins/access/index.ts",
+ "plugins/access": "./src/plugins/organization/access/index.ts",
api: "./src/api/index.ts",
"client/plugins": "./src/client/plugins/index.ts",
"svelte-kit": "./src/integrations/svelte-kit.ts",