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",