mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-25 10:13:54 -05:00
feat(organization): multi-team support
This commit is contained in:
@@ -1151,6 +1151,80 @@ type removeTeam = {
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
#### Set Active Team
|
||||
Sets the given team as the current active team. If `teamId` is `null` the current active team is unset.
|
||||
|
||||
<APIMethod path="/organization/set-active-team" method="POST">
|
||||
```ts
|
||||
type setActiveTeam = {
|
||||
/**
|
||||
* The team ID of the team to set as the current active team.
|
||||
*/
|
||||
teamId?: string = "team-id"
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
#### List User Teams
|
||||
List all teams that the current user is a part of.
|
||||
|
||||
<APIMethod path="/organization/list-user-teams" method="GET">
|
||||
```ts
|
||||
type listUserTeams = {
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
#### List Team Members
|
||||
List the members of the given team.
|
||||
|
||||
<APIMethod path="/organization/list-team-members" method="POST">
|
||||
```ts
|
||||
type listTeamMembers = {
|
||||
/**
|
||||
* The team whose members we should return. If this is not provided the members of the current active team get returned.
|
||||
*/
|
||||
teamId?: string = "team-id"
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
#### Add Team Member
|
||||
Add a member to a team.
|
||||
|
||||
<APIMethod path="/organization/add-team-member" method="POST">
|
||||
```ts
|
||||
type addTeamMember = {
|
||||
/**
|
||||
* The team the user should be a member of.
|
||||
*/
|
||||
teamId: string = "team-id"
|
||||
/**
|
||||
* The user Id which represents the user to be added as a member.
|
||||
*/
|
||||
userId: string = "user-id"
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
#### Remove Team Member
|
||||
Remove a member from a team.
|
||||
|
||||
<APIMethod path="/organization/remove-team-member" method="POST">
|
||||
```ts
|
||||
type removeTeamMember = {
|
||||
/**
|
||||
* The team the user should be removed from.
|
||||
*/
|
||||
teamId: string = "team-id"
|
||||
/**
|
||||
* The user which should be removed from the team.
|
||||
*/
|
||||
userId: string = "user-id"
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
### Team Permissions
|
||||
|
||||
Teams follow the organization's permission system. To manage teams, users need the following permissions:
|
||||
@@ -1212,7 +1286,9 @@ The invited member will be added to the specified team upon accepting the invita
|
||||
|
||||
### Database Schema
|
||||
|
||||
When teams are enabled, a new `team` table is added with the following structure:
|
||||
When teams are enabled, new `team` and `teamMember` tables are added to the database.
|
||||
|
||||
Table Name: `team`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
@@ -1241,8 +1317,39 @@ When teams are enabled, a new `team` table is added with the following structure
|
||||
{
|
||||
name: "updatedAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the team was last updated"
|
||||
}
|
||||
isOptional: true,
|
||||
description: "Timestamp of when the team was created"
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Table Name: `teamMember`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
description: "Unique identifier for each team member",
|
||||
isPrimaryKey: true
|
||||
},
|
||||
{
|
||||
name: "teamId",
|
||||
type: "string",
|
||||
description: "Unique identifier for each team",
|
||||
isForeignKey: true
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
type: "string",
|
||||
description: "The ID of the user",
|
||||
isForeignKey: true
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the team member was created"
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1385,7 +1492,7 @@ Table Name: `invitation`
|
||||
|
||||
Table Name: `session`
|
||||
|
||||
You need to add one more field to the session table to store the active organization ID.
|
||||
You need to add two more fields to the session table to store the active organization ID and the active team ID.
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
@@ -1395,6 +1502,12 @@ You need to add one more field to the session table to store the active organiza
|
||||
description: "The ID of the active organization",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "activeTeamId",
|
||||
type: "string",
|
||||
description: "The ID of the active team",
|
||||
isOptional: true
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1435,20 +1548,37 @@ Table Name: `team`
|
||||
]}
|
||||
/>
|
||||
|
||||
Table Name: `member`
|
||||
Table Name: `teamMember`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
description: "Unique identifier for each team member",
|
||||
isPrimaryKey: true
|
||||
},
|
||||
{
|
||||
name: "teamId",
|
||||
type: "string",
|
||||
description: "The ID of the team",
|
||||
isOptional: true
|
||||
description: "Unique identifier for each team",
|
||||
isForeignKey: true
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
type: "string",
|
||||
description: "The ID of the user",
|
||||
isForeignKey: true
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the team member was created"
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Table Name: `invitation`
|
||||
Table Name: `invitation`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
OrganizationInput,
|
||||
Team,
|
||||
TeamInput,
|
||||
TeamMember,
|
||||
} from "./schema";
|
||||
import { BetterAuthError } from "../../error";
|
||||
import type { AuthContext } from "../../init";
|
||||
@@ -444,7 +445,7 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
includeTeamMembers?: IncludeMembers;
|
||||
}): Promise<
|
||||
| (InferTeam<O> &
|
||||
(IncludeMembers extends true ? { members: InferMember<O>[] } : {}))
|
||||
(IncludeMembers extends true ? { members: TeamMember[] } : {}))
|
||||
| null
|
||||
> => {
|
||||
const team = await adapter.findOne<InferTeam<O>>({
|
||||
@@ -467,10 +468,11 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
let members: InferMember<O>[] = [];
|
||||
|
||||
let members: TeamMember[] = [];
|
||||
if (includeTeamMembers) {
|
||||
members = await adapter.findMany<InferMember<O>>({
|
||||
model: "member",
|
||||
members = await adapter.findMany<TeamMember>({
|
||||
model: "teamMember",
|
||||
where: [
|
||||
{
|
||||
field: "teamId",
|
||||
@@ -484,8 +486,9 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
return team as InferTeam<O> &
|
||||
(IncludeMembers extends true ? { members: InferMember<O>[] } : {});
|
||||
(IncludeMembers extends true ? { members: TeamMember[] } : {});
|
||||
},
|
||||
updateTeam: async (
|
||||
teamId: string,
|
||||
@@ -570,6 +573,130 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
|
||||
return invitation;
|
||||
},
|
||||
|
||||
setActiveTeam: async (sessionToken: string, teamId: string | null) => {
|
||||
const session = await context.internalAdapter.updateSession(
|
||||
sessionToken,
|
||||
{
|
||||
activeTeamId: teamId,
|
||||
},
|
||||
);
|
||||
return session as Session;
|
||||
},
|
||||
|
||||
listTeamMembers: async (data: {
|
||||
teamId: string;
|
||||
}) => {
|
||||
const members = await adapter.findMany<TeamMember>({
|
||||
model: "teamMember",
|
||||
where: [
|
||||
{
|
||||
field: "teamId",
|
||||
value: data.teamId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return members;
|
||||
},
|
||||
|
||||
listTeamsByUser: async (data: {
|
||||
userId: string;
|
||||
}) => {
|
||||
const members = await adapter.findMany<TeamMember>({
|
||||
model: "teamMember",
|
||||
where: [
|
||||
{
|
||||
field: "userId",
|
||||
value: data.userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const teams = await adapter.findMany<Team>({
|
||||
model: "team",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
operator: "in",
|
||||
value: members.map((m) => m.teamId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return teams;
|
||||
},
|
||||
|
||||
findTeamMember: async (data: {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
const member = await adapter.findOne<TeamMember>({
|
||||
model: "teamMember",
|
||||
where: [
|
||||
{
|
||||
field: "teamId",
|
||||
value: data.teamId,
|
||||
},
|
||||
{
|
||||
field: "userId",
|
||||
value: data.userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return member;
|
||||
},
|
||||
|
||||
findOrCreateTeamMember: async (data: {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
const member = await adapter.findOne<TeamMember>({
|
||||
model: "teamMember",
|
||||
where: [
|
||||
{
|
||||
field: "teamId",
|
||||
value: data.teamId,
|
||||
},
|
||||
{
|
||||
field: "userId",
|
||||
value: data.userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (member) return member;
|
||||
|
||||
return await adapter.create<Omit<TeamMember, "id">, TeamMember>({
|
||||
model: "teamMember",
|
||||
data: {
|
||||
teamId: data.teamId,
|
||||
userId: data.userId,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
removeTeamMember: async (data: {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
await adapter.delete({
|
||||
model: "teamMember",
|
||||
where: [
|
||||
{
|
||||
field: "teamId",
|
||||
value: data.teamId,
|
||||
},
|
||||
{
|
||||
field: "userId",
|
||||
value: data.userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
findInvitationsByTeamId: async (teamId: string) => {
|
||||
const invitations = await adapter.findMany<InferInvitation<O>>({
|
||||
model: "invitation",
|
||||
@@ -597,7 +724,7 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
email: string;
|
||||
role: string;
|
||||
organizationId: string;
|
||||
teamId?: string;
|
||||
teamIds: string[];
|
||||
} & Record<string, any>; // This represents the additionalFields for the invitation
|
||||
user: User;
|
||||
}) => {
|
||||
@@ -606,6 +733,7 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
options?.invitationExpiresIn || defaultExpiration,
|
||||
"sec",
|
||||
);
|
||||
|
||||
const invite = await adapter.create<
|
||||
Omit<InvitationInput, "id">,
|
||||
InferInvitation<O>
|
||||
@@ -616,6 +744,7 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
expiresAt,
|
||||
inviterId: user.id,
|
||||
...invitation,
|
||||
teamId: invitation.teamIds.join(","),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Role } from "../access";
|
||||
import type { OrganizationOptions } from "./types";
|
||||
import type { defaultRoles } from "./access/statement";
|
||||
|
||||
export const orgMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
export const orgMiddleware = createAuthMiddleware(async () => {
|
||||
return {} as {
|
||||
orgOptions: OrganizationOptions;
|
||||
roles: typeof defaultRoles & {
|
||||
@@ -13,6 +13,7 @@ export const orgMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
};
|
||||
getSession: (context: GenericEndpointContext) => Promise<{
|
||||
session: Session & {
|
||||
activeTeamId?: string;
|
||||
activeOrganizationId?: string;
|
||||
};
|
||||
user: User;
|
||||
@@ -31,6 +32,7 @@ export const orgSessionMiddleware = createAuthMiddleware(
|
||||
async (ctx) => {
|
||||
const session = ctx.context.session as {
|
||||
session: Session & {
|
||||
activeTeamId?: string;
|
||||
activeOrganizationId?: string;
|
||||
};
|
||||
user: User;
|
||||
|
||||
@@ -55,4 +55,12 @@ export const ORGANIZATION_ERROR_CODES = {
|
||||
"You are not allowed to delete this team",
|
||||
INVITATION_LIMIT_REACHED: "Invitation limit reached",
|
||||
TEAM_MEMBER_LIMIT_REACHED: "Team member limit reached",
|
||||
USER_IS_NOT_A_MEMBER_OF_THE_TEAM: "User is not a member of the team",
|
||||
YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM:
|
||||
"You are not allowed to list the members of this team",
|
||||
YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM: "You do not have an active team",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER:
|
||||
"You are not allowed to create a new member",
|
||||
YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER:
|
||||
"You are not allowed to remove a team member",
|
||||
} as const;
|
||||
|
||||
@@ -1327,6 +1327,9 @@ describe("Additional Fields", async () => {
|
||||
teamRequiredField: string;
|
||||
teamOptionalField?: string;
|
||||
}[],
|
||||
teamMember: [] as {
|
||||
id: string;
|
||||
}[],
|
||||
};
|
||||
|
||||
const orgOptions = {
|
||||
@@ -1429,7 +1432,6 @@ describe("Additional Fields", async () => {
|
||||
userId: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
teamId?: string | undefined;
|
||||
} & {
|
||||
memberRequiredField: string;
|
||||
} & {
|
||||
|
||||
@@ -39,12 +39,18 @@ import {
|
||||
listOrganizationTeams,
|
||||
removeTeam,
|
||||
updateTeam,
|
||||
setActiveTeam,
|
||||
listUserTeams,
|
||||
listTeamMembers,
|
||||
addTeamMember,
|
||||
removeTeamMember,
|
||||
} from "./routes/crud-team";
|
||||
import type {
|
||||
InferInvitation,
|
||||
InferMember,
|
||||
InferOrganization,
|
||||
Team,
|
||||
TeamMember,
|
||||
} from "./schema";
|
||||
import { ORGANIZATION_ERROR_CODES } from "./error-codes";
|
||||
import { defaultRoles, defaultStatements } from "./access";
|
||||
@@ -431,6 +437,86 @@ export const organization = <O extends OrganizationOptions>(
|
||||
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-update-team)
|
||||
*/
|
||||
updateTeam: updateTeam(options as O),
|
||||
/**
|
||||
* ### Endpoint
|
||||
*
|
||||
* POST `/organization/set-active-team`
|
||||
*
|
||||
* ### API Methods
|
||||
*
|
||||
* **server:**
|
||||
* `auth.api.setActiveTeam`
|
||||
*
|
||||
* **client:**
|
||||
* `authClient.organization.setActiveTeam`
|
||||
*
|
||||
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team)
|
||||
*/
|
||||
setActiveTeam: setActiveTeam(options as O),
|
||||
/**
|
||||
* ### Endpoint
|
||||
*
|
||||
* POST `/organization/list-user-teams`
|
||||
*
|
||||
* ### API Methods
|
||||
*
|
||||
* **server:**
|
||||
* `auth.api.listUserTeams`
|
||||
*
|
||||
* **client:**
|
||||
* `authClient.organization.listUserTeams`
|
||||
*
|
||||
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team)
|
||||
*/
|
||||
listUserTeams: listUserTeams(options as O),
|
||||
/**
|
||||
* ### Endpoint
|
||||
*
|
||||
* POST `/organization/list-team-members`
|
||||
*
|
||||
* ### API Methods
|
||||
*
|
||||
* **server:**
|
||||
* `auth.api.listTeamMembers`
|
||||
*
|
||||
* **client:**
|
||||
* `authClient.organization.listTeamMembers`
|
||||
*
|
||||
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team)
|
||||
*/
|
||||
listTeamMembers: listTeamMembers(options as O),
|
||||
/**
|
||||
* ### Endpoint
|
||||
*
|
||||
* POST `/organization/add-team-member`
|
||||
*
|
||||
* ### API Methods
|
||||
*
|
||||
* **server:**
|
||||
* `auth.api.addTeamMember`
|
||||
*
|
||||
* **client:**
|
||||
* `authClient.organization.addTeamMember`
|
||||
*
|
||||
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-add-team-member)
|
||||
*/
|
||||
addTeamMember: addTeamMember(options as O),
|
||||
/**
|
||||
* ### Endpoint
|
||||
*
|
||||
* POST `/organization/remove-team-member`
|
||||
*
|
||||
* ### API Methods
|
||||
*
|
||||
* **server:**
|
||||
* `auth.api.removeTeamMember`
|
||||
*
|
||||
* **client:**
|
||||
* `authClient.organization.removeTeamMember`
|
||||
*
|
||||
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-remove-team-member)
|
||||
*/
|
||||
removeTeamMember: removeTeamMember(options as O),
|
||||
};
|
||||
if (teamSupport) {
|
||||
endpoints = {
|
||||
@@ -475,6 +561,30 @@ export const organization = <O extends OrganizationOptions>(
|
||||
...(options?.schema?.team?.additionalFields || {}),
|
||||
},
|
||||
},
|
||||
teamMember: {
|
||||
modelName: options?.schema?.teamMember?.modelName,
|
||||
fields: {
|
||||
teamId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
fieldName: options?.schema?.teamMember?.fields?.teamId,
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id",
|
||||
},
|
||||
fieldName: options?.schema?.teamMember?.fields?.userId,
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
fieldName: options?.schema?.teamMember?.fields?.createdAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies AuthPluginSchema)
|
||||
: undefined;
|
||||
|
||||
@@ -637,6 +747,15 @@ export const organization = <O extends OrganizationOptions>(
|
||||
required: false,
|
||||
fieldName: options?.schema?.session?.fields?.activeOrganizationId,
|
||||
},
|
||||
...(teamSupport
|
||||
? {
|
||||
activeTeamId: {
|
||||
type: "string",
|
||||
required: false,
|
||||
fieldName: options?.schema?.session?.fields?.activeTeamId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
@@ -700,16 +819,6 @@ export const organization = <O extends OrganizationOptions>(
|
||||
defaultValue: "member",
|
||||
fieldName: options?.schema?.member?.fields?.role,
|
||||
},
|
||||
...(teamSupport
|
||||
? {
|
||||
teamId: {
|
||||
type: "string",
|
||||
required: false,
|
||||
sortable: true,
|
||||
fieldName: options?.schema?.member?.fields?.teamId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
@@ -783,6 +892,7 @@ export const organization = <O extends OrganizationOptions>(
|
||||
Invitation: {} as InferInvitation<O>,
|
||||
Member: {} as InferMember<O>,
|
||||
Team: teamSupport ? ({} as Team) : ({} as any),
|
||||
TeamMember: teamSupport ? ({} as TeamMember) : ({} as any),
|
||||
ActiveOrganization: {} as Awaited<
|
||||
ReturnType<ReturnType<typeof getFullOrganization<O>>>
|
||||
>,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseRoles } from "../organization";
|
||||
import { type OrganizationOptions } from "../types";
|
||||
import { ORGANIZATION_ERROR_CODES } from "../error-codes";
|
||||
import { hasPermission } from "../has-permission";
|
||||
import { setSessionCookie } from "../../../cookies";
|
||||
import {
|
||||
toZodSchema,
|
||||
type InferAdditionalFieldsFromPluginOptions,
|
||||
@@ -19,6 +20,7 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
|
||||
fields: option?.schema?.invitation?.additionalFields || {},
|
||||
isClientSide: true,
|
||||
});
|
||||
|
||||
const baseSchema = z.object({
|
||||
email: z.string().meta({
|
||||
description: "The email address of the user to invite",
|
||||
@@ -51,12 +53,21 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
|
||||
"Resend the invitation email, if the user is already invited. Eg: true",
|
||||
})
|
||||
.optional(),
|
||||
teamId: z
|
||||
.string()
|
||||
.meta({
|
||||
description: "The team ID to invite the user to",
|
||||
})
|
||||
.optional(),
|
||||
teamId: z.union([
|
||||
z
|
||||
.string()
|
||||
.meta({
|
||||
description: "The team ID to invite the user to",
|
||||
})
|
||||
.optional(),
|
||||
z
|
||||
.array(
|
||||
z.string().meta({
|
||||
description: "The team ID to invite the user to",
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
]),
|
||||
});
|
||||
|
||||
return createAuthEndpoint(
|
||||
@@ -98,7 +109,7 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
|
||||
* The team the user is
|
||||
* being invited to.
|
||||
*/
|
||||
teamId?: string;
|
||||
teamId?: string | string[];
|
||||
}
|
||||
: {}) &
|
||||
InferAdditionalFieldsFromPluginOptions<"invitation", O, false>,
|
||||
@@ -265,33 +276,48 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
|
||||
"teamId" in ctx.body &&
|
||||
ctx.body.teamId
|
||||
) {
|
||||
const teamId = ctx.body.teamId as string;
|
||||
const team = await adapter.findTeamById({
|
||||
teamId,
|
||||
organizationId: organizationId,
|
||||
includeTeamMembers: true,
|
||||
});
|
||||
if (!team) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
|
||||
});
|
||||
}
|
||||
const maximumMembersPerTeam =
|
||||
typeof ctx.context.orgOptions.teams.maximumMembersPerTeam ===
|
||||
"function"
|
||||
? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
|
||||
teamId,
|
||||
session: session,
|
||||
organizationId: organizationId,
|
||||
})
|
||||
: ctx.context.orgOptions.teams.maximumMembersPerTeam;
|
||||
if (team.members.length >= maximumMembersPerTeam) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED,
|
||||
const teamIds =
|
||||
typeof ctx.body.teamId === "string"
|
||||
? [ctx.body.teamId as string]
|
||||
: (ctx.body.teamId as string[]);
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
const team = await adapter.findTeamById({
|
||||
teamId,
|
||||
organizationId: organizationId,
|
||||
includeTeamMembers: true,
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
const maximumMembersPerTeam =
|
||||
typeof ctx.context.orgOptions.teams.maximumMembersPerTeam ===
|
||||
"function"
|
||||
? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
|
||||
teamId,
|
||||
session: session,
|
||||
organizationId: organizationId,
|
||||
})
|
||||
: ctx.context.orgOptions.teams.maximumMembersPerTeam;
|
||||
if (team.members.length >= maximumMembersPerTeam) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const teamIds: string[] =
|
||||
"teamId" in ctx.body
|
||||
? typeof ctx.body.teamId === "string"
|
||||
? [ctx.body.teamId as string]
|
||||
: (ctx.body.teamId as string[]) ?? []
|
||||
: [];
|
||||
|
||||
const {
|
||||
email: _,
|
||||
role: __,
|
||||
@@ -305,11 +331,7 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
|
||||
role: roles,
|
||||
email: ctx.body.email.toLowerCase(),
|
||||
organizationId: organizationId,
|
||||
...("teamId" in ctx.body
|
||||
? {
|
||||
teamId: ctx.body.teamId as string,
|
||||
}
|
||||
: {}),
|
||||
teamIds,
|
||||
...(additionalFields ? additionalFields : {}),
|
||||
},
|
||||
user: session.user,
|
||||
@@ -330,6 +352,7 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
|
||||
},
|
||||
ctx.request,
|
||||
);
|
||||
|
||||
return ctx.json(invitation);
|
||||
},
|
||||
);
|
||||
@@ -378,6 +401,7 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
const invitation = await adapter.findInvitationById(
|
||||
ctx.body.invitationId,
|
||||
);
|
||||
|
||||
if (
|
||||
!invitation ||
|
||||
invitation.expiresAt < new Date() ||
|
||||
@@ -387,12 +411,14 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION,
|
||||
});
|
||||
}
|
||||
|
||||
const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100;
|
||||
const members = await adapter.listMembers({
|
||||
organizationId: invitation.organizationId,
|
||||
@@ -408,6 +434,7 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
invitationId: ctx.body.invitationId,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
if (!acceptedI) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION,
|
||||
@@ -417,34 +444,52 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
if (
|
||||
ctx.context.orgOptions.teams &&
|
||||
ctx.context.orgOptions.teams.enabled &&
|
||||
typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !==
|
||||
"undefined" &&
|
||||
"teamId" in acceptedI &&
|
||||
acceptedI.teamId
|
||||
) {
|
||||
const teamId = acceptedI.teamId as string;
|
||||
const team = await adapter.findTeamById({
|
||||
teamId,
|
||||
organizationId: invitation.organizationId,
|
||||
includeTeamMembers: true,
|
||||
});
|
||||
if (!team) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
|
||||
const teamIds = (acceptedI.teamId as string).split(",");
|
||||
const onlyOne = teamIds.length === 1;
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
await adapter.findOrCreateTeamMember({
|
||||
teamId: teamId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (
|
||||
typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !==
|
||||
"undefined"
|
||||
) {
|
||||
const members = await adapter.listTeamMembers({ teamId });
|
||||
|
||||
const maximumMembersPerTeam =
|
||||
typeof ctx.context.orgOptions.teams.maximumMembersPerTeam ===
|
||||
"function"
|
||||
? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
|
||||
teamId,
|
||||
session: session,
|
||||
organizationId: invitation.organizationId,
|
||||
})
|
||||
: ctx.context.orgOptions.teams.maximumMembersPerTeam;
|
||||
|
||||
if (members.length >= maximumMembersPerTeam) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const maximumMembersPerTeam =
|
||||
typeof ctx.context.orgOptions.teams.maximumMembersPerTeam ===
|
||||
"function"
|
||||
? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
|
||||
teamId,
|
||||
session: session,
|
||||
organizationId: invitation.organizationId,
|
||||
})
|
||||
: ctx.context.orgOptions.teams.maximumMembersPerTeam;
|
||||
if (team.members.length >= maximumMembersPerTeam) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED,
|
||||
|
||||
if (onlyOne) {
|
||||
const teamId = teamIds[0];
|
||||
const updatedSession = await adapter.setActiveTeam(
|
||||
session.session.token,
|
||||
teamId,
|
||||
);
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session: updatedSession,
|
||||
user: session.user,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -454,16 +499,13 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
userId: session.user.id,
|
||||
role: invitation.role as string,
|
||||
createdAt: new Date(),
|
||||
...("teamId" in acceptedI
|
||||
? {
|
||||
teamId: acceptedI.teamId as string,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
await adapter.setActiveOrganization(
|
||||
session.session.token,
|
||||
invitation.organizationId,
|
||||
);
|
||||
|
||||
if (!acceptedI) {
|
||||
return ctx.json(null, {
|
||||
status: 400,
|
||||
@@ -472,6 +514,7 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
invitation: acceptedI,
|
||||
member,
|
||||
@@ -518,7 +561,7 @@ export const rejectInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
},
|
||||
async (ctx) => {
|
||||
const session = ctx.context.session;
|
||||
const adapter = getOrgAdapter<O>(ctx.context, options);
|
||||
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
||||
const invitation = await adapter.findInvitationById(
|
||||
ctx.body.invitationId,
|
||||
);
|
||||
@@ -537,10 +580,12 @@ export const rejectInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION,
|
||||
});
|
||||
}
|
||||
|
||||
const rejectedI = await adapter.updateInvitation({
|
||||
invitationId: ctx.body.invitationId,
|
||||
status: "rejected",
|
||||
});
|
||||
|
||||
return ctx.json({
|
||||
invitation: rejectedI,
|
||||
member: null,
|
||||
|
||||
@@ -153,9 +153,15 @@ export const addMember = <O extends OrganizationOptions>(option: O) => {
|
||||
role: parseRoles(ctx.body.role as string | string[]),
|
||||
createdAt: new Date(),
|
||||
...(additionalFields ? additionalFields : {}),
|
||||
...(teamId ? { teamId } : {}),
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
await adapter.findOrCreateTeamMember({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json(createdMember);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
InferOrganization,
|
||||
Member,
|
||||
Team,
|
||||
TeamMember,
|
||||
} from "../schema";
|
||||
import { hasPermission } from "../has-permission";
|
||||
import {
|
||||
@@ -166,6 +167,7 @@ export const createOrganization = <O extends OrganizationOptions>(
|
||||
data: Record<string, any>;
|
||||
}
|
||||
| undefined = undefined;
|
||||
|
||||
if (options.organizationCreation?.beforeCreate) {
|
||||
const response = await options.organizationCreation.beforeCreate(
|
||||
{
|
||||
@@ -189,9 +191,12 @@ export const createOrganization = <O extends OrganizationOptions>(
|
||||
...(hookResponse?.data || {}),
|
||||
},
|
||||
});
|
||||
|
||||
let member:
|
||||
| (Member & InferAdditionalFieldsFromPluginOptions<"member", O, false>)
|
||||
| undefined;
|
||||
let teamMember: TeamMember | null = null;
|
||||
|
||||
if (
|
||||
options?.teams?.enabled &&
|
||||
options.teams.defaultTeam?.enabled !== false
|
||||
@@ -208,11 +213,14 @@ export const createOrganization = <O extends OrganizationOptions>(
|
||||
}));
|
||||
|
||||
member = await adapter.createMember({
|
||||
teamId: defaultTeam.id,
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
role: ctx.context.orgOptions.creatorRole || "owner",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
teamMember = await adapter.findOrCreateTeamMember({
|
||||
teamId: defaultTeam.id,
|
||||
userId: user.id,
|
||||
});
|
||||
} else {
|
||||
member = await adapter.createMember({
|
||||
@@ -240,6 +248,17 @@ export const createOrganization = <O extends OrganizationOptions>(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
teamMember &&
|
||||
ctx.context.session &&
|
||||
!ctx.body.keepCurrentActiveOrganization
|
||||
) {
|
||||
await adapter.setActiveTeam(
|
||||
ctx.context.session.session.token,
|
||||
teamMember.teamId,
|
||||
);
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
...organization,
|
||||
metadata: ctx.body.metadata,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ORGANIZATION_ERROR_CODES } from "../error-codes";
|
||||
import type { OrganizationOptions } from "../types";
|
||||
import { teamSchema } from "../schema";
|
||||
import { hasPermission } from "../has-permission";
|
||||
import { setSessionCookie } from "../../../cookies";
|
||||
import {
|
||||
toZodSchema,
|
||||
type InferAdditionalFieldsFromPluginOptions,
|
||||
@@ -232,7 +233,7 @@ export const removeTeam = <O extends OrganizationOptions>(options: O) =>
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (!member || member.teamId === ctx.body.teamId) {
|
||||
if (!member || session.session?.activeTeamId === ctx.body.teamId) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM,
|
||||
@@ -522,7 +523,483 @@ export const listOrganizationTeams = <O extends OrganizationOptions>(
|
||||
}
|
||||
|
||||
const teams = await adapter.listTeams(organizationId);
|
||||
return ctx.json(teams);
|
||||
},
|
||||
);
|
||||
|
||||
export const setActiveTeam = <O extends OrganizationOptions>(options: O) =>
|
||||
createAuthEndpoint(
|
||||
"/organization/set-active-team",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
teamId: z
|
||||
.string()
|
||||
.meta({
|
||||
description:
|
||||
"The team id to set as active. It can be null to unset the active team",
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
}),
|
||||
use: [orgSessionMiddleware, orgMiddleware],
|
||||
metadata: {
|
||||
openapi: {
|
||||
description: "Set the active team",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Success",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
description: "The team",
|
||||
$ref: "#/components/schemas/Team",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (ctx) => {
|
||||
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
||||
const session = ctx.context.session;
|
||||
|
||||
if (ctx.body.teamId === null) {
|
||||
const sessionTeamId = session.session.activeTeamId;
|
||||
if (!sessionTeamId) {
|
||||
return ctx.json(null);
|
||||
}
|
||||
|
||||
const updatedSession = await adapter.setActiveTeam(
|
||||
session.session.token,
|
||||
null,
|
||||
);
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session: updatedSession,
|
||||
user: session.user,
|
||||
});
|
||||
|
||||
return ctx.json(null);
|
||||
}
|
||||
|
||||
let teamId: string;
|
||||
|
||||
if (!ctx.body.teamId) {
|
||||
const sessionTeamId = session.session.activeTeamId;
|
||||
if (!sessionTeamId) {
|
||||
return ctx.json(null);
|
||||
} else {
|
||||
teamId = sessionTeamId;
|
||||
}
|
||||
} else {
|
||||
teamId = ctx.body.teamId;
|
||||
}
|
||||
|
||||
const team = await adapter.findTeamById({ teamId });
|
||||
|
||||
if (!team) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
const member = await adapter.findTeamMember({
|
||||
teamId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedSession = await adapter.setActiveTeam(
|
||||
session.session.token,
|
||||
team.id,
|
||||
);
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session: updatedSession,
|
||||
user: session.user,
|
||||
});
|
||||
|
||||
return ctx.json(team);
|
||||
},
|
||||
);
|
||||
|
||||
export const listUserTeams = <O extends OrganizationOptions>(options: O) =>
|
||||
createAuthEndpoint(
|
||||
"/organization/list-user-teams",
|
||||
{
|
||||
method: "GET",
|
||||
metadata: {
|
||||
openapi: {
|
||||
description: "List all teams that the current user is a part of.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Teams retrieved successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
description: "The team",
|
||||
$ref: "#/components/schemas/Team",
|
||||
},
|
||||
description:
|
||||
"Array of team objects within the organization",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
use: [orgMiddleware, orgSessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const session = ctx.context.session;
|
||||
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
||||
const teams = await adapter.listTeamsByUser({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return ctx.json(teams);
|
||||
},
|
||||
);
|
||||
|
||||
export const listTeamMembers = <O extends OrganizationOptions>(options: O) =>
|
||||
createAuthEndpoint(
|
||||
"/organization/list-team-members",
|
||||
{
|
||||
method: "GET",
|
||||
query: z.optional(
|
||||
z
|
||||
.object({
|
||||
teamId: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
description:
|
||||
"The team whose members we should return. If this is not provided the members of the current active team get returned.",
|
||||
}),
|
||||
),
|
||||
metadata: {
|
||||
openapi: {
|
||||
description: "List the members of the given team.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Teams retrieved successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
description: "The team member",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "Unique identifier of the team member",
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
description: "The user ID of the team member",
|
||||
},
|
||||
teamId: {
|
||||
type: "string",
|
||||
description:
|
||||
"The team ID of the team the team member is in",
|
||||
},
|
||||
createdAt: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
description:
|
||||
"Timestamp when the team member was created",
|
||||
},
|
||||
},
|
||||
required: ["id", "userId", "teamId", "createdAt"],
|
||||
},
|
||||
description: "Array of team member objects within the team",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
use: [orgMiddleware, orgSessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const session = ctx.context.session;
|
||||
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
||||
|
||||
if (!session.session.activeOrganizationId) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.query?.teamId) {
|
||||
if (!session.session.activeTeamId) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: ORGANIZATION_ERROR_CODES.YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM,
|
||||
});
|
||||
}
|
||||
|
||||
const member = await adapter.findTeamMember({
|
||||
userId: session.user.id,
|
||||
teamId: session.session.activeTeamId,
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM,
|
||||
});
|
||||
}
|
||||
|
||||
const members = await adapter.listTeamMembers({
|
||||
teamId: session.session.activeTeamId,
|
||||
});
|
||||
|
||||
return ctx.json(members);
|
||||
}
|
||||
|
||||
const member = await adapter.findTeamMember({
|
||||
userId: session.user.id,
|
||||
teamId: ctx.query.teamId,
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM,
|
||||
});
|
||||
}
|
||||
|
||||
const members = await adapter.listTeamMembers({
|
||||
teamId: ctx.query.teamId,
|
||||
});
|
||||
|
||||
return ctx.json(members);
|
||||
},
|
||||
);
|
||||
|
||||
export const addTeamMember = <O extends OrganizationOptions>(options: O) =>
|
||||
createAuthEndpoint(
|
||||
"/organization/add-team-member",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
teamId: z.string().meta({
|
||||
description: "The team the user should be a member of.",
|
||||
}),
|
||||
|
||||
userId: z.coerce.string().meta({
|
||||
description:
|
||||
"The user Id which represents the user to be added as a member.",
|
||||
}),
|
||||
}),
|
||||
metadata: {
|
||||
openapi: {
|
||||
description: "The newly created member",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Team member created successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
description: "The team member",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "Unique identifier of the team member",
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
description: "The user ID of the team member",
|
||||
},
|
||||
teamId: {
|
||||
type: "string",
|
||||
description:
|
||||
"The team ID of the team the team member is in",
|
||||
},
|
||||
createdAt: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
description:
|
||||
"Timestamp when the team member was created",
|
||||
},
|
||||
},
|
||||
required: ["id", "userId", "teamId", "createdAt"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
use: [orgMiddleware, orgSessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const session = ctx.context.session;
|
||||
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
||||
|
||||
if (!session.session.activeOrganizationId) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
const currentMember = await adapter.findMemberByOrgId({
|
||||
userId: session.user.id,
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
});
|
||||
|
||||
if (!currentMember) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
const canUpdateMember = hasPermission({
|
||||
role: currentMember.role,
|
||||
options: ctx.context.orgOptions,
|
||||
permissions: {
|
||||
member: ["update"],
|
||||
},
|
||||
});
|
||||
|
||||
if (!canUpdateMember) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER,
|
||||
});
|
||||
}
|
||||
|
||||
const toBeAddedMember = await adapter.findMemberByOrgId({
|
||||
userId: ctx.body.userId,
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
});
|
||||
|
||||
if (!toBeAddedMember) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
const teamMember = await adapter.findOrCreateTeamMember({
|
||||
teamId: ctx.body.teamId,
|
||||
userId: ctx.body.userId,
|
||||
});
|
||||
|
||||
return ctx.json(teamMember);
|
||||
},
|
||||
);
|
||||
|
||||
export const removeTeamMember = <O extends OrganizationOptions>(options: O) =>
|
||||
createAuthEndpoint(
|
||||
"/organization/remove-team-member",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
teamId: z.string().meta({
|
||||
description: "The team the user should be removed from.",
|
||||
}),
|
||||
|
||||
userId: z.coerce.string().meta({
|
||||
description: "The user which should be removed from the team.",
|
||||
}),
|
||||
}),
|
||||
metadata: {
|
||||
openapi: {
|
||||
description: "Remove a member from a team",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Team member removed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description:
|
||||
"Confirmation message indicating successful removal",
|
||||
enum: ["Team member removed successfully."],
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
use: [orgMiddleware, orgSessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const session = ctx.context.session;
|
||||
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
||||
|
||||
if (!session.session.activeOrganizationId) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
const currentMember = await adapter.findMemberByOrgId({
|
||||
userId: session.user.id,
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
});
|
||||
|
||||
if (!currentMember) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
const canDeleteMember = hasPermission({
|
||||
role: currentMember.role,
|
||||
options: ctx.context.orgOptions,
|
||||
permissions: {
|
||||
member: ["delete"],
|
||||
},
|
||||
});
|
||||
|
||||
if (!canDeleteMember) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER,
|
||||
});
|
||||
}
|
||||
|
||||
const toBeAddedMember = await adapter.findMemberByOrgId({
|
||||
userId: ctx.body.userId,
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
});
|
||||
|
||||
if (!toBeAddedMember) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
await adapter.removeTeamMember({
|
||||
teamId: ctx.body.teamId,
|
||||
userId: ctx.body.userId,
|
||||
});
|
||||
|
||||
return ctx.json({ message: "Team member removed successfully." });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,7 +27,6 @@ export const memberSchema = z.object({
|
||||
userId: z.coerce.string(),
|
||||
role,
|
||||
createdAt: z.date().default(() => new Date()),
|
||||
teamId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const invitationSchema = z.object({
|
||||
@@ -49,12 +48,21 @@ export const teamSchema = z.object({
|
||||
updatedAt: z.date().optional(),
|
||||
});
|
||||
|
||||
export const teamMemberSchema = z.object({
|
||||
id: z.string().default(generateId),
|
||||
teamId: z.string(),
|
||||
userId: z.string(),
|
||||
createdAt: z.date().default(() => new Date()),
|
||||
});
|
||||
|
||||
export type Organization = z.infer<typeof organizationSchema>;
|
||||
export type Member = z.infer<typeof memberSchema>;
|
||||
export type TeamMember = z.infer<typeof teamMemberSchema>;
|
||||
export type Team = z.infer<typeof teamSchema>;
|
||||
export type Invitation = z.infer<typeof invitationSchema>;
|
||||
export type InvitationInput = z.input<typeof invitationSchema>;
|
||||
export type MemberInput = z.input<typeof memberSchema>;
|
||||
export type TeamMemberInput = z.input<typeof teamMemberSchema>;
|
||||
export type OrganizationInput = z.input<typeof organizationSchema>;
|
||||
export type TeamInput = z.infer<typeof teamSchema>;
|
||||
|
||||
|
||||
@@ -5,23 +5,22 @@ import { createAuthClient } from "../../client";
|
||||
import { organizationClient } from "./client";
|
||||
|
||||
describe("team", async (it) => {
|
||||
const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
|
||||
await getTestInstance({
|
||||
user: {
|
||||
modelName: "users",
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
async sendInvitationEmail(data, request) {},
|
||||
teams: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
logger: {
|
||||
level: "error",
|
||||
},
|
||||
});
|
||||
const { auth, signInWithTestUser, cookieSetter } = await getTestInstance({
|
||||
user: {
|
||||
modelName: "users",
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
async sendInvitationEmail() {},
|
||||
teams: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
logger: {
|
||||
level: "error",
|
||||
},
|
||||
});
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const client = createAuthClient({
|
||||
@@ -50,6 +49,11 @@ describe("team", async (it) => {
|
||||
name: "Invited User",
|
||||
};
|
||||
|
||||
const signUpHeaders = new Headers();
|
||||
const signUpRes = await client.signUp.email(invitedUser, {
|
||||
onSuccess: cookieSetter(signUpHeaders),
|
||||
});
|
||||
|
||||
it("should create an organization and a team", async () => {
|
||||
const createOrganizationResponse = await client.organization.create({
|
||||
name: "Test Organization",
|
||||
@@ -119,29 +123,58 @@ describe("team", async (it) => {
|
||||
teamId,
|
||||
});
|
||||
|
||||
const newHeaders = new Headers();
|
||||
const signUpRes = await client.signUp.email(invitedUser, {
|
||||
onSuccess: cookieSetter(newHeaders),
|
||||
});
|
||||
|
||||
expect(signUpRes.data?.user).toBeDefined();
|
||||
|
||||
const invitation = await client.organization.acceptInvitation(
|
||||
{
|
||||
invitationId: res.data?.id as string,
|
||||
},
|
||||
{
|
||||
headers: newHeaders,
|
||||
headers: signUpHeaders,
|
||||
},
|
||||
);
|
||||
|
||||
expect(invitation.data?.member).toMatchObject({
|
||||
role: "member",
|
||||
teamId,
|
||||
userId: signUpRes.data?.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("should add team to the member's list of teams", async () => {
|
||||
const listUserTeamsRes = await client.organization.listUserTeams(
|
||||
{},
|
||||
{
|
||||
headers: signUpHeaders,
|
||||
},
|
||||
);
|
||||
|
||||
expect(listUserTeamsRes.error).toBeNull();
|
||||
expect(listUserTeamsRes.data).not.toBeNull();
|
||||
expect(listUserTeamsRes.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should be able to list team members in the current active team", async () => {
|
||||
const activeTeamHeaders = new Headers();
|
||||
await client.organization.setActiveTeam(
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
headers: signUpHeaders,
|
||||
onSuccess: cookieSetter(activeTeamHeaders),
|
||||
},
|
||||
);
|
||||
|
||||
const res = await client.organization.listTeamMembers(
|
||||
{},
|
||||
{
|
||||
headers: activeTeamHeaders,
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.error).toBeNull();
|
||||
expect(res.data).not.toBeNull();
|
||||
expect(res.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should get full organization", async () => {
|
||||
const organization = await client.organization.getFullOrganization({
|
||||
fetchOptions: {
|
||||
@@ -313,3 +346,275 @@ describe("team", async (it) => {
|
||||
expect(res2.error?.code).toEqual("TEAM_MEMBER_LIMIT_REACHED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mulit team support", async (it) => {
|
||||
const { auth, signInWithTestUser, cookieSetter } = await getTestInstance(
|
||||
{
|
||||
plugins: [
|
||||
organization({
|
||||
async sendInvitationEmail() {},
|
||||
teams: {
|
||||
enabled: true,
|
||||
defaultTeam: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
logger: {
|
||||
level: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
testWith: "sqlite",
|
||||
},
|
||||
);
|
||||
|
||||
const admin = await signInWithTestUser();
|
||||
|
||||
const invitedUser = await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: "Invited User",
|
||||
email: "invited@email.com",
|
||||
password: "password",
|
||||
},
|
||||
returnHeaders: true,
|
||||
});
|
||||
|
||||
let organizationId: string | null = null;
|
||||
|
||||
let team1Id: string | null = null;
|
||||
let team2Id: string | null = null;
|
||||
let team3Id: string | null = null;
|
||||
|
||||
let invitationId: string | null = null;
|
||||
|
||||
it("should create an organization to test multi team support", async () => {
|
||||
const organization = await auth.api.createOrganization({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
name: "Test Organization",
|
||||
slug: "test-org",
|
||||
metadata: {
|
||||
test: "organization-metadata",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(organization?.id).toBeDefined();
|
||||
expect(organization?.name).toBe("Test Organization");
|
||||
|
||||
organizationId = organization?.id as string;
|
||||
});
|
||||
|
||||
it("should create 3 teams", async () => {
|
||||
expect(organizationId).toBeDefined();
|
||||
if (!organizationId) throw Error("can not run test");
|
||||
|
||||
const team1 = await auth.api.createTeam({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
name: "Team One",
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(team1.id).toBeDefined();
|
||||
expect(team1.organizationId).toBe(organizationId);
|
||||
|
||||
team1Id = team1.id;
|
||||
|
||||
const team2 = await auth.api.createTeam({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
name: "Team Two",
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(team2.id).toBeDefined();
|
||||
expect(team2.organizationId).toBe(organizationId);
|
||||
|
||||
team2Id = team2.id;
|
||||
|
||||
const team3 = await auth.api.createTeam({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
name: "Team Three",
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(team3.id).toBeDefined();
|
||||
expect(team3.organizationId).toBe(organizationId);
|
||||
|
||||
team3Id = team3.id;
|
||||
});
|
||||
|
||||
it("should invite user to all 3 teams", async () => {
|
||||
expect(organizationId).toBeDefined();
|
||||
expect(team1Id).toBeDefined();
|
||||
expect(team2Id).toBeDefined();
|
||||
expect(team3Id).toBeDefined();
|
||||
|
||||
if (!organizationId || !team1Id || !team2Id || !team3Id)
|
||||
throw Error("can not run test");
|
||||
|
||||
const invitation = await auth.api.createInvitation({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
email: invitedUser.response.user.email,
|
||||
role: "member",
|
||||
organizationId,
|
||||
teamId: [team1Id, team2Id, team3Id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(invitation.id).toBeDefined();
|
||||
expect(invitation.teamId).toBe([team1Id, team2Id, team3Id].join(","));
|
||||
|
||||
invitationId = invitation.id;
|
||||
});
|
||||
|
||||
it("should accept invite and join all 3 teams", async () => {
|
||||
expect(invitationId).toBeDefined();
|
||||
|
||||
if (!invitationId) throw Error("can not run test");
|
||||
|
||||
const accept = await auth.api.acceptInvitation({
|
||||
headers: { cookie: invitedUser.headers.getSetCookie()[0] },
|
||||
body: {
|
||||
invitationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(accept?.member).toBeDefined();
|
||||
expect(accept?.invitation).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have jonied all 3 teams", async () => {
|
||||
expect(invitationId).toBeDefined();
|
||||
|
||||
if (!invitationId) throw Error("can not run test");
|
||||
|
||||
const teams = await auth.api.listUserTeams({
|
||||
headers: { cookie: invitedUser.headers.getSetCookie()[0] },
|
||||
});
|
||||
|
||||
expect(teams).toHaveLength(3);
|
||||
});
|
||||
|
||||
let activeTeamCookie: string | null = null;
|
||||
|
||||
it("should allow you to set one of the teams as active", async () => {
|
||||
expect(team1Id).toBeDefined();
|
||||
expect(organizationId).toBeDefined();
|
||||
|
||||
if (!team1Id || !organizationId) throw Error("can not run test");
|
||||
|
||||
const team = await auth.api.setActiveTeam({
|
||||
headers: { cookie: invitedUser.headers.getSetCookie()[0] },
|
||||
body: {
|
||||
teamId: team1Id,
|
||||
},
|
||||
returnHeaders: true,
|
||||
});
|
||||
|
||||
expect(team.response?.id).toBe(team1Id);
|
||||
expect(team.response?.organizationId).toBe(organizationId);
|
||||
|
||||
activeTeamCookie = team.headers.getSetCookie()[0];
|
||||
});
|
||||
|
||||
it("should allow you to list team members of the current active team", async () => {
|
||||
expect(activeTeamCookie).toBeDefined();
|
||||
|
||||
if (!activeTeamCookie) throw Error("can not run test");
|
||||
|
||||
const members = await auth.api.listTeamMembers({
|
||||
headers: { cookie: activeTeamCookie },
|
||||
});
|
||||
|
||||
expect(members).toHaveLength(1);
|
||||
expect(members.at(0)?.teamId).toBe(team1Id);
|
||||
});
|
||||
|
||||
it("should allow user to list team members of any team the user is in", async () => {
|
||||
expect(team2Id).toBeDefined();
|
||||
expect(team3Id).toBeDefined();
|
||||
|
||||
if (!team2Id || !team3Id) throw Error("can not run test");
|
||||
|
||||
const team2Members = await auth.api.listTeamMembers({
|
||||
headers: { cookie: invitedUser.headers.getSetCookie()[0] },
|
||||
query: {
|
||||
teamId: team2Id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(team2Members).toHaveLength(1);
|
||||
expect(team2Members.at(0)?.teamId).toBe(team2Id);
|
||||
|
||||
const team3Members = await auth.api.listTeamMembers({
|
||||
headers: { cookie: invitedUser.headers.getSetCookie()[0] },
|
||||
query: {
|
||||
teamId: team3Id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(team3Members).toHaveLength(1);
|
||||
expect(team3Members.at(0)?.teamId).toBe(team3Id);
|
||||
});
|
||||
|
||||
let team4Id: string | null = null;
|
||||
it("should directly add a member to a team", async () => {
|
||||
expect(organizationId).toBeDefined();
|
||||
if (!organizationId) throw Error("can not run test");
|
||||
|
||||
const team = await auth.api.createTeam({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
name: "Team Four",
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMember = await auth.api.addTeamMember({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
userId: invitedUser.response.user.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(teamMember.teamId).toBe(team.id);
|
||||
expect(teamMember.userId).toBe(invitedUser.response.user.id);
|
||||
|
||||
const teams = await auth.api.listUserTeams({
|
||||
headers: { cookie: invitedUser.headers.getSetCookie()[0] },
|
||||
});
|
||||
|
||||
expect(teams).toHaveLength(4);
|
||||
|
||||
team4Id = team.id;
|
||||
});
|
||||
|
||||
it("should remove a member from a team", async () => {
|
||||
expect(team4Id).toBeDefined();
|
||||
if (!team4Id) throw Error("can not run test");
|
||||
|
||||
await auth.api.removeTeamMember({
|
||||
headers: admin.headers,
|
||||
body: {
|
||||
userId: invitedUser.response.user.id,
|
||||
teamId: team4Id,
|
||||
},
|
||||
});
|
||||
|
||||
const teams = await auth.api.listUserTeams({
|
||||
headers: { cookie: invitedUser.headers.getSetCookie()[0] },
|
||||
});
|
||||
|
||||
expect(teams).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { FieldAttribute } from "../../db";
|
||||
import type { User, Session, AuthContext } from "../../types";
|
||||
import type { AccessControl, Role } from "../access";
|
||||
import type { Invitation, Member, Organization, Team } from "./schema";
|
||||
import type {
|
||||
Invitation,
|
||||
Member,
|
||||
Organization,
|
||||
Team,
|
||||
TeamMember,
|
||||
} from "./schema";
|
||||
|
||||
export interface OrganizationOptions {
|
||||
/**
|
||||
@@ -217,6 +223,7 @@ export interface OrganizationOptions {
|
||||
session?: {
|
||||
fields?: {
|
||||
activeOrganizationId?: string;
|
||||
activeTeamId?: string;
|
||||
};
|
||||
};
|
||||
organization?: {
|
||||
@@ -256,6 +263,13 @@ export interface OrganizationOptions {
|
||||
[key in string]: FieldAttribute;
|
||||
};
|
||||
};
|
||||
|
||||
teamMember?: {
|
||||
modelName?: string;
|
||||
fields?: {
|
||||
[key in keyof Omit<TeamMember, "id">]?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Configure how organization deletion is handled
|
||||
|
||||
@@ -11,6 +11,7 @@ import Database from "better-sqlite3";
|
||||
import { getMigrations } from "../db";
|
||||
|
||||
let server = new OAuth2Server();
|
||||
let port = 8005;
|
||||
|
||||
vi.mock("../oauth2", async (importOriginal) => {
|
||||
const original = (await importOriginal()) as any;
|
||||
@@ -75,7 +76,7 @@ vi.mock("../oauth2", async (importOriginal) => {
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(options.clientId).toBe("test-client-id");
|
||||
expect(options.clientSecret).toBe("test-client-secret");
|
||||
expect(tokenEndpoint).toBe("http://localhost:8080/token");
|
||||
expect(tokenEndpoint).toBe(`http://localhost:${port}/token`);
|
||||
|
||||
const data: GoogleProfile = {
|
||||
email: "user@email.com",
|
||||
@@ -150,8 +151,8 @@ describe("Social Providers", async (c) => {
|
||||
beforeAll(async () => {
|
||||
await server.issuer.keys.generate("RS256");
|
||||
server.issuer.on;
|
||||
await server.start(8080, "localhost");
|
||||
console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080
|
||||
await server.start(port, "localhost");
|
||||
console.log("Issuer URL:", server.issuer.url); // -> http://localhost:${port}
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.stop().catch(console.error);
|
||||
@@ -206,7 +207,7 @@ describe("Social Providers", async (c) => {
|
||||
clientKey: "test-client-key",
|
||||
clientSecret: "test-client-secret",
|
||||
},
|
||||
tokenEndpoint: "http://localhost:8080/token",
|
||||
tokenEndpoint: `http://localhost:${port}/token`,
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
@@ -382,7 +383,7 @@ describe("Social Providers", async (c) => {
|
||||
if (!authUrl) throw new Error("No auth url found");
|
||||
const mockEndpoint = authUrl.replace(
|
||||
"https://accounts.google.com/o/oauth2/auth",
|
||||
"http://localhost:8080/authorize",
|
||||
`http://localhost:${port}/authorize`,
|
||||
);
|
||||
const result = await simulateOAuthFlowRefresh(mockEndpoint, headers);
|
||||
const { accessToken, refreshToken } = result;
|
||||
|
||||
@@ -52,7 +52,6 @@ describe("general types", async (it) => {
|
||||
token: string;
|
||||
ipAddress?: string | undefined | null;
|
||||
userAgent?: string | undefined | null;
|
||||
activeOrganizationId?: string | undefined | null;
|
||||
}>();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user