feat: custom roles (#565)

This commit is contained in:
Bereket Engida
2024-11-18 14:26:16 +03:00
committed by GitHub
parent a3b9baba53
commit 8014648dff
23 changed files with 532 additions and 357 deletions
+7
View File
@@ -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,
+74 -7
View File
@@ -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:
+2 -2
View File
@@ -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,4 +1,3 @@
export * from "./access";
export * from "./types";
export * from "./statement";
export * from "./utils";
@@ -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"
>;
+1 -1
View File
@@ -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",