mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
feat: custom roles (#565)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout type="warn">
|
||||
Currently, you can't create custom roles
|
||||
</Callout>
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
#### 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]
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
@@ -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
|
||||
</Steps>
|
||||
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -102,16 +102,16 @@ export async function setSessionCookie(
|
||||
overrides?: Partial<CookieOptions>,
|
||||
) {
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TStatements extends Statements = Statements> {
|
||||
private readonly statements: TStatements;
|
||||
constructor(private readonly s: TStatements) {
|
||||
this.statements = s;
|
||||
}
|
||||
public newRole<K extends keyof TStatements>(
|
||||
statements: Subset<K, TStatements>,
|
||||
) {
|
||||
return new Role<Subset<K, TStatements>>(statements);
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthortizeResponse =
|
||||
| { success: false; error: string }
|
||||
| { success: true; error?: never };
|
||||
|
||||
export class Role<TStatements extends Statements> {
|
||||
public readonly statements: TStatements;
|
||||
|
||||
constructor(statements: TStatements) {
|
||||
this.statements = statements;
|
||||
}
|
||||
|
||||
public authorize<K extends keyof TStatements>(
|
||||
request: Subset<K, TStatements>,
|
||||
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<TStatements extends Statements>(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<TStatements>(statements);
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return JSON.stringify(this.statements);
|
||||
}
|
||||
}
|
||||
@@ -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 extends unknown[] | readonly unknown[] | any[]> =
|
||||
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<R[P]>;
|
||||
};
|
||||
|
||||
export type StatementsPrimitive = {
|
||||
readonly [resource: string]: readonly LiteralString[]; //| undefined
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Role } from "./access";
|
||||
|
||||
export const permissionFromString = (permission?: string) => {
|
||||
return Role.fromString(permission ?? "");
|
||||
};
|
||||
@@ -5,7 +5,6 @@ export const multiSessionClient = () => {
|
||||
return {
|
||||
id: "multi-session",
|
||||
$InferServerPlugin: {} as ReturnType<typeof multiSession>,
|
||||
|
||||
atomListeners: [
|
||||
{
|
||||
matcher(path) {
|
||||
|
||||
@@ -83,6 +83,7 @@ export const oneTap = (options?: OneTapOptions) =>
|
||||
user.user.id,
|
||||
c.request,
|
||||
);
|
||||
|
||||
await setSessionCookie(c, {
|
||||
user: user.user,
|
||||
session,
|
||||
|
||||
@@ -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<K extends keyof TStatements>(statements: Subset<K, TStatements>) {
|
||||
return role<Subset<K, TStatements>>(statements);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function role<TStatements extends Statements>(statements: TStatements) {
|
||||
return {
|
||||
statements,
|
||||
authorize<K extends keyof TStatements>(
|
||||
request: Subset<K, TStatements>,
|
||||
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<TStatements extends Statements = Statements> =
|
||||
ReturnType<typeof createAccessControl<TStatements>>;
|
||||
|
||||
export type Role<TStatements extends Statements = Record<string, any>> = {
|
||||
authorize: (request: any, connector?: Connector) => AuthorizeResponse;
|
||||
statements: TStatements;
|
||||
};
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
export * from "./access";
|
||||
export * from "./types";
|
||||
export * from "./statement";
|
||||
export * from "./utils";
|
||||
+1
-8
@@ -1,11 +1,4 @@
|
||||
import { AccessControl } from "./access";
|
||||
import type { StatementsPrimitive } from "./types";
|
||||
|
||||
export const createAccessControl = <S extends StatementsPrimitive>(
|
||||
statements: S,
|
||||
) => {
|
||||
return new AccessControl<S>(statements);
|
||||
};
|
||||
import { createAccessControl } from "./access";
|
||||
|
||||
export const defaultStatements = {
|
||||
organization: ["update", "delete"],
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { LiteralString } from "../../../types/helper";
|
||||
|
||||
export type SubArray<T extends unknown[] | readonly unknown[] | any[]> =
|
||||
T[number][];
|
||||
|
||||
export type Subset<
|
||||
K extends keyof R,
|
||||
R extends Record<
|
||||
string | LiteralString,
|
||||
readonly string[] | readonly LiteralString[]
|
||||
>,
|
||||
> = {
|
||||
[P in K]: SubArray<R[P]>;
|
||||
};
|
||||
|
||||
export type StatementsPrimitive = {
|
||||
readonly [resource: string]: readonly LiteralString[];
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<any>;
|
||||
roles: {
|
||||
[key in string]: Role;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,9 +37,28 @@ export const organizationClient = <O extends OrganizationClientOptions>(
|
||||
? S & DefaultStatements
|
||||
: DefaultStatements
|
||||
: DefaultStatements;
|
||||
const roles = {
|
||||
admin: adminAc,
|
||||
member: memberAc,
|
||||
owner: ownerAc,
|
||||
...options?.roles,
|
||||
};
|
||||
return {
|
||||
id: "organization",
|
||||
$InferServerPlugin: {} as ReturnType<typeof organization>,
|
||||
$InferServerPlugin: {} as ReturnType<
|
||||
typeof organization<{
|
||||
ac: O["ac"] extends AccessControl
|
||||
? O["ac"]
|
||||
: AccessControl<DefaultStatements>;
|
||||
roles: O["roles"] extends Record<string, Role>
|
||||
? O["roles"]
|
||||
: {
|
||||
admin: Role;
|
||||
member: Role;
|
||||
owner: Role;
|
||||
};
|
||||
}>
|
||||
>,
|
||||
getActions: ($fetch) => ({
|
||||
$Infer: {
|
||||
ActiveOrganization: {} as Prettify<
|
||||
@@ -56,22 +81,23 @@ export const organizationClient = <O extends OrganizationClientOptions>(
|
||||
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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> | 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<any>;
|
||||
[key in string]?: Role<any>;
|
||||
};
|
||||
/**
|
||||
* 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 = <O extends OrganizationOptions>(options?: O) => {
|
||||
const endpoints = {
|
||||
createOrganization,
|
||||
@@ -201,13 +210,13 @@ export const organization = <O extends OrganizationOptions>(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 = <O extends OrganizationOptions>(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(
|
||||
|
||||
@@ -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 = <O extends OrganizationOptions | undefined>(
|
||||
option: O,
|
||||
) =>
|
||||
createAuthEndpoint(
|
||||
"/organization/invite-member",
|
||||
{
|
||||
method: "POST",
|
||||
use: [orgMiddleware, orgSessionMiddleware],
|
||||
body: z.object({
|
||||
email: z.string(),
|
||||
role: z.string() as unknown as InferRolesFromOption<O>,
|
||||
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",
|
||||
|
||||
@@ -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 = <O extends OrganizationOptions>(option: O) =>
|
||||
createAuthEndpoint(
|
||||
"/organization/update-member-role",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
role: z.string() as unknown as InferRolesFromOption<O>,
|
||||
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",
|
||||
|
||||
@@ -73,6 +73,10 @@ export const createOrganization = createAuthEndpoint(
|
||||
},
|
||||
user,
|
||||
});
|
||||
await adapter.setActiveOrganization(
|
||||
ctx.context.session.session.id,
|
||||
organization.id,
|
||||
);
|
||||
return ctx.json(organization);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<typeof organizationSchema>;
|
||||
export type Member = z.infer<typeof memberSchema>;
|
||||
export type Invitation = z.infer<typeof invitationSchema>;
|
||||
|
||||
export type InferRolesFromOption<O extends OrganizationOptions | undefined> =
|
||||
ZodLiteral<
|
||||
O extends { roles: any } ? keyof O["roles"] : "admin" | "member" | "owner"
|
||||
>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user