mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #2321 from Infisical/daniel/specify-roles-and-projects
Feat: Select roles & projects when inviting members to organization
This commit is contained in:
@@ -74,6 +74,7 @@ const envSchema = z
|
|||||||
JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")),
|
JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")),
|
||||||
JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")),
|
JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")),
|
||||||
JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")),
|
JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")),
|
||||||
|
JWT_INVITE_LIFETIME: zpStr(z.string().default("1d")),
|
||||||
JWT_MFA_LIFETIME: zpStr(z.string().default("5m")),
|
JWT_MFA_LIFETIME: zpStr(z.string().default("5m")),
|
||||||
JWT_PROVIDER_AUTH_LIFETIME: zpStr(z.string().default("15m")),
|
JWT_PROVIDER_AUTH_LIFETIME: zpStr(z.string().default("15m")),
|
||||||
// Oauth
|
// Oauth
|
||||||
|
|||||||
@@ -477,9 +477,12 @@ export const registerRoutes = async (
|
|||||||
orgRoleDAL,
|
orgRoleDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
orgDAL,
|
orgDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
|
projectBotDAL,
|
||||||
incidentContactDAL,
|
incidentContactDAL,
|
||||||
tokenService,
|
tokenService,
|
||||||
projectUserAdditionalPrivilegeDAL,
|
projectUserAdditionalPrivilegeDAL,
|
||||||
|
projectUserMembershipRoleDAL,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
projectMembershipDAL,
|
projectMembershipDAL,
|
||||||
orgMembershipDAL,
|
orgMembershipDAL,
|
||||||
@@ -499,6 +502,8 @@ export const registerRoutes = async (
|
|||||||
projectDAL,
|
projectDAL,
|
||||||
projectBotDAL,
|
projectBotDAL,
|
||||||
groupProjectDAL,
|
groupProjectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectUserMembershipRoleDAL,
|
||||||
orgDAL,
|
orgDAL,
|
||||||
orgService,
|
orgService,
|
||||||
licenseService
|
licenseService
|
||||||
@@ -683,6 +688,7 @@ export const registerRoutes = async (
|
|||||||
orgDAL,
|
orgDAL,
|
||||||
orgService,
|
orgService,
|
||||||
projectMembershipDAL,
|
projectMembershipDAL,
|
||||||
|
projectRoleDAL,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
licenseService,
|
licenseService,
|
||||||
certificateAuthorityDAL,
|
certificateAuthorityDAL,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { UsersSchema } from "@app/db/schemas";
|
import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas";
|
||||||
import { inviteUserRateLimit } from "@app/server/config/rateLimiter";
|
import { inviteUserRateLimit } from "@app/server/config/rateLimiter";
|
||||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
@@ -16,23 +16,37 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
schema: {
|
schema: {
|
||||||
body: z.object({
|
body: z.object({
|
||||||
inviteeEmail: z.string().trim().email(),
|
inviteeEmails: z.array(z.string().trim().email()),
|
||||||
organizationId: z.string().trim()
|
organizationId: z.string().trim(),
|
||||||
|
projectIds: z.array(z.string().trim()).optional(),
|
||||||
|
projectRoleSlug: z.nativeEnum(ProjectMembershipRole).optional(),
|
||||||
|
organizationRoleSlug: z.nativeEnum(OrgMembershipRole)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
completeInviteLink: z.string().optional()
|
completeInviteLinks: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string(),
|
||||||
|
link: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
if (req.auth.actor !== ActorType.USER) return;
|
if (req.auth.actor !== ActorType.USER) return;
|
||||||
const completeInviteLink = await server.services.org.inviteUserToOrganization({
|
|
||||||
|
const completeInviteLinks = await server.services.org.inviteUserToOrganization({
|
||||||
orgId: req.body.organizationId,
|
orgId: req.body.organizationId,
|
||||||
userId: req.permission.id,
|
userId: req.permission.id,
|
||||||
inviteeEmail: req.body.inviteeEmail,
|
inviteeEmails: req.body.inviteeEmails,
|
||||||
|
projectIds: req.body.projectIds,
|
||||||
|
projectRoleSlug: req.body.projectRoleSlug,
|
||||||
|
organizationRoleSlug: req.body.organizationRoleSlug,
|
||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
actorOrgId: req.permission.orgId
|
actorOrgId: req.permission.orgId
|
||||||
});
|
});
|
||||||
@@ -41,14 +55,15 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
event: PostHogEventTypes.UserOrgInvitation,
|
event: PostHogEventTypes.UserOrgInvitation,
|
||||||
distinctId: getTelemetryDistinctId(req),
|
distinctId: getTelemetryDistinctId(req),
|
||||||
properties: {
|
properties: {
|
||||||
inviteeEmail: req.body.inviteeEmail,
|
inviteeEmails: req.body.inviteeEmails,
|
||||||
|
organizationRoleSlug: req.body.organizationRoleSlug,
|
||||||
...req.auditLogInfo
|
...req.auditLogInfo
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
completeInviteLink,
|
completeInviteLinks,
|
||||||
message: `Send an invite link to ${req.body.inviteeEmail}`
|
message: `Send an invite link to ${req.body.inviteeEmails.join(", ")}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { IntegrationsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
import {
|
||||||
|
IntegrationsSchema,
|
||||||
|
ProjectMembershipsSchema,
|
||||||
|
ProjectRolesSchema,
|
||||||
|
UserEncryptionKeysSchema,
|
||||||
|
UsersSchema
|
||||||
|
} from "@app/db/schemas";
|
||||||
import { PROJECTS } from "@app/lib/api-docs";
|
import { PROJECTS } from "@app/lib/api-docs";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
@@ -122,15 +128,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
rateLimit: readLimit
|
rateLimit: readLimit
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
includeRoles: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.default("false")
|
||||||
|
.transform((value) => value === "true")
|
||||||
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
workspaces: projectWithEnv.array()
|
workspaces: projectWithEnv
|
||||||
|
.extend({
|
||||||
|
roles: ProjectRolesSchema.array().optional()
|
||||||
|
})
|
||||||
|
.array()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const workspaces = await server.services.project.getProjects(req.permission.id);
|
const workspaces = await server.services.project.getProjects({
|
||||||
|
includeRoles: req.query.includeRoles,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorOrgId: req.permission.orgId
|
||||||
|
});
|
||||||
return { workspaces };
|
return { workspaces };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
|
|||||||
encryptedPrivateKeyIV: z.string().trim(),
|
encryptedPrivateKeyIV: z.string().trim(),
|
||||||
encryptedPrivateKeyTag: z.string().trim(),
|
encryptedPrivateKeyTag: z.string().trim(),
|
||||||
salt: z.string().trim(),
|
salt: z.string().trim(),
|
||||||
verifier: z.string().trim()
|
verifier: z.string().trim(),
|
||||||
|
tokenMetadata: z.string().optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||||
|
|
||||||
export enum TokenType {
|
export enum TokenType {
|
||||||
TOKEN_EMAIL_CONFIRMATION = "emailConfirmation",
|
TOKEN_EMAIL_CONFIRMATION = "emailConfirmation",
|
||||||
TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified
|
TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified
|
||||||
@@ -49,3 +51,19 @@ export type TIssueAuthTokenDTO = {
|
|||||||
ip: string;
|
ip: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum TokenMetadataType {
|
||||||
|
InviteToProjects = "projects-invite"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TTokenInviteToProjectsMetadataPayload = {
|
||||||
|
projectIds: string[];
|
||||||
|
projectRoleSlug: ProjectMembershipRole;
|
||||||
|
userId: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTokenMetadata = {
|
||||||
|
type: TokenMetadataType.InviteToProjects;
|
||||||
|
payload: TTokenInviteToProjectsMetadataPayload;
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
|
|||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
import { getUserPrivateKey } from "@app/lib/crypto/srp";
|
import { getUserPrivateKey } from "@app/lib/crypto/srp";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||||
import { isDisposableEmail } from "@app/lib/validator";
|
import { isDisposableEmail } from "@app/lib/validator";
|
||||||
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
|
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
|
||||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
@@ -17,9 +17,12 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal
|
|||||||
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
|
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
|
||||||
|
|
||||||
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||||
import { TokenType } from "../auth-token/auth-token-types";
|
import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types";
|
||||||
import { TOrgDALFactory } from "../org/org-dal";
|
import { TOrgDALFactory } from "../org/org-dal";
|
||||||
import { TOrgServiceFactory } from "../org/org-service";
|
import { TOrgServiceFactory } from "../org/org-service";
|
||||||
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
|
import { addMembersToProject } from "../project-membership/project-membership-fns";
|
||||||
|
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||||
import { TUserDALFactory } from "../user/user-dal";
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TAuthDALFactory } from "./auth-dal";
|
import { TAuthDALFactory } from "./auth-dal";
|
||||||
@@ -32,10 +35,14 @@ type TAuthSignupDep = {
|
|||||||
userDAL: TUserDALFactory;
|
userDAL: TUserDALFactory;
|
||||||
userGroupMembershipDAL: Pick<
|
userGroupMembershipDAL: Pick<
|
||||||
TUserGroupMembershipDALFactory,
|
TUserGroupMembershipDALFactory,
|
||||||
"find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds"
|
| "find"
|
||||||
|
| "transaction"
|
||||||
|
| "insertMany"
|
||||||
|
| "deletePendingUserGroupMembershipsByUserIds"
|
||||||
|
| "findUserGroupMembershipsInProject"
|
||||||
>;
|
>;
|
||||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
|
||||||
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
|
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser" | "findProjectById">;
|
||||||
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
||||||
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
|
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
|
||||||
orgService: Pick<TOrgServiceFactory, "createOrganization">;
|
orgService: Pick<TOrgServiceFactory, "createOrganization">;
|
||||||
@@ -43,6 +50,8 @@ type TAuthSignupDep = {
|
|||||||
tokenService: TAuthTokenServiceFactory;
|
tokenService: TAuthTokenServiceFactory;
|
||||||
smtpService: TSmtpService;
|
smtpService: TSmtpService;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "updateSubscriptionOrgMemberCount">;
|
licenseService: Pick<TLicenseServiceFactory, "updateSubscriptionOrgMemberCount">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "transaction" | "insertMany">;
|
||||||
|
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAuthSignupFactory = ReturnType<typeof authSignupServiceFactory>;
|
export type TAuthSignupFactory = ReturnType<typeof authSignupServiceFactory>;
|
||||||
@@ -58,6 +67,8 @@ export const authSignupServiceFactory = ({
|
|||||||
smtpService,
|
smtpService,
|
||||||
orgService,
|
orgService,
|
||||||
orgDAL,
|
orgDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectUserMembershipRoleDAL,
|
||||||
licenseService
|
licenseService
|
||||||
}: TAuthSignupDep) => {
|
}: TAuthSignupDep) => {
|
||||||
// first step of signup. create user and send email
|
// first step of signup. create user and send email
|
||||||
@@ -301,7 +312,8 @@ export const authSignupServiceFactory = ({
|
|||||||
encryptedPrivateKey,
|
encryptedPrivateKey,
|
||||||
encryptedPrivateKeyIV,
|
encryptedPrivateKeyIV,
|
||||||
encryptedPrivateKeyTag,
|
encryptedPrivateKeyTag,
|
||||||
authorization
|
authorization,
|
||||||
|
tokenMetadata
|
||||||
}: TCompleteAccountInviteDTO) => {
|
}: TCompleteAccountInviteDTO) => {
|
||||||
const user = await userDAL.findUserByUsername(email);
|
const user = await userDAL.findUserByUsername(email);
|
||||||
if (!user || (user && user.isAccepted)) {
|
if (!user || (user && user.isAccepted)) {
|
||||||
@@ -358,6 +370,45 @@ export const authSignupServiceFactory = ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (tokenMetadata) {
|
||||||
|
const metadataObj = jwt.verify(tokenMetadata, appCfg.AUTH_SECRET) as TTokenMetadata;
|
||||||
|
|
||||||
|
if (
|
||||||
|
metadataObj?.payload?.userId !== user.id ||
|
||||||
|
metadataObj?.payload?.orgId !== orgMembership.orgId ||
|
||||||
|
metadataObj?.type !== TokenMetadataType.InviteToProjects
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedError({
|
||||||
|
message: "Malformed or invalid metadata token"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const projectId of metadataObj.payload.projectIds) {
|
||||||
|
await addMembersToProject({
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectKeyDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
projectUserMembershipRoleDAL,
|
||||||
|
smtpService
|
||||||
|
}).addMembersToNonE2EEProject(
|
||||||
|
{
|
||||||
|
emails: [user.email!],
|
||||||
|
usernames: [],
|
||||||
|
projectId,
|
||||||
|
projectMembershipRole: metadataObj.payload.projectRoleSlug,
|
||||||
|
sendEmails: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx,
|
||||||
|
throwOnProjectNotFound: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedMembersips = await orgDAL.updateMembership(
|
const updatedMembersips = await orgDAL.updateMembership(
|
||||||
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
|
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
|
||||||
{ userId: us.id, status: OrgMembershipStatus.Accepted },
|
{ userId: us.id, status: OrgMembershipStatus.Accepted },
|
||||||
|
|||||||
@@ -37,4 +37,5 @@ export type TCompleteAccountInviteDTO = {
|
|||||||
ip: string;
|
ip: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
authorization: string;
|
authorization: string;
|
||||||
|
tokenMetadata?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,10 +114,11 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => {
|
const findOrgMembersByUsername = async (orgId: string, usernames: string[], tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const members = await db
|
const conn = tx || db;
|
||||||
.replicaNode()(TableName.OrgMembership)
|
const members = await conn(TableName.OrgMembership)
|
||||||
|
// .replicaNode()(TableName.OrgMembership)
|
||||||
.where(`${TableName.OrgMembership}.orgId`, orgId)
|
.where(`${TableName.OrgMembership}.orgId`, orgId)
|
||||||
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
||||||
.leftJoin<TUserEncryptionKeys>(
|
.leftJoin<TUserEncryptionKeys>(
|
||||||
@@ -126,18 +127,18 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
`${TableName.Users}.id`
|
`${TableName.Users}.id`
|
||||||
)
|
)
|
||||||
.select(
|
.select(
|
||||||
db.ref("id").withSchema(TableName.OrgMembership),
|
conn.ref("id").withSchema(TableName.OrgMembership),
|
||||||
db.ref("inviteEmail").withSchema(TableName.OrgMembership),
|
conn.ref("inviteEmail").withSchema(TableName.OrgMembership),
|
||||||
db.ref("orgId").withSchema(TableName.OrgMembership),
|
conn.ref("orgId").withSchema(TableName.OrgMembership),
|
||||||
db.ref("role").withSchema(TableName.OrgMembership),
|
conn.ref("role").withSchema(TableName.OrgMembership),
|
||||||
db.ref("roleId").withSchema(TableName.OrgMembership),
|
conn.ref("roleId").withSchema(TableName.OrgMembership),
|
||||||
db.ref("status").withSchema(TableName.OrgMembership),
|
conn.ref("status").withSchema(TableName.OrgMembership),
|
||||||
db.ref("username").withSchema(TableName.Users),
|
conn.ref("username").withSchema(TableName.Users),
|
||||||
db.ref("email").withSchema(TableName.Users),
|
conn.ref("email").withSchema(TableName.Users),
|
||||||
db.ref("firstName").withSchema(TableName.Users),
|
conn.ref("firstName").withSchema(TableName.Users),
|
||||||
db.ref("lastName").withSchema(TableName.Users),
|
conn.ref("lastName").withSchema(TableName.Users),
|
||||||
db.ref("id").withSchema(TableName.Users).as("userId"),
|
conn.ref("id").withSchema(TableName.Users).as("userId"),
|
||||||
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
|
conn.ref("publicKey").withSchema(TableName.UserEncryptionKey)
|
||||||
)
|
)
|
||||||
.where({ isGhost: false })
|
.where({ isGhost: false })
|
||||||
.whereIn("username", usernames);
|
.whereIn("username", usernames);
|
||||||
|
|||||||
@@ -4,9 +4,17 @@ import crypto from "crypto";
|
|||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas";
|
import {
|
||||||
|
OrgMembershipRole,
|
||||||
|
OrgMembershipStatus,
|
||||||
|
ProjectMembershipRole,
|
||||||
|
ProjectVersion,
|
||||||
|
TableName,
|
||||||
|
TUsers
|
||||||
|
} from "@app/db/schemas";
|
||||||
import { TProjects } from "@app/db/schemas/projects";
|
import { TProjects } from "@app/db/schemas/projects";
|
||||||
import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
|
import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
|
||||||
|
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
|
||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
@@ -24,10 +32,14 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
|||||||
|
|
||||||
import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
|
import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
|
||||||
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||||
import { TokenType } from "../auth-token/auth-token-types";
|
import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types";
|
||||||
import { TProjectDALFactory } from "../project/project-dal";
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
|
import { verifyProjectVersions } from "../project/project-fns";
|
||||||
|
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||||
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
|
import { addMembersToProject } from "../project-membership/project-membership-fns";
|
||||||
|
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||||
import { TUserDALFactory } from "../user/user-dal";
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TIncidentContactsDALFactory } from "./incident-contacts-dal";
|
import { TIncidentContactsDALFactory } from "./incident-contacts-dal";
|
||||||
@@ -56,8 +68,11 @@ type TOrgServiceFactoryDep = {
|
|||||||
userDAL: TUserDALFactory;
|
userDAL: TUserDALFactory;
|
||||||
groupDAL: TGroupDALFactory;
|
groupDAL: TGroupDALFactory;
|
||||||
projectDAL: TProjectDALFactory;
|
projectDAL: TProjectDALFactory;
|
||||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserId" | "delete">;
|
projectMembershipDAL: Pick<
|
||||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete">;
|
TProjectMembershipDALFactory,
|
||||||
|
"findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction"
|
||||||
|
>;
|
||||||
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "insertMany" | "findLatestProjectKey">;
|
||||||
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "findOrgMembershipById" | "findOne">;
|
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "findOrgMembershipById" | "findOne">;
|
||||||
incidentContactDAL: TIncidentContactsDALFactory;
|
incidentContactDAL: TIncidentContactsDALFactory;
|
||||||
samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne" | "findEnforceableSamlCfg">;
|
samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne" | "findEnforceableSamlCfg">;
|
||||||
@@ -69,6 +84,9 @@ type TOrgServiceFactoryDep = {
|
|||||||
"getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer"
|
"getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer"
|
||||||
>;
|
>;
|
||||||
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
|
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
|
||||||
|
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findUserGroupMembershipsInProject">;
|
||||||
|
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
||||||
|
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
|
export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
|
||||||
@@ -90,7 +108,10 @@ export const orgServiceFactory = ({
|
|||||||
tokenService,
|
tokenService,
|
||||||
orgBotDAL,
|
orgBotDAL,
|
||||||
licenseService,
|
licenseService,
|
||||||
samlConfigDAL
|
samlConfigDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
projectUserMembershipRoleDAL
|
||||||
}: TOrgServiceFactoryDep) => {
|
}: TOrgServiceFactoryDep) => {
|
||||||
/*
|
/*
|
||||||
* Get organization details by the organization id
|
* Get organization details by the organization id
|
||||||
@@ -420,10 +441,15 @@ export const orgServiceFactory = ({
|
|||||||
const inviteUserToOrganization = async ({
|
const inviteUserToOrganization = async ({
|
||||||
orgId,
|
orgId,
|
||||||
userId,
|
userId,
|
||||||
inviteeEmail,
|
inviteeEmails,
|
||||||
|
organizationRoleSlug,
|
||||||
|
projectRoleSlug,
|
||||||
|
projectIds,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
actorOrgId
|
actorOrgId
|
||||||
}: TInviteUserToOrgDTO) => {
|
}: TInviteUserToOrgDTO) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
|
||||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
|
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
|
||||||
|
|
||||||
@@ -450,98 +476,203 @@ export const orgServiceFactory = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitee = await orgDAL.transaction(async (tx) => {
|
if (projectIds?.length) {
|
||||||
const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx);
|
const projects = await projectDAL.find({
|
||||||
if (inviteeUser) {
|
orgId,
|
||||||
// if user already exist means its already part of infisical
|
$in: {
|
||||||
// Thus the signup flow is not needed anymore
|
id: projectIds
|
||||||
const [inviteeMembership] = await orgDAL.findMembership(
|
|
||||||
{
|
|
||||||
[`${TableName.OrgMembership}.orgId` as "orgId"]: orgId,
|
|
||||||
[`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id
|
|
||||||
},
|
|
||||||
{ tx }
|
|
||||||
);
|
|
||||||
if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) {
|
|
||||||
throw new BadRequestError({
|
|
||||||
message: "Failed to invite an existing member of org",
|
|
||||||
name: "Invite user to org"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!inviteeMembership) {
|
// if its not v3, throw an error
|
||||||
await orgDAL.createMembership(
|
if (!verifyProjectVersions(projects, ProjectVersion.V3)) {
|
||||||
{
|
|
||||||
userId: inviteeUser.id,
|
|
||||||
inviteEmail: inviteeEmail,
|
|
||||||
orgId,
|
|
||||||
role: OrgMembershipRole.Member,
|
|
||||||
status: OrgMembershipStatus.Invited,
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return inviteeUser;
|
|
||||||
}
|
|
||||||
const isEmailInvalid = await isDisposableEmail(inviteeEmail);
|
|
||||||
if (isEmailInvalid) {
|
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Provided a disposable email",
|
message: "One or more selected projects are not compatible with this operation. Please upgrade your projects."
|
||||||
name: "Org invite"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// not invited before
|
}
|
||||||
const user = await userDAL.create(
|
|
||||||
{
|
|
||||||
username: inviteeEmail,
|
|
||||||
email: inviteeEmail,
|
|
||||||
isAccepted: false,
|
|
||||||
authMethods: [AuthMethod.EMAIL],
|
|
||||||
isGhost: false
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
await orgDAL.createMembership(
|
|
||||||
{
|
|
||||||
inviteEmail: inviteeEmail,
|
|
||||||
orgId,
|
|
||||||
userId: user.id,
|
|
||||||
role: OrgMembershipRole.Member,
|
|
||||||
status: OrgMembershipStatus.Invited,
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
return user;
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = await tokenService.createTokenForUser({
|
const inviteeUsers = await orgDAL.transaction(async (tx) => {
|
||||||
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
|
const users: Pick<
|
||||||
userId: invitee.id,
|
TUsers & { orgId: string },
|
||||||
orgId
|
"id" | "firstName" | "lastName" | "email" | "orgId" | "username"
|
||||||
|
>[] = [];
|
||||||
|
for await (const inviteeEmail of inviteeEmails) {
|
||||||
|
const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx);
|
||||||
|
|
||||||
|
if (inviteeUser) {
|
||||||
|
// if user already exist means its already part of infisical
|
||||||
|
// Thus the signup flow is not needed anymore
|
||||||
|
const [inviteeMembership] = await orgDAL.findMembership(
|
||||||
|
{
|
||||||
|
[`${TableName.OrgMembership}.orgId` as "orgId"]: orgId,
|
||||||
|
[`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id
|
||||||
|
},
|
||||||
|
{ tx }
|
||||||
|
);
|
||||||
|
if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Failed to invite members because ${inviteeEmail} is already part of the organization`,
|
||||||
|
name: "Invite user to org"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inviteeMembership) {
|
||||||
|
await orgDAL.createMembership(
|
||||||
|
{
|
||||||
|
userId: inviteeUser.id,
|
||||||
|
inviteEmail: inviteeEmail,
|
||||||
|
orgId,
|
||||||
|
role: OrgMembershipRole.Member,
|
||||||
|
status: OrgMembershipStatus.Invited,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (projectIds?.length) {
|
||||||
|
if (
|
||||||
|
organizationRoleSlug === OrgMembershipRole.Custom ||
|
||||||
|
projectRoleSlug === ProjectMembershipRole.Custom
|
||||||
|
) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Custom roles are not supported for inviting users to projects and organizations"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectRoleSlug) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Selecting a project role is required to invite users to projects"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await projectMembershipDAL.insertMany(
|
||||||
|
projectIds.map((id) => ({ projectId: id, userId: inviteeUser.id })),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
for await (const projectId of projectIds) {
|
||||||
|
await addMembersToProject({
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectKeyDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
projectUserMembershipRoleDAL,
|
||||||
|
smtpService
|
||||||
|
}).addMembersToNonE2EEProject(
|
||||||
|
{
|
||||||
|
emails: [inviteeEmail],
|
||||||
|
usernames: [],
|
||||||
|
projectId,
|
||||||
|
projectMembershipRole: projectRoleSlug,
|
||||||
|
sendEmails: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [{ ...inviteeUser, orgId }];
|
||||||
|
}
|
||||||
|
const isEmailInvalid = await isDisposableEmail(inviteeEmail);
|
||||||
|
if (isEmailInvalid) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Provided a disposable email",
|
||||||
|
name: "Org invite"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// not invited before
|
||||||
|
const user = await userDAL.create(
|
||||||
|
{
|
||||||
|
username: inviteeEmail,
|
||||||
|
email: inviteeEmail,
|
||||||
|
isAccepted: false,
|
||||||
|
authMethods: [AuthMethod.EMAIL],
|
||||||
|
isGhost: false
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
await orgDAL.createMembership(
|
||||||
|
{
|
||||||
|
inviteEmail: inviteeEmail,
|
||||||
|
orgId,
|
||||||
|
userId: user.id,
|
||||||
|
role: organizationRoleSlug,
|
||||||
|
status: OrgMembershipStatus.Invited,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
users.push({
|
||||||
|
...user,
|
||||||
|
orgId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return users;
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await userDAL.findById(userId);
|
const user = await userDAL.findById(userId);
|
||||||
const appCfg = getConfig();
|
|
||||||
await smtpService.sendMail({
|
|
||||||
template: SmtpTemplates.OrgInvite,
|
|
||||||
subjectLine: "Infisical organization invitation",
|
|
||||||
recipients: [inviteeEmail],
|
|
||||||
substitutions: {
|
|
||||||
inviterFirstName: user.firstName,
|
|
||||||
inviterUsername: user.username,
|
|
||||||
organizationName: org?.name,
|
|
||||||
email: inviteeEmail,
|
|
||||||
organizationId: org?.id.toString(),
|
|
||||||
token,
|
|
||||||
callback_url: `${appCfg.SITE_URL}/signupinvite`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const signupTokens: { email: string; link: string }[] = [];
|
||||||
|
if (inviteeUsers) {
|
||||||
|
for await (const invitee of inviteeUsers) {
|
||||||
|
const token = await tokenService.createTokenForUser({
|
||||||
|
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
|
||||||
|
userId: invitee.id,
|
||||||
|
orgId
|
||||||
|
});
|
||||||
|
|
||||||
|
let inviteMetadata: string = "";
|
||||||
|
if (projectIds && projectIds?.length > 0) {
|
||||||
|
inviteMetadata = jwt.sign(
|
||||||
|
{
|
||||||
|
type: TokenMetadataType.InviteToProjects,
|
||||||
|
payload: {
|
||||||
|
projectIds,
|
||||||
|
projectRoleSlug: projectRoleSlug!, // Implicitly checked inside transaction if projectRoleSlug is undefined
|
||||||
|
userId: invitee.id,
|
||||||
|
orgId
|
||||||
|
}
|
||||||
|
} satisfies TTokenMetadata,
|
||||||
|
appCfg.AUTH_SECRET,
|
||||||
|
{
|
||||||
|
expiresIn: appCfg.JWT_INVITE_LIFETIME
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
signupTokens.push({
|
||||||
|
email: invitee.email || invitee.username,
|
||||||
|
link: `${appCfg.SITE_URL}/signupinvite?token=${token}${
|
||||||
|
inviteMetadata ? `&metadata=${inviteMetadata}` : ""
|
||||||
|
}&to=${invitee.email || invitee.username}&organization_id=${org?.id}`
|
||||||
|
});
|
||||||
|
|
||||||
|
await smtpService.sendMail({
|
||||||
|
template: SmtpTemplates.OrgInvite,
|
||||||
|
subjectLine: "Infisical organization invitation",
|
||||||
|
recipients: [invitee.email || invitee.username],
|
||||||
|
substitutions: {
|
||||||
|
metadata: inviteMetadata,
|
||||||
|
inviterFirstName: user.firstName,
|
||||||
|
inviterUsername: user.username,
|
||||||
|
organizationName: org?.name,
|
||||||
|
email: invitee.email || invitee.username,
|
||||||
|
organizationId: org?.id.toString(),
|
||||||
|
token,
|
||||||
|
callback_url: `${appCfg.SITE_URL}/signupinvite`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
||||||
|
|
||||||
if (!appCfg.isSmtpConfigured) {
|
if (!appCfg.isSmtpConfigured) {
|
||||||
return `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${inviteeEmail}&organization_id=${org?.id}`;
|
return signupTokens;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas";
|
||||||
import { TOrgPermission } from "@app/lib/types";
|
import { TOrgPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||||
@@ -29,7 +30,10 @@ export type TInviteUserToOrgDTO = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
actorOrgId: string | undefined;
|
actorOrgId: string | undefined;
|
||||||
actorAuthMethod: ActorAuthMethod;
|
actorAuthMethod: ActorAuthMethod;
|
||||||
inviteeEmail: string;
|
inviteeEmails: string[];
|
||||||
|
organizationRoleSlug: OrgMembershipRole;
|
||||||
|
projectIds?: string[];
|
||||||
|
projectRoleSlug?: ProjectMembershipRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TVerifyUserToOrgDTO = {
|
export type TVerifyUserToOrgDTO = {
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { ProjectMembershipRole, SecretKeyEncoding, TProjectMemberships } from "@app/db/schemas";
|
||||||
|
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { groupBy } from "@app/lib/fn";
|
||||||
|
|
||||||
|
import { TOrgDALFactory } from "../org/org-dal";
|
||||||
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
|
import { assignWorkspaceKeysToMembers } from "../project/project-fns";
|
||||||
|
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||||
|
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
|
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||||
|
import { TProjectMembershipDALFactory } from "./project-membership-dal";
|
||||||
|
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
|
||||||
|
|
||||||
|
type TAddMembersToProjectArg = {
|
||||||
|
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "transaction" | "insertMany">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findProjectById" | "findProjectGhostUser">;
|
||||||
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "insertMany">;
|
||||||
|
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
||||||
|
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findUserGroupMembershipsInProject">;
|
||||||
|
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AddMembersToNonE2EEProjectDTO = {
|
||||||
|
emails: string[];
|
||||||
|
usernames: string[];
|
||||||
|
projectId: string;
|
||||||
|
projectMembershipRole: ProjectMembershipRole;
|
||||||
|
sendEmails?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AddMembersToNonE2EEProjectOptions = {
|
||||||
|
tx?: Knex;
|
||||||
|
throwOnProjectNotFound?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addMembersToProject = ({
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectKeyDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
|
projectUserMembershipRoleDAL,
|
||||||
|
smtpService
|
||||||
|
}: TAddMembersToProjectArg) => {
|
||||||
|
// Can create multiple memberships for a singular project, based on user email / username
|
||||||
|
const addMembersToNonE2EEProject = async (
|
||||||
|
{ emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO,
|
||||||
|
options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true }
|
||||||
|
) => {
|
||||||
|
const processTransaction = async (tx: Knex) => {
|
||||||
|
const usernamesAndEmails = [...emails, ...usernames];
|
||||||
|
|
||||||
|
const project = await projectDAL.findProjectById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
if (options.throwOnProjectNotFound) {
|
||||||
|
throw new BadRequestError({ message: "Project not found when attempting to add user to project" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgMembers = await orgDAL.findOrgMembersByUsername(
|
||||||
|
project.orgId,
|
||||||
|
[...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))],
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgMembers.length !== usernamesAndEmails.length)
|
||||||
|
throw new BadRequestError({ message: "Some users are not part of org" });
|
||||||
|
|
||||||
|
if (!orgMembers.length) return [];
|
||||||
|
|
||||||
|
const existingMembers = await projectMembershipDAL.find({
|
||||||
|
projectId,
|
||||||
|
$in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) }
|
||||||
|
});
|
||||||
|
if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" });
|
||||||
|
|
||||||
|
const ghostUser = await projectDAL.findProjectGhostUser(projectId);
|
||||||
|
|
||||||
|
if (!ghostUser) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to find sudo user"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId);
|
||||||
|
|
||||||
|
if (!ghostUserLatestKey) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to find sudo user latest key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await projectBotDAL.findOne({ projectId });
|
||||||
|
if (!bot) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to find bot"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const botPrivateKey = infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
|
||||||
|
iv: bot.iv,
|
||||||
|
tag: bot.tag,
|
||||||
|
ciphertext: bot.encryptedPrivateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const newWsMembers = assignWorkspaceKeysToMembers({
|
||||||
|
decryptKey: ghostUserLatestKey,
|
||||||
|
userPrivateKey: botPrivateKey,
|
||||||
|
members: orgMembers.map((membership) => ({
|
||||||
|
orgMembershipId: membership.id,
|
||||||
|
projectMembershipRole,
|
||||||
|
userPublicKey: membership.user.publicKey
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const members: TProjectMemberships[] = [];
|
||||||
|
|
||||||
|
const userIdsToExcludeForProjectKeyAddition = new Set(
|
||||||
|
await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId)
|
||||||
|
);
|
||||||
|
const projectMemberships = await projectMembershipDAL.insertMany(
|
||||||
|
orgMembers.map(({ user }) => ({
|
||||||
|
projectId,
|
||||||
|
userId: user.id
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
await projectUserMembershipRoleDAL.insertMany(
|
||||||
|
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
members.push(...projectMemberships);
|
||||||
|
|
||||||
|
const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId);
|
||||||
|
await projectKeyDAL.insertMany(
|
||||||
|
orgMembers
|
||||||
|
.filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id))
|
||||||
|
.map(({ user, id }) => ({
|
||||||
|
encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey,
|
||||||
|
nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce,
|
||||||
|
senderId: ghostUser.id,
|
||||||
|
receiverId: user.id,
|
||||||
|
projectId
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sendEmails) {
|
||||||
|
const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string);
|
||||||
|
|
||||||
|
const appCfg = getConfig();
|
||||||
|
|
||||||
|
if (recipients.length) {
|
||||||
|
await smtpService.sendMail({
|
||||||
|
template: SmtpTemplates.WorkspaceInvite,
|
||||||
|
subjectLine: "Infisical project invitation",
|
||||||
|
recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string),
|
||||||
|
substitutions: {
|
||||||
|
workspaceName: project.name,
|
||||||
|
callback_url: `${appCfg.SITE_URL}/login`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return members;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.tx) {
|
||||||
|
return processTransaction(options.tx);
|
||||||
|
}
|
||||||
|
return projectMembershipDAL.transaction(processTransaction);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addMembersToNonE2EEProject
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,19 +2,12 @@
|
|||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
import ms from "ms";
|
import ms from "ms";
|
||||||
|
|
||||||
import {
|
import { ProjectMembershipRole, ProjectVersion, TableName } from "@app/db/schemas";
|
||||||
ProjectMembershipRole,
|
|
||||||
ProjectVersion,
|
|
||||||
SecretKeyEncoding,
|
|
||||||
TableName,
|
|
||||||
TProjectMemberships
|
|
||||||
} from "@app/db/schemas";
|
|
||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal";
|
import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { groupBy } from "@app/lib/fn";
|
import { groupBy } from "@app/lib/fn";
|
||||||
|
|
||||||
@@ -23,13 +16,13 @@ import { ActorType } from "../auth/auth-type";
|
|||||||
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
||||||
import { TOrgDALFactory } from "../org/org-dal";
|
import { TOrgDALFactory } from "../org/org-dal";
|
||||||
import { TProjectDALFactory } from "../project/project-dal";
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
import { assignWorkspaceKeysToMembers } from "../project/project-fns";
|
|
||||||
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||||
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||||
import { TUserDALFactory } from "../user/user-dal";
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TProjectMembershipDALFactory } from "./project-membership-dal";
|
import { TProjectMembershipDALFactory } from "./project-membership-dal";
|
||||||
|
import { addMembersToProject } from "./project-membership-fns";
|
||||||
import {
|
import {
|
||||||
ProjectUserMembershipTemporaryMode,
|
ProjectUserMembershipTemporaryMode,
|
||||||
TAddUsersToWorkspaceDTO,
|
TAddUsersToWorkspaceDTO,
|
||||||
@@ -53,7 +46,7 @@ type TProjectMembershipServiceFactoryDep = {
|
|||||||
userGroupMembershipDAL: TUserGroupMembershipDALFactory;
|
userGroupMembershipDAL: TUserGroupMembershipDALFactory;
|
||||||
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
|
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
|
||||||
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
|
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
|
||||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction">;
|
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction" | "findProjectById">;
|
||||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
|
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
|
||||||
@@ -247,116 +240,23 @@ export const projectMembershipServiceFactory = ({
|
|||||||
);
|
);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
|
||||||
|
|
||||||
const usernamesAndEmails = [...emails, ...usernames];
|
const members = await addMembersToProject({
|
||||||
|
orgDAL,
|
||||||
const orgMembers = await orgDAL.findOrgMembersByUsername(project.orgId, [
|
projectDAL,
|
||||||
...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))
|
projectMembershipDAL,
|
||||||
]);
|
projectKeyDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
if (orgMembers.length !== usernamesAndEmails.length)
|
projectBotDAL,
|
||||||
throw new BadRequestError({ message: "Some users are not part of org" });
|
projectUserMembershipRoleDAL,
|
||||||
|
smtpService
|
||||||
if (!orgMembers.length) return [];
|
}).addMembersToNonE2EEProject({
|
||||||
|
emails,
|
||||||
const existingMembers = await projectMembershipDAL.find({
|
usernames,
|
||||||
projectId,
|
projectId,
|
||||||
$in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) }
|
projectMembershipRole: ProjectMembershipRole.Member,
|
||||||
});
|
sendEmails
|
||||||
if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" });
|
|
||||||
|
|
||||||
const ghostUser = await projectDAL.findProjectGhostUser(projectId);
|
|
||||||
|
|
||||||
if (!ghostUser) {
|
|
||||||
throw new BadRequestError({
|
|
||||||
message: "Failed to find sudo user"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId);
|
|
||||||
|
|
||||||
if (!ghostUserLatestKey) {
|
|
||||||
throw new BadRequestError({
|
|
||||||
message: "Failed to find sudo user latest key"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const bot = await projectBotDAL.findOne({ projectId });
|
|
||||||
if (!bot) {
|
|
||||||
throw new BadRequestError({
|
|
||||||
message: "Failed to find bot"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const botPrivateKey = infisicalSymmetricDecrypt({
|
|
||||||
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
|
|
||||||
iv: bot.iv,
|
|
||||||
tag: bot.tag,
|
|
||||||
ciphertext: bot.encryptedPrivateKey
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const newWsMembers = assignWorkspaceKeysToMembers({
|
|
||||||
decryptKey: ghostUserLatestKey,
|
|
||||||
userPrivateKey: botPrivateKey,
|
|
||||||
members: orgMembers.map((membership) => ({
|
|
||||||
orgMembershipId: membership.id,
|
|
||||||
projectMembershipRole: ProjectMembershipRole.Member,
|
|
||||||
userPublicKey: membership.user.publicKey
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
const members: TProjectMemberships[] = [];
|
|
||||||
|
|
||||||
const userIdsToExcludeForProjectKeyAddition = new Set(
|
|
||||||
await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId)
|
|
||||||
);
|
|
||||||
|
|
||||||
await projectMembershipDAL.transaction(async (tx) => {
|
|
||||||
const projectMemberships = await projectMembershipDAL.insertMany(
|
|
||||||
orgMembers.map(({ user }) => ({
|
|
||||||
projectId,
|
|
||||||
userId: user.id
|
|
||||||
})),
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
await projectUserMembershipRoleDAL.insertMany(
|
|
||||||
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })),
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
|
|
||||||
members.push(...projectMemberships);
|
|
||||||
|
|
||||||
const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId);
|
|
||||||
await projectKeyDAL.insertMany(
|
|
||||||
orgMembers
|
|
||||||
.filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id))
|
|
||||||
.map(({ user, id }) => ({
|
|
||||||
encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey,
|
|
||||||
nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce,
|
|
||||||
senderId: ghostUser.id,
|
|
||||||
receiverId: user.id,
|
|
||||||
projectId
|
|
||||||
})),
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sendEmails) {
|
|
||||||
const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string);
|
|
||||||
|
|
||||||
const appCfg = getConfig();
|
|
||||||
|
|
||||||
if (recipients.length) {
|
|
||||||
await smtpService.sendMail({
|
|
||||||
template: SmtpTemplates.WorkspaceInvite,
|
|
||||||
subjectLine: "Infisical project invitation",
|
|
||||||
recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string),
|
|
||||||
substitutions: {
|
|
||||||
workspaceName: project.name,
|
|
||||||
callback_url: `${appCfg.SITE_URL}/login`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return members;
|
return members;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
52
backend/src/services/project-role/project-role-fns.ts
Normal file
52
backend/src/services/project-role/project-role-fns.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||||
|
import {
|
||||||
|
projectAdminPermissions,
|
||||||
|
projectMemberPermissions,
|
||||||
|
projectNoAccessPermissions,
|
||||||
|
projectViewerPermission
|
||||||
|
} from "@app/ee/services/permission/project-permission";
|
||||||
|
|
||||||
|
export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid
|
||||||
|
projectId,
|
||||||
|
name: "Admin",
|
||||||
|
slug: ProjectMembershipRole.Admin,
|
||||||
|
permissions: projectAdminPermissions,
|
||||||
|
description: "Full administrative access over a project",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response
|
||||||
|
projectId,
|
||||||
|
name: "Developer",
|
||||||
|
slug: ProjectMembershipRole.Member,
|
||||||
|
permissions: projectMemberPermissions,
|
||||||
|
description: "Limited read/write role in a project",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response
|
||||||
|
projectId,
|
||||||
|
name: "Viewer",
|
||||||
|
slug: ProjectMembershipRole.Viewer,
|
||||||
|
permissions: projectViewerPermission,
|
||||||
|
description: "Only read role in a project",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response
|
||||||
|
projectId,
|
||||||
|
name: "No Access",
|
||||||
|
slug: ProjectMembershipRole.NoAccess,
|
||||||
|
permissions: projectNoAccessPermissions,
|
||||||
|
description: "No access to any resources in the project",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
].filter(({ slug }) => !roleFilter || roleFilter.includes(slug));
|
||||||
|
};
|
||||||
@@ -5,13 +5,9 @@ import { ProjectMembershipRole } from "@app/db/schemas";
|
|||||||
import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
|
import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import {
|
import {
|
||||||
projectAdminPermissions,
|
|
||||||
projectMemberPermissions,
|
|
||||||
projectNoAccessPermissions,
|
|
||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
ProjectPermissionSet,
|
ProjectPermissionSet,
|
||||||
ProjectPermissionSub,
|
ProjectPermissionSub
|
||||||
projectViewerPermission
|
|
||||||
} from "@app/ee/services/permission/project-permission";
|
} from "@app/ee/services/permission/project-permission";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
@@ -20,6 +16,7 @@ import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/id
|
|||||||
import { TProjectDALFactory } from "../project/project-dal";
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||||
import { TProjectRoleDALFactory } from "./project-role-dal";
|
import { TProjectRoleDALFactory } from "./project-role-dal";
|
||||||
|
import { getPredefinedRoles } from "./project-role-fns";
|
||||||
import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types";
|
import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types";
|
||||||
|
|
||||||
type TProjectRoleServiceFactoryDep = {
|
type TProjectRoleServiceFactoryDep = {
|
||||||
@@ -37,51 +34,6 @@ const unpackPermissions = (permissions: unknown) =>
|
|||||||
unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
|
unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
|
||||||
);
|
);
|
||||||
|
|
||||||
const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid
|
|
||||||
projectId,
|
|
||||||
name: "Admin",
|
|
||||||
slug: ProjectMembershipRole.Admin,
|
|
||||||
permissions: projectAdminPermissions,
|
|
||||||
description: "Full administrative access over a project",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response
|
|
||||||
projectId,
|
|
||||||
name: "Developer",
|
|
||||||
slug: ProjectMembershipRole.Member,
|
|
||||||
permissions: projectMemberPermissions,
|
|
||||||
description: "Limited read/write role in a project",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response
|
|
||||||
projectId,
|
|
||||||
name: "Viewer",
|
|
||||||
slug: ProjectMembershipRole.Viewer,
|
|
||||||
permissions: projectViewerPermission,
|
|
||||||
description: "Only read role in a project",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response
|
|
||||||
projectId,
|
|
||||||
name: "No Access",
|
|
||||||
slug: ProjectMembershipRole.NoAccess,
|
|
||||||
permissions: projectNoAccessPermissions,
|
|
||||||
description: "No access to any resources in the project",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date()
|
|
||||||
}
|
|
||||||
].filter(({ slug }) => !roleFilter || roleFilter.includes(slug));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const projectRoleServiceFactory = ({
|
export const projectRoleServiceFactory = ({
|
||||||
projectRoleDAL,
|
projectRoleDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import { ProjectVersion, TProjects } from "@app/db/schemas";
|
||||||
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
|
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
@@ -53,6 +54,16 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr
|
|||||||
return { key: encryptedProjectKey, iv: encryptedProjectKeyIv };
|
return { key: encryptedProjectKey, iv: encryptedProjectKeyIv };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const verifyProjectVersions = (projects: Pick<TProjects, "version">[], version: ProjectVersion) => {
|
||||||
|
for (const project of projects) {
|
||||||
|
if (project.version !== version) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const getProjectKmsCertificateKeyId = async ({
|
export const getProjectKmsCertificateKeyId = async ({
|
||||||
projectId,
|
projectId,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TKeyStoreFactory } from "@app/keystore/keystore";
|
|||||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
|
import { groupBy } from "@app/lib/fn";
|
||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
|||||||
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||||
|
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||||
|
import { getPredefinedRoles } from "../project-role/project-role-fns";
|
||||||
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
import { TUserDALFactory } from "../user/user-dal";
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TProjectDALFactory } from "./project-dal";
|
import { TProjectDALFactory } from "./project-dal";
|
||||||
@@ -44,6 +47,7 @@ import {
|
|||||||
TListProjectCasDTO,
|
TListProjectCasDTO,
|
||||||
TListProjectCertificateTemplatesDTO,
|
TListProjectCertificateTemplatesDTO,
|
||||||
TListProjectCertsDTO,
|
TListProjectCertsDTO,
|
||||||
|
TListProjectsDTO,
|
||||||
TLoadProjectKmsBackupDTO,
|
TLoadProjectKmsBackupDTO,
|
||||||
TToggleProjectAutoCapitalizationDTO,
|
TToggleProjectAutoCapitalizationDTO,
|
||||||
TUpdateAuditLogsRetentionDTO,
|
TUpdateAuditLogsRetentionDTO,
|
||||||
@@ -84,6 +88,7 @@ type TProjectServiceFactoryDep = {
|
|||||||
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
||||||
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
||||||
projectBotDAL: Pick<TProjectBotDALFactory, "create">;
|
projectBotDAL: Pick<TProjectBotDALFactory, "create">;
|
||||||
|
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
|
||||||
kmsService: Pick<
|
kmsService: Pick<
|
||||||
TKmsServiceFactory,
|
TKmsServiceFactory,
|
||||||
| "updateProjectSecretManagerKmsKey"
|
| "updateProjectSecretManagerKmsKey"
|
||||||
@@ -112,6 +117,7 @@ export const projectServiceFactory = ({
|
|||||||
projectEnvDAL,
|
projectEnvDAL,
|
||||||
licenseService,
|
licenseService,
|
||||||
projectUserMembershipRoleDAL,
|
projectUserMembershipRoleDAL,
|
||||||
|
projectRoleDAL,
|
||||||
identityProjectMembershipRoleDAL,
|
identityProjectMembershipRoleDAL,
|
||||||
certificateAuthorityDAL,
|
certificateAuthorityDAL,
|
||||||
certificateDAL,
|
certificateDAL,
|
||||||
@@ -389,8 +395,34 @@ export const projectServiceFactory = ({
|
|||||||
return deletedProject;
|
return deletedProject;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProjects = async (actorId: string) => {
|
const getProjects = async ({ actorId, includeRoles, actorAuthMethod, actorOrgId }: TListProjectsDTO) => {
|
||||||
const workspaces = await projectDAL.findAllProjects(actorId);
|
const workspaces = await projectDAL.findAllProjects(actorId);
|
||||||
|
|
||||||
|
if (includeRoles) {
|
||||||
|
const { permission } = await permissionService.getUserOrgPermission(actorId, actorOrgId, actorAuthMethod);
|
||||||
|
|
||||||
|
// `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls.
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
|
||||||
|
const customRoles = await projectRoleDAL.find({
|
||||||
|
$in: {
|
||||||
|
projectId: workspaces.map((workspace) => workspace.id)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceMappedToRoles = groupBy(customRoles, (role) => role.projectId);
|
||||||
|
|
||||||
|
const workspacesWithRoles = await Promise.all(
|
||||||
|
workspaces.map(async (workspace) => {
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
roles: [...(workspaceMappedToRoles[workspace.id] || []), ...getPredefinedRoles(workspace.id)]
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return workspacesWithRoles;
|
||||||
|
}
|
||||||
|
|
||||||
return workspaces;
|
return workspaces;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ export type TDeleteProjectDTO = {
|
|||||||
actorOrgId: string | undefined;
|
actorOrgId: string | undefined;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TListProjectsDTO = {
|
||||||
|
includeRoles: boolean;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TUpgradeProjectDTO = {
|
export type TUpgradeProjectDTO = {
|
||||||
userPrivateKey: string;
|
userPrivateKey: string;
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<h2>Join your organization on Infisical</h2>
|
<h2>Join your organization on Infisical</h2>
|
||||||
<p>{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}</p>
|
<p>{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}</p>
|
||||||
<a href="{{callback_url}}?token={{token}}&to={{email}}&organization_id={{organizationId}}">Join now</a>
|
<a href="{{callback_url}}?token={{token}}{{#if metadata}}&metadata={{metadata}}{{/if}}&to={{email}}&organization_id={{organizationId}}">Join now</a>
|
||||||
<h3>What is Infisical?</h3>
|
<h3>What is Infisical?</h3>
|
||||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ export type TIntegrationCreatedEvent = {
|
|||||||
export type TUserOrgInvitedEvent = {
|
export type TUserOrgInvitedEvent = {
|
||||||
event: PostHogEventTypes.UserOrgInvitation;
|
event: PostHogEventTypes.UserOrgInvitation;
|
||||||
properties: {
|
properties: {
|
||||||
inviteeEmail: string;
|
inviteeEmails: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
organizationRoleSlug?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const path = require("path");
|
|||||||
|
|
||||||
const ContentSecurityPolicy = `
|
const ContentSecurityPolicy = `
|
||||||
default-src 'self';
|
default-src 'self';
|
||||||
script-src 'self' https://*.posthog.com https://*.*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval';
|
script-src 'self' https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval';
|
||||||
style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
|
style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
|
||||||
child-src https://api.stripe.com;
|
child-src https://api.stripe.com;
|
||||||
frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com;
|
frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com;
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import { Fragment } from "react";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
|
|
||||||
import Button from "../buttons/Button";
|
|
||||||
import InputField from "../InputField";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
closeModal: () => void;
|
|
||||||
submitModal: (email: string) => void;
|
|
||||||
email: string;
|
|
||||||
setEmail: (email: string) => void;
|
|
||||||
orgName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AddUserDialog = ({ isOpen, closeModal, submitModal, email, setEmail, orgName }: Props) => {
|
|
||||||
const submit = () => {
|
|
||||||
submitModal(email);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="z-50">
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative" onClose={closeModal}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-70" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-md border border-gray-700 bg-bunker-800 p-6 text-left align-middle shadow-xl transition-all">
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="z-50 text-lg font-medium leading-6 text-gray-400"
|
|
||||||
>
|
|
||||||
Invite others to {orgName}
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-2 mb-4">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
An invite is specific to an email address and expires after 1 day. For
|
|
||||||
security reasons, you will need to separately add members to projects.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-28">
|
|
||||||
<InputField
|
|
||||||
label="Email"
|
|
||||||
onChangeHandler={setEmail}
|
|
||||||
type="varName"
|
|
||||||
value={email}
|
|
||||||
placeholder=""
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 max-w-max">
|
|
||||||
<Button onButtonPressed={submit} color="mineshaft" text="Invite" size="md" />
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
{/* <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all">
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="text-xl font-medium leading-6 text-gray-300 z-50"
|
|
||||||
>
|
|
||||||
Unleash Infisical's Full Power
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-4 mb-4">
|
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
|
||||||
You have exceeded the number of members in a free organization.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Upgrade now and get access to adding more members, as well as to other powerful enhancements.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex justify-center rounded-md border border-transparent bg-primary px-4 py-2 text-sm font-medium text-black hover:opacity-80 hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
|
||||||
onClick={() => router.push("/settings/billing/" + router.query.id)}
|
|
||||||
>
|
|
||||||
Upgrade Now
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-2 inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-sm font-medium text-gray-400 hover:bg-gray-500 hover:text-black hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
|
||||||
onClick={closeModal}
|
|
||||||
>
|
|
||||||
Maybe Later
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel> */}
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddUserDialog;
|
|
||||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { useAddUserToOrg } from "@app/hooks/api";
|
import { useAddUsersToOrg } from "@app/hooks/api";
|
||||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export default function TeamInviteStep(): JSX.Element {
|
|||||||
const [emails, setEmails] = useState("");
|
const [emails, setEmails] = useState("");
|
||||||
const { data: serverDetails } = useFetchServerStatus();
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
|
|
||||||
const { mutateAsync } = useAddUserToOrg();
|
const { mutateAsync } = useAddUsersToOrg();
|
||||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
|
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
|
||||||
|
|
||||||
// Redirect user to the getting started page
|
// Redirect user to the getting started page
|
||||||
@@ -31,8 +31,9 @@ export default function TeamInviteStep(): JSX.Element {
|
|||||||
.map((email) => email.trim())
|
.map((email) => email.trim())
|
||||||
.map(async (email) => {
|
.map(async (email) => {
|
||||||
mutateAsync({
|
mutateAsync({
|
||||||
inviteeEmail: email,
|
inviteeEmails: [email],
|
||||||
organizationId: String(localStorage.getItem("orgData.id"))
|
organizationId: String(localStorage.getItem("orgData.id")),
|
||||||
|
organizationRoleSlug: "member"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export type CompleteAccountDTO = {
|
|||||||
salt: string;
|
salt: string;
|
||||||
verifier: string;
|
verifier: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
tokenMetadata?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompleteAccountSignupDTO = CompleteAccountDTO & {
|
export type CompleteAccountSignupDTO = CompleteAccountDTO & {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const roleQueryKeys = {
|
|||||||
["user-project-permissions", { workspaceId }] as const
|
["user-project-permissions", { workspaceId }] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProjectRoles = async (projectId: string) => {
|
export const getProjectRoles = async (projectId: string) => {
|
||||||
const { data } = await apiRequest.get<{ roles: Array<Omit<TProjectRole, "permissions">> }>(
|
const { data } = await apiRequest.get<{ roles: Array<Omit<TProjectRole, "permissions">> }>(
|
||||||
`/api/v1/workspace/${projectId}/roles`
|
`/api/v1/workspace/${projectId}/roles`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export {
|
|||||||
} from "./mutation";
|
} from "./mutation";
|
||||||
export {
|
export {
|
||||||
fetchOrgUsers,
|
fetchOrgUsers,
|
||||||
useAddUserToOrg,
|
useAddUsersToOrg,
|
||||||
useCreateAPIKey,
|
useCreateAPIKey,
|
||||||
useDeleteAPIKey,
|
useDeleteAPIKey,
|
||||||
useDeleteMe,
|
useDeleteMe,
|
||||||
@@ -26,4 +26,5 @@ export {
|
|||||||
useRevokeMySessions,
|
useRevokeMySessions,
|
||||||
useUpdateMfaEnabled,
|
useUpdateMfaEnabled,
|
||||||
useUpdateOrgMembership,
|
useUpdateOrgMembership,
|
||||||
useUpdateUserAuthMethods} from "./queries";
|
useUpdateUserAuthMethods
|
||||||
|
} from "./queries";
|
||||||
|
|||||||
@@ -157,12 +157,15 @@ export const useGetOrgUsers = (orgId: string) =>
|
|||||||
|
|
||||||
// mutation
|
// mutation
|
||||||
// TODO(akhilmhdh): move all mutation to mutation file
|
// TODO(akhilmhdh): move all mutation to mutation file
|
||||||
export const useAddUserToOrg = () => {
|
export const useAddUsersToOrg = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
type Response = {
|
type Response = {
|
||||||
data: {
|
data: {
|
||||||
message: string;
|
message: string;
|
||||||
completeInviteLink: string | undefined;
|
completeInviteLinks?: {
|
||||||
|
email: string;
|
||||||
|
link: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,10 @@ export type DeletOrgMembershipDTO = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AddUserToOrgDTO = {
|
export type AddUserToOrgDTO = {
|
||||||
inviteeEmail: string;
|
inviteeEmails: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
projectRoleSlug?: string;
|
||||||
|
organizationRoleSlug: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,12 @@ export const useGetUpgradeProjectStatus = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUserWorkspaces = async () => {
|
const fetchUserWorkspaces = async (includeRoles?: boolean) => {
|
||||||
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace");
|
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace", {
|
||||||
|
params: {
|
||||||
|
includeRoles
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.workspaces;
|
return data.workspaces;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,8 +175,8 @@ export const useGetWorkspaceById = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGetUserWorkspaces = () =>
|
export const useGetUserWorkspaces = (includeRoles?: boolean) =>
|
||||||
useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces);
|
useQuery(workspaceKeys.getAllUserWorkspace, () => fetchUserWorkspaces(includeRoles));
|
||||||
|
|
||||||
const fetchUserWorkspaceMemberships = async (orgId: string) => {
|
const fetchUserWorkspaceMemberships = async (orgId: string) => {
|
||||||
const { data } = await apiRequest.get<Record<string, Workspace[]>>(
|
const { data } = await apiRequest.get<Record<string, Workspace[]>>(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { TProjectRole } from "../roles/types";
|
||||||
|
|
||||||
export enum ProjectVersion {
|
export enum ProjectVersion {
|
||||||
V1 = 1,
|
V1 = 1,
|
||||||
V2 = 2,
|
V2 = 2,
|
||||||
@@ -22,6 +24,8 @@ export type Workspace = {
|
|||||||
auditLogsRetentionDays: number;
|
auditLogsRetentionDays: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
roles?: TProjectRole[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceEnv = {
|
export type WorkspaceEnv = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type UseToggleReturn = [
|
|||||||
on: VoidFn;
|
on: VoidFn;
|
||||||
off: VoidFn;
|
off: VoidFn;
|
||||||
toggle: VoidFn;
|
toggle: VoidFn;
|
||||||
|
timedToggle: (timeout?: number) => void;
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -26,5 +27,13 @@ export const useToggle = (initialState = false): UseToggleReturn => {
|
|||||||
setValue((prev) => (typeof isOpen === "boolean" ? isOpen : !prev));
|
setValue((prev) => (typeof isOpen === "boolean" ? isOpen : !prev));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return [value, { on, off, toggle }];
|
const timedToggle = useCallback((timeout = 2000) => {
|
||||||
|
setValue((prev) => !prev);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setValue(false);
|
||||||
|
}, timeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [value, { on, off, toggle, timedToggle }];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default function LoginPage() {
|
|||||||
const { user, isLoading: userLoading } = useUser();
|
const { user, isLoading: userLoading } = useUser();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams(window.location.search);
|
const queryParams = new URLSearchParams(window.location.search);
|
||||||
|
const callbackPort = queryParams.get("callback_port");
|
||||||
|
|
||||||
const logout = useLogoutUser(true);
|
const logout = useLogoutUser(true);
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
@@ -52,8 +53,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const handleSelectOrganization = useCallback(
|
const handleSelectOrganization = useCallback(
|
||||||
async (organization: Organization) => {
|
async (organization: Organization) => {
|
||||||
const callbackPort = queryParams.get("callback_port");
|
|
||||||
|
|
||||||
if (organization.authEnforced) {
|
if (organization.authEnforced) {
|
||||||
// org has an org-level auth method enabled (e.g. SAML)
|
// org has an org-level auth method enabled (e.g. SAML)
|
||||||
// -> logout + redirect to SAML SSO
|
// -> logout + redirect to SAML SSO
|
||||||
@@ -116,9 +115,8 @@ export default function LoginPage() {
|
|||||||
[selectOrg]
|
[selectOrg]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleCliRedirect = useCallback(() => {
|
||||||
const authToken = getAuthToken();
|
const authToken = getAuthToken();
|
||||||
const callbackPort = queryParams.get("callback_port");
|
|
||||||
|
|
||||||
if (authToken && !callbackPort) {
|
if (authToken && !callbackPort) {
|
||||||
const decodedJwt = jwt_decode(authToken) as any;
|
const decodedJwt = jwt_decode(authToken) as any;
|
||||||
@@ -131,13 +129,27 @@ export default function LoginPage() {
|
|||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (callbackPort) {
|
||||||
|
handleCliRedirect();
|
||||||
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
// Case: User has no organizations.
|
// Case: User has no organizations.
|
||||||
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
|
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!organizations.isLoading && organizations.data?.length === 0) {
|
if (organizations.isLoading || !organizations.data) return;
|
||||||
|
|
||||||
|
if (organizations.data.length === 0) {
|
||||||
router.push("/org/none");
|
router.push("/org/none");
|
||||||
|
} else if (organizations.data.length === 1) {
|
||||||
|
if (callbackPort) {
|
||||||
|
handleCliRedirect();
|
||||||
|
} else {
|
||||||
|
handleSelectOrganization(organizations.data[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [organizations.isLoading, organizations.data]);
|
}, [organizations.isLoading, organizations.data]);
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export default function SignupInvite() {
|
|||||||
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
|
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
|
||||||
const { config } = useServerConfig();
|
const { config } = useServerConfig();
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
const metadata = queryParams.get("metadata") || undefined;
|
||||||
|
|
||||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -160,7 +164,8 @@ export default function SignupInvite() {
|
|||||||
encryptedPrivateKeyIV,
|
encryptedPrivateKeyIV,
|
||||||
encryptedPrivateKeyTag,
|
encryptedPrivateKeyTag,
|
||||||
salt: result.salt,
|
salt: result.salt,
|
||||||
verifier: result.verifier
|
verifier: result.verifier,
|
||||||
|
tokenMetadata: metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
// unset temporary signup JWT token and set JWT token
|
// unset temporary signup JWT token and set JWT token
|
||||||
|
|||||||
@@ -1,65 +1,144 @@
|
|||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faCheckCircle,
|
||||||
|
faChevronDown,
|
||||||
|
faExclamationCircle
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { Button, FormControl, IconButton, Input, Modal, ModalContent } from "@app/components/v2";
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
FormControl,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
TextArea,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
import { useToggle } from "@app/hooks";
|
import {
|
||||||
import { useAddUserToOrg, useFetchServerStatus } from "@app/hooks/api";
|
useAddUsersToOrg,
|
||||||
|
useFetchServerStatus,
|
||||||
|
useGetOrgRoles,
|
||||||
|
useGetUserWorkspaces
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
|
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
const addMemberFormSchema = yup.object({
|
import { OrgInviteLink } from "./OrgInviteLink";
|
||||||
email: yup.string().email().required().label("Email").trim().lowercase()
|
|
||||||
|
const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member";
|
||||||
|
|
||||||
|
const EmailSchema = z.string().email().min(1).trim().toLowerCase();
|
||||||
|
|
||||||
|
const addMemberFormSchema = z.object({
|
||||||
|
emails: z.string().min(1).trim().toLowerCase(),
|
||||||
|
projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]),
|
||||||
|
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||||
|
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
|
||||||
});
|
});
|
||||||
|
|
||||||
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
|
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
popUp: UsePopUpState<["addMember"]>;
|
popUp: UsePopUpState<["addMember"]>;
|
||||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
|
||||||
completeInviteLink: string;
|
completeInviteLinks: Array<{
|
||||||
setCompleteInviteLink: (link: string) => void;
|
email: string;
|
||||||
|
link: string;
|
||||||
|
}> | null;
|
||||||
|
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddOrgMemberModal = ({
|
export const AddOrgMemberModal = ({
|
||||||
popUp,
|
popUp,
|
||||||
handlePopUpToggle,
|
handlePopUpToggle,
|
||||||
completeInviteLink,
|
completeInviteLinks,
|
||||||
setCompleteInviteLink
|
setCompleteInviteLinks
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
|
|
||||||
|
const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? "");
|
||||||
const { data: serverDetails } = useFetchServerStatus();
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
const { mutateAsync: addUserMutateAsync } = useAddUserToOrg();
|
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
|
||||||
|
const { data: projects } = useGetUserWorkspaces(true);
|
||||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
watch,
|
||||||
reset,
|
reset,
|
||||||
formState: { isSubmitting }
|
formState: { isSubmitting }
|
||||||
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
} = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) });
|
||||||
|
|
||||||
const onAddMember = async ({ email }: TAddMemberForm) => {
|
const selectedProjectIds = watch("projectIds", []);
|
||||||
|
|
||||||
|
const onAddMembers = async ({
|
||||||
|
emails,
|
||||||
|
organizationRoleSlug,
|
||||||
|
projectIds,
|
||||||
|
projectRoleSlug
|
||||||
|
}: TAddMemberForm) => {
|
||||||
if (!currentOrg?.id) return;
|
if (!currentOrg?.id) return;
|
||||||
|
|
||||||
|
const selectedProjects = projects?.filter((project) => projectIds.includes(String(project.id)));
|
||||||
|
|
||||||
|
if (selectedProjects?.length) {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const project of selectedProjects) {
|
||||||
|
if (project.version !== ProjectVersion.V3) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await addUserMutateAsync({
|
const parsedEmails = emails
|
||||||
|
.replace(/\s/g, "")
|
||||||
|
.split(",")
|
||||||
|
.map((email) => {
|
||||||
|
if (EmailSchema.safeParse(email).success) {
|
||||||
|
return email.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsedEmails.includes(null)) {
|
||||||
|
createNotification({
|
||||||
|
text: "Invalid email addresses provided.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await addUsersMutateAsync({
|
||||||
organizationId: currentOrg?.id,
|
organizationId: currentOrg?.id,
|
||||||
inviteeEmail: email
|
inviteeEmails: emails.split(",").map((email) => email.trim()),
|
||||||
|
organizationRoleSlug,
|
||||||
|
projectIds,
|
||||||
|
projectRoleSlug
|
||||||
});
|
});
|
||||||
|
|
||||||
setCompleteInviteLink(data?.completeInviteLink ?? "");
|
setCompleteInviteLinks(data?.completeInviteLinks ?? null);
|
||||||
|
|
||||||
// only show this notification when email is configured.
|
// only show this notification when email is configured.
|
||||||
// A [completeInviteLink] will not be sent if smtp is configured
|
// A [completeInviteLink] will not be sent if smtp is configured
|
||||||
|
|
||||||
if (!data.completeInviteLink) {
|
if (!data.completeInviteLinks) {
|
||||||
createNotification({
|
createNotification({
|
||||||
text: "Successfully invited user to the organization.",
|
text: "Successfully invited user to the organization.",
|
||||||
type: "success"
|
type: "success"
|
||||||
@@ -80,47 +159,196 @@ export const AddOrgMemberModal = ({
|
|||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyTokenToClipboard = () => {
|
|
||||||
navigator.clipboard.writeText(completeInviteLink as string);
|
|
||||||
setInviteLinkCopied.on();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp?.addMember?.isOpen}
|
isOpen={popUp?.addMember?.isOpen}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
handlePopUpToggle("addMember", isOpen);
|
handlePopUpToggle("addMember", isOpen);
|
||||||
setCompleteInviteLink("");
|
setCompleteInviteLinks(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title={`Invite others to ${currentOrg?.name}`}
|
title={`Invite others to ${currentOrg?.name}`}
|
||||||
subTitle={
|
subTitle={
|
||||||
<div>
|
<div>
|
||||||
{!completeInviteLink && (
|
{!completeInviteLinks && (
|
||||||
<div>
|
<div>An invite is specific to an email address and expires after 1 day.</div>
|
||||||
An invite is specific to an email address and expires after 1 day.
|
|
||||||
<br />
|
|
||||||
For security reasons, you will need to separately add members to projects.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{completeInviteLink &&
|
{completeInviteLinks &&
|
||||||
"This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
|
"This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!completeInviteLink && (
|
{!completeInviteLinks && (
|
||||||
<form onSubmit={handleSubmit(onAddMember)}>
|
<form onSubmit={handleSubmit(onAddMembers)}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue=""
|
name="emails"
|
||||||
name="email"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
<FormControl label="Emails" isError={Boolean(error)} errorText={error?.message}>
|
||||||
<Input {...field} />
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
className="mt-1 h-20 w-full min-w-[30rem] rounded-md border border-mineshaft-500 bg-mineshaft-900/70 py-1 px-2 text-sm text-bunker-300 outline-none ring-primary-800 ring-opacity-70 transition-all placeholder:text-bunker-400 focus:ring-2"
|
||||||
|
placeholder="email@example.com, email2@example.com..."
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="organizationRoleSlug"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
tooltipText="Select which organization role you want to assign to the user."
|
||||||
|
label="Assign organization role"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
className="w-full"
|
||||||
|
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(val) => field.onChange(val)}
|
||||||
|
>
|
||||||
|
{organizationRoles?.map((role) => (
|
||||||
|
<SelectItem key={role.id} value={role.slug}>
|
||||||
|
{role.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="w-full">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="projectIds"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Assign users to projects (optional)"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
{projects && projects.length > 0 ? (
|
||||||
|
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||||
|
{/* eslint-disable-next-line no-nested-ternary */}
|
||||||
|
{selectedProjectIds.length === 1
|
||||||
|
? projects.find((project) => project.id === selectedProjectIds[0])
|
||||||
|
?.name
|
||||||
|
: selectedProjectIds.length === 0
|
||||||
|
? "No projects selected"
|
||||||
|
: `${selectedProjectIds.length} projects selected`}
|
||||||
|
<FontAwesomeIcon icon={faChevronDown} className="text-xs" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||||
|
No projects found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="thin-scrollbar z-[100] max-h-80"
|
||||||
|
>
|
||||||
|
{projects && projects.length > 0 ? (
|
||||||
|
projects.map((project) => {
|
||||||
|
const isSelected = selectedProjectIds.includes(String(project.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) =>
|
||||||
|
projects.length > 1 && event.preventDefault()
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedProjectIds.includes(String(project.id))) {
|
||||||
|
field.onChange(
|
||||||
|
selectedProjectIds.filter(
|
||||||
|
(projectId: string) => projectId !== String(project.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
field.onChange([...selectedProjectIds, String(project.id)]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={`project-id-${project.id}`}
|
||||||
|
icon={
|
||||||
|
isSelected ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faCheckCircle}
|
||||||
|
className="pr-0.5 text-primary"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="pl-[1.01rem]" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="left"
|
||||||
|
className="w-[28.4rem] text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 capitalize">
|
||||||
|
{project.name}
|
||||||
|
{project.version !== ProjectVersion.V3 && (
|
||||||
|
<Tooltip content="Project is not compatible with this action, please upgrade this project.">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faExclamationCircle}
|
||||||
|
className="text-xs opacity-50"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-fit justify-end">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="projectRoleSlug"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
tooltipText="Select which role to assign to the users in the selected projects."
|
||||||
|
label="Role"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
isDisabled={selectedProjectIds.length === 0}
|
||||||
|
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(val) => field.onChange(val)}
|
||||||
|
>
|
||||||
|
{Object.entries(ProjectMembershipRole).map(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
([_, slug]) =>
|
||||||
|
slug !== "custom" && (
|
||||||
|
<SelectItem key={slug} value={slug}>
|
||||||
|
<span className="capitalize">{slug.replace("-", " ")}</span>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex items-center">
|
<div className="mt-8 flex items-center">
|
||||||
<Button
|
<Button
|
||||||
className="mr-4"
|
className="mr-4"
|
||||||
@@ -141,20 +369,11 @@ export const AddOrgMemberModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{completeInviteLink && (
|
{completeInviteLinks && (
|
||||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
<div className="space-y-3">
|
||||||
<p className="mr-4 break-all">{completeInviteLink}</p>
|
{completeInviteLinks.map((invite) => (
|
||||||
<IconButton
|
<OrgInviteLink key={`invite-${invite.email}`} invite={invite} />
|
||||||
ariaLabel="copy icon"
|
))}
|
||||||
colorSchema="secondary"
|
|
||||||
className="group relative"
|
|
||||||
onClick={copyTokenToClipboard}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
|
||||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
|
||||||
click to copy
|
|
||||||
</span>
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { IconButton, Tooltip } from "@app/components/v2";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
invite: { email: string; link: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrgInviteLink = ({ invite }: Props) => {
|
||||||
|
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||||
|
|
||||||
|
const copyTokenToClipboard = () => {
|
||||||
|
if (isInviteLinkCopied) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(invite.link);
|
||||||
|
setInviteLinkCopied.timedToggle();
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: "info",
|
||||||
|
text: "Copied invitation link to clipboard"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`invite-${invite.email}`}>
|
||||||
|
<p className="text-sm text-mineshaft-400">
|
||||||
|
Invite for <span className="font-medium">{invite.email}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1 rounded-md bg-white/[0.04] p-2 text-base text-gray-400">
|
||||||
|
<p className="line-clamp-1 mr-4 overflow-hidden text-ellipsis whitespace-nowrap ">
|
||||||
|
{invite.link}
|
||||||
|
</p>
|
||||||
|
<Tooltip content={`Copy invitation link for ${invite.email}`}>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative"
|
||||||
|
onClick={() => copyTokenToClipboard()}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,7 +27,10 @@ export const OrgMembersSection = () => {
|
|||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const orgId = currentOrg?.id ?? "";
|
const orgId = currentOrg?.id ?? "";
|
||||||
|
|
||||||
const [completeInviteLink, setCompleteInviteLink] = useState<string>("");
|
const [completeInviteLinks, setCompleteInviteLinks] = useState<Array<{
|
||||||
|
email: string;
|
||||||
|
link: string;
|
||||||
|
}> | null>(null);
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
"addMember",
|
"addMember",
|
||||||
@@ -132,13 +135,13 @@ export const OrgMembersSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
<OrgMembersTable
|
<OrgMembersTable
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
setCompleteInviteLink={setCompleteInviteLink}
|
setCompleteInviteLinks={setCompleteInviteLinks}
|
||||||
/>
|
/>
|
||||||
<AddOrgMemberModal
|
<AddOrgMemberModal
|
||||||
popUp={popUp}
|
popUp={popUp}
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
completeInviteLink={completeInviteLink}
|
completeInviteLinks={completeInviteLinks}
|
||||||
setCompleteInviteLink={setCompleteInviteLink}
|
setCompleteInviteLinks={setCompleteInviteLinks}
|
||||||
/>
|
/>
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.removeMember.isOpen}
|
isOpen={popUp.removeMember.isOpen}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
useUser
|
useUser
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import {
|
import {
|
||||||
useAddUserToOrg,
|
useAddUsersToOrg,
|
||||||
useFetchServerStatus,
|
useFetchServerStatus,
|
||||||
useGetOrgRoles,
|
useGetOrgRoles,
|
||||||
useGetOrgUsers,
|
useGetOrgUsers,
|
||||||
@@ -50,10 +50,10 @@ type Props = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
setCompleteInviteLink: (link: string) => void;
|
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => {
|
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
@@ -68,7 +68,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
|||||||
const { data: serverDetails } = useFetchServerStatus();
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
|
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
|
||||||
|
|
||||||
const { mutateAsync: addUserMutateAsync } = useAddUserToOrg();
|
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
|
||||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||||
|
|
||||||
const onRoleChange = async (membershipId: string, role: string) => {
|
const onRoleChange = async (membershipId: string, role: string) => {
|
||||||
@@ -106,14 +106,15 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
|||||||
|
|
||||||
const onResendInvite = async (email: string) => {
|
const onResendInvite = async (email: string) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await addUserMutateAsync({
|
const { data } = await addUsersMutateAsync({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
inviteeEmail: email
|
inviteeEmails: [email],
|
||||||
|
organizationRoleSlug: "member"
|
||||||
});
|
});
|
||||||
|
|
||||||
setCompleteInviteLink(data?.completeInviteLink || "");
|
setCompleteInviteLinks(data?.completeInviteLinks || null);
|
||||||
|
|
||||||
if (!data.completeInviteLink) {
|
if (!data.completeInviteLinks) {
|
||||||
createNotification({
|
createNotification({
|
||||||
text: `Successfully resent invite to ${email}`,
|
text: `Successfully resent invite to ${email}`,
|
||||||
type: "success"
|
type: "success"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { useTimedReset } from "@app/hooks";
|
import { useTimedReset } from "@app/hooks";
|
||||||
import {
|
import {
|
||||||
useAddUserToOrg,
|
useAddUsersToOrg,
|
||||||
useFetchServerStatus,
|
useFetchServerStatus,
|
||||||
useGetOrgMembership,
|
useGetOrgMembership,
|
||||||
useGetOrgRoles
|
useGetOrgRoles
|
||||||
@@ -44,18 +44,19 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
|||||||
const { data: roles } = useGetOrgRoles(orgId);
|
const { data: roles } = useGetOrgRoles(orgId);
|
||||||
const { data: serverDetails } = useFetchServerStatus();
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
const { data: membership } = useGetOrgMembership(orgId, membershipId);
|
const { data: membership } = useGetOrgMembership(orgId, membershipId);
|
||||||
const { mutateAsync: inviteUser, isLoading } = useAddUserToOrg();
|
const { mutateAsync: inviteUsers, isLoading } = useAddUsersToOrg();
|
||||||
|
|
||||||
const onResendInvite = async (email: string) => {
|
const onResendInvite = async (email: string) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await inviteUser({
|
const { data } = await inviteUsers({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
inviteeEmail: email
|
inviteeEmails: [email],
|
||||||
|
organizationRoleSlug: "member"
|
||||||
});
|
});
|
||||||
|
|
||||||
// setCompleteInviteLink(data?.completeInviteLink || "");
|
// setCompleteInviteLink(data?.completeInviteLink || "");
|
||||||
|
|
||||||
if (!data.completeInviteLink) {
|
if (!data.completeInviteLinks) {
|
||||||
createNotification({
|
createNotification({
|
||||||
text: `Successfully resent invite to ${email}`,
|
text: `Successfully resent invite to ${email}`,
|
||||||
type: "success"
|
type: "success"
|
||||||
|
|||||||
Reference in New Issue
Block a user