Compare commits

...

18 Commits

Author SHA1 Message Date
4b718b679a Change deactivate button messaging, add scim check 2024-07-18 10:44:54 +07:00
498b1109c9 resolve pr review issues 2024-07-18 10:32:39 +07:00
ada0033bd0 Fix type issue frontend 2024-07-17 23:13:04 +07:00
8542ec8c3e Complete preliminary user page 2024-07-17 19:48:04 +07:00
9efeb8926f Merge pull request #2137 from Infisical/maidul-dewfewfqwef
Address vanta postcss update
2024-07-16 21:46:14 -04:00
389bbfcade fix vanta postcss 2024-07-16 21:44:33 -04:00
0b8427a004 Merge pull request #2112 from Infisical/feat/added-support-for-oidc-auth-in-cli
feat: added support for oidc auth in cli
2024-07-17 00:51:51 +08:00
8a470772e3 Merge pull request #2136 from Infisical/polish-scim-groups
Add SCIM user activation/deactivation
2024-07-16 12:09:50 -04:00
853f3c40bc Adjustments to migration file 2024-07-16 22:20:56 +07:00
fed44f328d Merge pull request #2133 from akhilmhdh/feat/aws-kms-sm
fix: slug too big for project fixed
2024-07-16 09:50:08 -04:00
a1d00f2c41 Add SCIM user activation/deactivation 2024-07-16 20:19:27 +07:00
95a68f2c2d Merge pull request #2134 from Infisical/improve-auth-method-errors
Improve Native Auth Method Forbidden Errors
2024-07-16 15:00:12 +07:00
db7c0c45f6 Merge pull request #2135 from Infisical/fix-identity-projects
Fix Identity-Project Provisioning Modal — Filter Current Org Projects
2024-07-16 14:59:41 +07:00
82bca03162 Filter out only projects that are part of current org in identity project modal 2024-07-16 14:31:40 +07:00
=
560cd81a1c fix: slug too big for project fixed 2024-07-16 11:26:45 +05:30
6eae98c1d4 Update login.mdx 2024-07-16 05:45:48 +02:00
c2ddb7e2fe misc: updated go-sdk version 2024-07-15 12:26:31 +08:00
356afd18c4 feat: added support for oidc auth in cli 2024-07-12 18:28:09 +08:00
57 changed files with 1972 additions and 167 deletions

View File

@ -0,0 +1,25 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.OrgMembership)) {
const doesUserIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "userId");
const doesOrgIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "orgId");
await knex.schema.alterTable(TableName.OrgMembership, (t) => {
t.boolean("isActive").notNullable().defaultTo(true);
if (doesUserIdExist && doesOrgIdExist) t.index(["userId", "orgId"]);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.OrgMembership)) {
const doesUserIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "userId");
const doesOrgIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "orgId");
await knex.schema.alterTable(TableName.OrgMembership, (t) => {
t.dropColumn("isActive");
if (doesUserIdExist && doesOrgIdExist) t.dropIndex(["userId", "orgId"]);
});
}
}

View File

@ -17,7 +17,8 @@ export const OrgMembershipsSchema = z.object({
userId: z.string().uuid().nullable().optional(),
orgId: z.string().uuid(),
roleId: z.string().uuid().nullable().optional(),
projectFavorites: z.string().array().nullable().optional()
projectFavorites: z.string().array().nullable().optional(),
isActive: z.boolean()
});
export type TOrgMemberships = z.infer<typeof OrgMembershipsSchema>;

View File

@ -29,7 +29,8 @@ export async function seed(knex: Knex): Promise<void> {
role: OrgMembershipRole.Admin,
orgId: org.id,
status: OrgMembershipStatus.Accepted,
userId: user.id
userId: user.id,
isActive: true
}
]);
}

View File

@ -39,7 +39,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => {
},
schema: {
body: z.object({
slug: z.string().min(1).trim().optional(),
slug: z.string().min(1).trim().toLowerCase().optional(),
description: z.string().min(1).trim().optional(),
provider: ExternalKmsInputSchema
}),
@ -75,7 +75,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => {
id: z.string().trim().min(1)
}),
body: z.object({
slug: z.string().min(1).trim().optional(),
slug: z.string().min(1).trim().toLowerCase().optional(),
description: z.string().min(1).trim().optional(),
provider: ExternalKmsInputUpdateSchema
}),

View File

@ -186,7 +186,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
})
),
displayName: z.string().trim(),
active: z.boolean()
active: z.boolean(),
groups: z.array(
z.object({
value: z.string().trim(),
display: z.string().trim()
})
)
})
}
},
@ -572,7 +578,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
})
),
displayName: z.string().trim(),
active: z.boolean()
active: z.boolean(),
groups: z.array(
z.object({
value: z.string().trim(),
display: z.string().trim()
})
)
})
}
},

View File

@ -52,7 +52,7 @@ export const externalKmsServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const kmsSlug = slug ? slugify(slug) : slugify(alphaNumericNanoId(32));
const kmsSlug = slug ? slugify(slug) : slugify(alphaNumericNanoId(8).toLowerCase());
let sanitizedProviderInput = "";
switch (provider.type) {

View File

@ -162,11 +162,26 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
}
};
const findUserGroupMembershipsInOrg = async (userId: string, orgId: string) => {
try {
const docs = await db
.replicaNode()(TableName.UserGroupMembership)
.join(TableName.Groups, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`)
.where(`${TableName.UserGroupMembership}.userId`, userId)
.where(`${TableName.Groups}.orgId`, orgId);
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "findTest" });
}
};
return {
...userGroupMembershipOrm,
filterProjectsByUserMembership,
findUserGroupMembershipsInProject,
findGroupMembersNotInProject,
deletePendingUserGroupMembershipsByUserIds
deletePendingUserGroupMembershipsByUserIds,
findUserGroupMembershipsInOrg
};
};

View File

@ -449,7 +449,8 @@ export const ldapConfigServiceFactory = ({
userId: userAlias.userId,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Accepted
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
@ -534,7 +535,8 @@ export const ldapConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);

View File

@ -193,7 +193,8 @@ export const oidcConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -266,7 +267,8 @@ export const oidcConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);

View File

@ -370,7 +370,8 @@ export const samlConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -457,7 +458,8 @@ export const samlConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);

View File

@ -32,12 +32,19 @@ export const parseScimFilter = (filterToParse: string | undefined) => {
return { [attributeName]: parsedValue.replace(/"/g, "") };
};
export function extractScimValueFromPath(path: string): string | null {
const regex = /members\[value eq "([^"]+)"\]/;
const match = path.match(regex);
return match ? match[1] : null;
}
export const buildScimUser = ({
orgMembershipId,
username,
email,
firstName,
lastName,
groups = [],
active
}: {
orgMembershipId: string;
@ -45,6 +52,10 @@ export const buildScimUser = ({
email?: string | null;
firstName: string;
lastName: string;
groups?: {
value: string;
display: string;
}[];
active: boolean;
}): TScimUser => {
const scimUser = {
@ -67,7 +78,7 @@ export const buildScimUser = ({
]
: [],
active,
groups: [],
groups,
meta: {
resourceType: "User",
location: null

View File

@ -30,7 +30,14 @@ import { UserAliasType } from "@app/services/user-alias/user-alias-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, parseScimFilter } from "./scim-fns";
import {
buildScimGroup,
buildScimGroupList,
buildScimUser,
buildScimUserList,
extractScimValueFromPath,
parseScimFilter
} from "./scim-fns";
import {
TCreateScimGroupDTO,
TCreateScimTokenDTO,
@ -61,7 +68,7 @@ type TScimServiceFactoryDep = {
TOrgDALFactory,
"createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById"
>;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "findOne" | "create" | "updateById">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "findOne" | "create" | "updateById" | "findById">;
projectDAL: Pick<TProjectDALFactory, "find" | "findProjectGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete" | "findProjectMembershipsByUserId">;
groupDAL: Pick<
@ -71,7 +78,12 @@ type TScimServiceFactoryDep = {
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete"
| "find"
| "transaction"
| "insertMany"
| "filterProjectsByUserMembership"
| "delete"
| "findUserGroupMembershipsInOrg"
>;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany" | "delete">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
@ -197,14 +209,14 @@ export const scimServiceFactory = ({
findOpts
);
const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email }) =>
const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email, isActive }) =>
buildScimUser({
orgMembershipId: id ?? "",
username: externalId ?? username,
firstName: firstName ?? "",
lastName: lastName ?? "",
email,
active: true
active: isActive
})
);
@ -240,13 +252,19 @@ export const scimServiceFactory = ({
status: 403
});
const groupMembershipsInOrg = await userGroupMembershipDAL.findUserGroupMembershipsInOrg(membership.userId, orgId);
return buildScimUser({
orgMembershipId: membership.id,
username: membership.externalId ?? membership.username,
email: membership.email ?? "",
firstName: membership.firstName as string,
lastName: membership.lastName as string,
active: true
active: membership.isActive,
groups: groupMembershipsInOrg.map((group) => ({
value: group.groupId,
display: group.name
}))
});
};
@ -296,7 +314,8 @@ export const scimServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -364,7 +383,8 @@ export const scimServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -401,7 +421,7 @@ export const scimServiceFactory = ({
firstName: createdUser.firstName as string,
lastName: createdUser.lastName as string,
email: createdUser.email ?? "",
active: true
active: createdOrgMembership.isActive
});
};
@ -445,14 +465,8 @@ export const scimServiceFactory = ({
});
if (!active) {
await deleteOrgMembershipFn({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL,
projectMembershipDAL,
projectKeyDAL,
userAliasDAL,
licenseService
await orgMembershipDAL.updateById(membership.id, {
isActive: false
});
}
@ -491,17 +505,11 @@ export const scimServiceFactory = ({
status: 403
});
if (!active) {
await deleteOrgMembershipFn({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL,
projectMembershipDAL,
projectKeyDAL,
userAliasDAL,
licenseService
});
}
await orgMembershipDAL.updateById(membership.id, {
isActive: active
});
const groupMembershipsInOrg = await userGroupMembershipDAL.findUserGroupMembershipsInOrg(membership.userId, orgId);
return buildScimUser({
orgMembershipId: membership.id,
@ -509,7 +517,11 @@ export const scimServiceFactory = ({
email: membership.email,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
active
active,
groups: groupMembershipsInOrg.map((group) => ({
value: group.groupId,
display: group.name
}))
});
};
@ -881,7 +893,18 @@ export const scimServiceFactory = ({
break;
}
case "remove": {
// TODO
const orgMembershipId = extractScimValueFromPath(operation.path);
if (!orgMembershipId) throw new ScimRequestError({ detail: "Invalid path value", status: 400 });
const orgMembership = await orgMembershipDAL.findById(orgMembershipId);
if (!orgMembership) throw new ScimRequestError({ detail: "Org Membership Not Found", status: 400 });
await removeUsersFromGroupByUserIds({
group,
userIds: [orgMembership.userId as string],
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL
});
break;
}
default: {

View File

@ -158,7 +158,10 @@ export type TScimUser = {
type: string;
}[];
active: boolean;
groups: string[];
groups: {
value: string;
display: string;
}[];
meta: {
resourceType: string;
location: null;

View File

@ -348,10 +348,15 @@ export const ORGANIZATIONS = {
LIST_USER_MEMBERSHIPS: {
organizationId: "The ID of the organization to get memberships from."
},
GET_USER_MEMBERSHIP: {
organizationId: "The ID of the organization to get the membership for.",
membershipId: "The ID of the membership to get."
},
UPDATE_USER_MEMBERSHIP: {
organizationId: "The ID of the organization to update the membership for.",
membershipId: "The ID of the membership to update.",
role: "The new role of the membership."
role: "The new role of the membership.",
isActive: "The active status of the membership"
},
DELETE_USER_MEMBERSHIP: {
organizationId: "The ID of the organization to delete the membership from.",

View File

@ -345,7 +345,7 @@ export const registerRoutes = async (
permissionService,
secretApprovalPolicyDAL
});
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL });
const samlService = samlConfigServiceFactory({
permissionService,
@ -457,6 +457,7 @@ export const registerRoutes = async (
tokenService,
projectDAL,
projectMembershipDAL,
orgMembershipDAL,
projectKeyDAL,
smtpService,
userDAL,

View File

@ -78,6 +78,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
lastName: true,
id: true
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
project: ProjectsSchema.pick({ name: true, id: true }),
roles: z.array(
z.object({
id: z.string(),

View File

@ -1,6 +1,13 @@
import { z } from "zod";
import { OrganizationsSchema, OrgMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
import {
OrganizationsSchema,
OrgMembershipsSchema,
ProjectMembershipsSchema,
ProjectsSchema,
UserEncryptionKeysSchema,
UsersSchema
} from "@app/db/schemas";
import { ORGANIZATIONS } from "@app/lib/api-docs";
import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@ -30,6 +37,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
user: UsersSchema.pick({
username: true,
email: true,
isEmailVerified: true,
firstName: true,
lastName: true,
id: true
@ -103,6 +111,54 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/:organizationId/memberships/:membershipId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Get organization user membership",
security: [
{
bearerAuth: []
}
],
params: z.object({
organizationId: z.string().trim().describe(ORGANIZATIONS.GET_USER_MEMBERSHIP.organizationId),
membershipId: z.string().trim().describe(ORGANIZATIONS.GET_USER_MEMBERSHIP.membershipId)
}),
response: {
200: z.object({
membership: OrgMembershipsSchema.merge(
z.object({
user: UsersSchema.pick({
username: true,
email: true,
isEmailVerified: true,
firstName: true,
lastName: true,
id: true
}).merge(z.object({ publicKey: z.string().nullable() }))
})
).omit({ createdAt: true, updatedAt: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const membership = await server.services.org.getOrgMembership({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
orgId: req.params.organizationId,
membershipId: req.params.membershipId
});
return { membership };
}
});
server.route({
method: "PATCH",
url: "/:organizationId/memberships/:membershipId",
@ -121,7 +177,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
membershipId: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.membershipId)
}),
body: z.object({
role: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role)
role: z.string().trim().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role),
isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive)
}),
response: {
200: z.object({
@ -129,17 +186,17 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
if (req.auth.actor !== ActorType.USER) return;
const membership = await server.services.org.updateOrgMembership({
userId: req.permission.id,
role: req.body.role,
actorAuthMethod: req.permission.authMethod,
orgId: req.params.organizationId,
membershipId: req.params.membershipId,
actorOrgId: req.permission.orgId
actorOrgId: req.permission.orgId,
...req.body
});
return { membership };
}
@ -183,6 +240,69 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
// TODO: re-think endpoint structure in future so users only need to pass in membershipId bc organizationId is redundant
method: "GET",
url: "/:organizationId/memberships/:membershipId/project-memberships",
config: {
rateLimit: writeLimit
},
schema: {
description: "Get project memberships given organization membership",
security: [
{
bearerAuth: []
}
],
params: z.object({
organizationId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.organizationId),
membershipId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.membershipId)
}),
response: {
200: z.object({
memberships: ProjectMembershipsSchema.extend({
user: UsersSchema.pick({
email: true,
username: true,
firstName: true,
lastName: true,
id: true
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
project: ProjectsSchema.pick({ name: true, id: true }),
roles: z.array(
z.object({
id: z.string(),
role: z.string(),
customRoleId: z.string().optional().nullable(),
customRoleName: z.string().optional().nullable(),
customRoleSlug: z.string().optional().nullable(),
isTemporary: z.boolean(),
temporaryMode: z.string().optional().nullable(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional()
})
)
})
.omit({ createdAt: true, updatedAt: true })
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const memberships = await server.services.org.listProjectMembershipsByOrgMembershipId({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
orgId: req.params.organizationId,
orgMembershipId: req.params.membershipId
});
return { memberships };
}
});
server.route({
method: "POST",
url: "/",

View File

@ -4,7 +4,8 @@ import bcrypt from "bcrypt";
import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { AuthModeJwtTokenPayload } from "../auth/auth-type";
import { TUserDALFactory } from "../user/user-dal";
@ -14,6 +15,7 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo
type TAuthTokenServiceFactoryDep = {
tokenDAL: TTokenDALFactory;
userDAL: Pick<TUserDALFactory, "findById" | "transaction">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "findOne">;
};
export type TAuthTokenServiceFactory = ReturnType<typeof tokenServiceFactory>;
@ -67,7 +69,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
}
};
export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFactoryDep) => {
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => {
const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => {
const { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig();
@ -154,6 +156,16 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFact
const user = await userDAL.findById(session.userId);
if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" });
if (token.organizationId) {
const orgMembership = await orgMembershipDAL.findOne({
userId: user.id,
orgId: token.organizationId
});
if (!orgMembership) throw new ForbiddenRequestError({ message: "User not member of organization" });
if (!orgMembership.isActive) throw new ForbiddenRequestError({ message: "User not active in organization" });
}
return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId };
};

View File

@ -56,15 +56,18 @@ export const kmsServiceFactory = ({
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
const kmsKeyMaterial = randomSecureBytes(32);
const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY);
const sanitizedSlug = slug ? slugify(slug) : slugify(alphaNumericNanoId(32));
const sanitizedSlug = slug ? slugify(slug) : slugify(alphaNumericNanoId(8).toLowerCase());
const dbQuery = async (db: Knex) => {
const kmsDoc = await kmsDAL.create({
slug: sanitizedSlug,
orgId,
isReserved
});
const kmsDoc = await kmsDAL.create(
{
slug: sanitizedSlug,
orgId,
isReserved
},
db
);
const { encryptedKey, ...doc } = await internalKmsDAL.create(
await internalKmsDAL.create(
{
version: 1,
encryptedKey: encryptedKeyMaterial,
@ -73,7 +76,7 @@ export const kmsServiceFactory = ({
},
db
);
return doc;
return kmsDoc;
};
if (tx) return dbQuery(tx);
const doc = await kmsDAL.transaction(async (tx2) => dbQuery(tx2));

View File

@ -1,5 +1,6 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TOrgMembershipDALFactory = ReturnType<typeof orgMembershipDALFactory>;
@ -7,7 +8,51 @@ export type TOrgMembershipDALFactory = ReturnType<typeof orgMembershipDALFactory
export const orgMembershipDALFactory = (db: TDbClient) => {
const orgMembershipOrm = ormify(db, TableName.OrgMembership);
const findOrgMembershipById = async (membershipId: string) => {
try {
const member = await db
.replicaNode()(TableName.OrgMembership)
.where(`${TableName.OrgMembership}.id`, membershipId)
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin<TUserEncryptionKeys>(
TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id`
)
.select(
db.ref("id").withSchema(TableName.OrgMembership),
db.ref("inviteEmail").withSchema(TableName.OrgMembership),
db.ref("orgId").withSchema(TableName.OrgMembership),
db.ref("role").withSchema(TableName.OrgMembership),
db.ref("roleId").withSchema(TableName.OrgMembership),
db.ref("status").withSchema(TableName.OrgMembership),
db.ref("isActive").withSchema(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("isEmailVerified").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
)
.where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER
.first();
if (!member) return undefined;
const { email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data } = member;
return {
...data,
user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey }
};
} catch (error) {
throw new DatabaseError({ error, name: "Find org membership by id" });
}
};
return {
...orgMembershipOrm
...orgMembershipOrm,
findOrgMembershipById
};
};

View File

@ -74,7 +74,9 @@ export const orgDALFactory = (db: TDbClient) => {
db.ref("role").withSchema(TableName.OrgMembership),
db.ref("roleId").withSchema(TableName.OrgMembership),
db.ref("status").withSchema(TableName.OrgMembership),
db.ref("isActive").withSchema(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("isEmailVerified").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
@ -83,9 +85,9 @@ export const orgDALFactory = (db: TDbClient) => {
)
.where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER
return members.map(({ email, username, firstName, lastName, userId, publicKey, ...data }) => ({
return members.map(({ email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data }) => ({
...data,
user: { email, username, firstName, lastName, id: userId, publicKey }
user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey }
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find all org members" });

View File

@ -15,9 +15,10 @@ import { getConfig } from "@app/lib/config/env";
import { generateAsymmetricKeyPair } from "@app/lib/crypto";
import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { generateUserSrpKeys } from "@app/lib/crypto/srp";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { isDisposableEmail } from "@app/lib/validator";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
@ -38,7 +39,9 @@ import {
TFindAllWorkspacesDTO,
TFindOrgMembersByEmailDTO,
TGetOrgGroupsDTO,
TGetOrgMembershipDTO,
TInviteUserToOrgDTO,
TListProjectMembershipsByOrgMembershipIdDTO,
TUpdateOrgDTO,
TUpdateOrgMembershipDTO,
TVerifyUserToOrgDTO
@ -54,6 +57,7 @@ type TOrgServiceFactoryDep = {
projectDAL: TProjectDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserId" | "delete">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "findOrgMembershipById">;
incidentContactDAL: TIncidentContactsDALFactory;
samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne" | "findEnforceableSamlCfg">;
smtpService: TSmtpService;
@ -79,6 +83,7 @@ export const orgServiceFactory = ({
projectDAL,
projectMembershipDAL,
projectKeyDAL,
orgMembershipDAL,
tokenService,
orgBotDAL,
licenseService,
@ -204,7 +209,8 @@ export const orgServiceFactory = ({
orgId,
userId: user.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted
status: OrgMembershipStatus.Accepted,
isActive: true
};
await orgDAL.createMembership(createMembershipData, tx);
@ -308,7 +314,8 @@ export const orgServiceFactory = ({
userId,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
@ -362,6 +369,7 @@ export const orgServiceFactory = ({
* */
const updateOrgMembership = async ({
role,
isActive,
orgId,
userId,
membershipId,
@ -371,8 +379,13 @@ export const orgServiceFactory = ({
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member);
const [foundMembership] = await orgDAL.findMembership({ id: membershipId, orgId });
if (!foundMembership) throw new NotFoundError({ message: "Failed to find organization membership" });
if (foundMembership.userId === userId)
throw new BadRequestError({ message: "Cannot update own organization membership" });
const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole);
if (isCustomRole) {
if (role && isCustomRole) {
const customRole = await orgRoleDAL.findOne({ slug: role, orgId });
if (!customRole) throw new BadRequestError({ name: "Update membership", message: "Role not found" });
@ -392,7 +405,7 @@ export const orgServiceFactory = ({
return membership;
}
const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null });
const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null, isActive });
return membership;
};
/*
@ -457,7 +470,8 @@ export const orgServiceFactory = ({
inviteEmail: inviteeEmail,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
status: OrgMembershipStatus.Invited,
isActive: true
},
tx
);
@ -488,7 +502,8 @@ export const orgServiceFactory = ({
orgId,
userId: user.id,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
status: OrgMembershipStatus.Invited,
isActive: true
},
tx
);
@ -581,6 +596,24 @@ export const orgServiceFactory = ({
return { token, user };
};
const getOrgMembership = async ({
membershipId,
orgId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TGetOrgMembershipDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
const membership = await orgMembershipDAL.findOrgMembershipById(membershipId);
if (!membership) throw new NotFoundError({ message: "Failed to find organization membership" });
if (membership.orgId !== orgId) throw new NotFoundError({ message: "Failed to find organization membership" });
return membership;
};
const deleteOrgMembership = async ({
orgId,
userId,
@ -604,6 +637,26 @@ export const orgServiceFactory = ({
return deletedMembership;
};
const listProjectMembershipsByOrgMembershipId = async ({
orgMembershipId,
orgId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TListProjectMembershipsByOrgMembershipIdDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
const membership = await orgMembershipDAL.findOrgMembershipById(orgMembershipId);
if (!membership) throw new NotFoundError({ message: "Failed to find organization membership" });
if (membership.orgId !== orgId) throw new NotFoundError({ message: "Failed to find organization membership" });
const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, membership.user.id);
return projectMemberships;
};
/*
* CRUD operations of incident contacts
* */
@ -664,6 +717,7 @@ export const orgServiceFactory = ({
findOrgMembersByUsername,
createOrganization,
deleteOrganizationById,
getOrgMembership,
deleteOrgMembership,
findAllWorkspaces,
addGhostUser,
@ -672,6 +726,7 @@ export const orgServiceFactory = ({
findIncidentContacts,
createIncidentContact,
deleteIncidentContact,
getOrgGroups
getOrgGroups,
listProjectMembershipsByOrgMembershipId
};
};

View File

@ -6,11 +6,16 @@ export type TUpdateOrgMembershipDTO = {
userId: string;
orgId: string;
membershipId: string;
role: string;
role?: string;
isActive?: boolean;
actorOrgId: string | undefined;
actorAuthMethod: ActorAuthMethod;
};
export type TGetOrgMembershipDTO = {
membershipId: string;
} & TOrgPermission;
export type TDeleteOrgMembershipDTO = {
userId: string;
orgId: string;
@ -55,3 +60,7 @@ export type TUpdateOrgDTO = {
} & TOrgPermission;
export type TGetOrgGroupsDTO = TOrgPermission;
export type TListProjectMembershipsByOrgMembershipIdDTO = {
orgMembershipId: string;
} & TOrgPermission;

View File

@ -16,6 +16,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
const docs = await db
.replicaNode()(TableName.ProjectMembership)
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.where((qb) => {
if (filter.usernames) {
@ -58,17 +59,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole)
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project)
)
.where({ isGhost: false });
const members = sqlNestRelationships({
data: docs,
parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId }) => ({
parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId, projectName }) => ({
id,
userId,
projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
project: {
id: projectId,
name: projectName
}
}),
key: "id",
childrenMapper: [
@ -151,14 +157,95 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
const findProjectMembershipsByUserId = async (orgId: string, userId: string) => {
try {
const memberships = await db
const docs = await db
.replicaNode()(TableName.ProjectMembership)
.where({ userId })
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.where({ [`${TableName.Project}.orgId` as "orgId"]: orgId })
.select(selectAllTableCols(TableName.ProjectMembership));
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.where(`${TableName.Users}.id`, userId)
.where(`${TableName.Project}.orgId`, orgId)
.join<TUserEncryptionKeys>(
TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id`
)
.join(
TableName.ProjectUserMembershipRole,
`${TableName.ProjectUserMembershipRole}.projectMembershipId`,
`${TableName.ProjectMembership}.id`
)
.leftJoin(
TableName.ProjectRoles,
`${TableName.ProjectUserMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.select(
db.ref("id").withSchema(TableName.ProjectMembership),
db.ref("isGhost").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("email").withSchema(TableName.Users),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("role").withSchema(TableName.ProjectUserMembershipRole),
db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"),
db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole),
db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("id").as("projectId").withSchema(TableName.Project)
)
.where({ isGhost: false });
return memberships;
const members = sqlNestRelationships({
data: docs,
parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, projectId, projectName }) => ({
id,
userId,
projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
project: {
id: projectId,
name: projectName
}
}),
key: "id",
childrenMapper: [
{
label: "roles" as const,
key: "membershipRoleId",
mapper: ({
role,
customRoleId,
customRoleName,
customRoleSlug,
membershipRoleId,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
}) => ({
id: membershipRoleId,
role,
customRoleId,
customRoleName,
customRoleSlug,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
})
}
]
});
return members;
} catch (error) {
throw new DatabaseError({ error, name: "Find project memberships by user id" });
}

View File

@ -10,7 +10,7 @@ require (
github.com/fatih/semgroup v1.2.0
github.com/gitleaks/go-gitdiff v0.8.0
github.com/h2non/filetype v1.1.3
github.com/infisical/go-sdk v0.2.0
github.com/infisical/go-sdk v0.3.0
github.com/mattn/go-isatty v0.0.14
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a
github.com/muesli/mango-cobra v1.2.0

View File

@ -263,8 +263,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/infisical/go-sdk v0.2.0 h1:n1/KNdYpeQavSqVwC9BfeV8VRzf3N2X9zO1tzQOSj5Q=
github.com/infisical/go-sdk v0.2.0/go.mod h1:vHTDVw3k+wfStXab513TGk1n53kaKF2xgLqpw/xvtl4=
github.com/infisical/go-sdk v0.3.0 h1:Ls71t227F4CWVQWdStcwv8WDyfHe8eRlyAuMRNHsmlQ=
github.com/infisical/go-sdk v0.3.0/go.mod h1:vHTDVw3k+wfStXab513TGk1n53kaKF2xgLqpw/xvtl4=
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=

View File

@ -122,6 +122,21 @@ func handleAwsIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.Infi
return infisicalClient.Auth().AwsIamAuthLogin(identityId)
}
func handleOidcAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) {
identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME)
if err != nil {
return infisicalSdk.MachineIdentityCredential{}, err
}
jwt, err := util.GetCmdFlagOrEnv(cmd, "oidc-jwt", util.INFISICAL_OIDC_AUTH_JWT_NAME)
if err != nil {
return infisicalSdk.MachineIdentityCredential{}, err
}
return infisicalClient.Auth().OidcAuthLogin(identityId, jwt)
}
func formatAuthMethod(authMethod string) string {
return strings.ReplaceAll(authMethod, "-", " ")
}
@ -257,6 +272,7 @@ var loginCmd = &cobra.Command{
util.AuthStrategy.GCP_ID_TOKEN_AUTH: handleGcpIdTokenAuthLogin,
util.AuthStrategy.GCP_IAM_AUTH: handleGcpIamAuthLogin,
util.AuthStrategy.AWS_IAM_AUTH: handleAwsIamAuthLogin,
util.AuthStrategy.OIDC_AUTH: handleOidcAuthLogin,
}
credential, err := authStrategies[strategy](cmd, infisicalClient)
@ -456,6 +472,7 @@ func init() {
loginCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods")
loginCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth")
loginCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth")
loginCmd.Flags().String("oidc-jwt", "", "JWT for OIDC authentication")
}
func DomainOverridePrompt() (bool, error) {
@ -616,7 +633,7 @@ func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2R
loginTwoResponseResult, err := api.CallLogin2V2(httpClient, api.GetLoginTwoV2Request{
Email: email,
ClientProof: hex.EncodeToString(srpM1),
Password: password,
Password: password,
})
if err != nil {

View File

@ -9,6 +9,7 @@ var AuthStrategy = struct {
GCP_ID_TOKEN_AUTH AuthStrategyType
GCP_IAM_AUTH AuthStrategyType
AWS_IAM_AUTH AuthStrategyType
OIDC_AUTH AuthStrategyType
}{
UNIVERSAL_AUTH: "universal-auth",
KUBERNETES_AUTH: "kubernetes",
@ -16,6 +17,7 @@ var AuthStrategy = struct {
GCP_ID_TOKEN_AUTH: "gcp-id-token",
GCP_IAM_AUTH: "gcp-iam",
AWS_IAM_AUTH: "aws-iam",
OIDC_AUTH: "oidc-auth",
}
var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{
@ -25,6 +27,7 @@ var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{
AuthStrategy.GCP_ID_TOKEN_AUTH,
AuthStrategy.GCP_IAM_AUTH,
AuthStrategy.AWS_IAM_AUTH,
AuthStrategy.OIDC_AUTH,
}
func IsAuthMethodValid(authMethod string, allowUserAuth bool) (isValid bool, strategy AuthStrategyType) {

View File

@ -19,6 +19,9 @@ const (
// GCP Auth
INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME = "INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH"
// OIDC Auth
INFISICAL_OIDC_AUTH_JWT_NAME = "INFISICAL_OIDC_AUTH_JWT"
// Generic env variable used for auth methods that require a machine identity ID
INFISICAL_MACHINE_IDENTITY_ID_NAME = "INFISICAL_MACHINE_IDENTITY_ID"

View File

@ -8,7 +8,8 @@ infisical login
```
### Description
The CLI uses authentication to verify your identity. When you enter the correct email and password for your account, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI.
The CLI uses authentication to verify your identity. When you enter the correct email and password for your account, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI.
To change where the login credentials are stored, visit the [vaults command](./vault).
@ -17,12 +18,12 @@ If you have added multiple users, you can switch between the users by using the
<Info>
When you authenticate with **any other method than `user`**, an access token will be printed to the console upon successful login. This token can be used to authenticate with the Infisical API and the CLI by passing it in the `--token` flag when applicable.
Use flag `--plain` along with `--silent` to print only the token in plain text when using a machine identity auth method.
Use flag `--plain` along with `--silent` to print only the token in plain text when using a machine identity auth method.
</Info>
### Flags
The login command supports a number of flags that you can use for different authentication methods. Below is a list of all the flags that can be used with the login command.
<AccordionGroup>
@ -52,6 +53,7 @@ The login command supports a number of flags that you can use for different auth
<Tip>
The `client-id` flag can be substituted with the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` environment variable.
</Tip>
</Accordion>
<Accordion title="--client-secret">
```bash
@ -63,6 +65,7 @@ The login command supports a number of flags that you can use for different auth
<Tip>
The `client-secret` flag can be substituted with the `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` environment variable.
</Tip>
</Accordion>
<Accordion title="--machine-identity-id">
```bash
@ -75,6 +78,7 @@ The login command supports a number of flags that you can use for different auth
<Tip>
The `machine-identity-id` flag can be substituted with the `INFISICAL_MACHINE_IDENTITY_ID` environment variable.
</Tip>
</Accordion>
<Accordion title="--service-account-token-path">
```bash
@ -88,6 +92,7 @@ The login command supports a number of flags that you can use for different auth
<Tip>
The `service-account-token-path` flag can be substituted with the `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH` environment variable.
</Tip>
</Accordion>
<Accordion title="--service-account-key-file-path">
```bash
@ -100,9 +105,23 @@ The login command supports a number of flags that you can use for different auth
<Tip>
The `service-account-key-path` flag can be substituted with the `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` environment variable.
</Tip>
</Accordion>
</AccordionGroup>
<Accordion title="--oidc-jwt">
```bash
infisical login --oidc-jwt=<oidc-jwt-token>
```
#### Description
The JWT provided by an identity provider for OIDC authentication.
<Tip>
The `oidc-jwt` flag can be substituted with the `INFISICAL_OIDC_AUTH_JWT` environment variable.
</Tip>
</Accordion>
### Authentication Methods
@ -121,6 +140,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa
Your machine identity client secret.
</ParamField>
</Expandable>
</ParamField>
<Steps>
@ -134,6 +154,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa
infisical login --method=universal-auth --client-id=<client-id> --client-secret=<client-secret>
```
</Step>
</Steps>
</Accordion>
<Accordion title="Native Kubernetes">
@ -148,6 +169,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa
Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`.
</ParamField>
</Expandable>
</ParamField>
<Steps>
@ -162,6 +184,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa
infisical login --method=kubernetes --machine-identity-id=<machine-identity-id> --service-account-token-path=<service-account-token-path>
```
</Step>
</Steps>
</Accordion>
@ -213,6 +236,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa
```
</Step>
</Steps>
</Accordion>
<Accordion title="GCP IAM">
The GCP IAM method is used to authenticate with Infisical with a GCP service account key.
@ -235,11 +259,12 @@ The Infisical CLI supports multiple authentication methods. Below are the availa
<Step title="Obtain an access token">
Run the `login` command with the following flags to obtain an access token:
```bash
```bash
infisical login --method=gcp-iam --machine-identity-id=<machine-identity-id> --service-account-key-file-path=<service-account-key-file-path>
```
</Step>
</Steps>
</Accordion>
<Accordion title="Native AWS IAM">
The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc.
@ -264,10 +289,40 @@ The Infisical CLI supports multiple authentication methods. Below are the availa
```
</Step>
</Steps>
</Accordion>
<Accordion title="OIDC Auth">
The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC.
<ParamField query="Flags">
<Expandable title="properties">
<ParamField query="machine-identity-id" type="string" required>
Your machine identity ID.
</ParamField>
<ParamField query="oidc-jwt" type="string" required>
The OIDC JWT from the identity provider.
</ParamField>
</Expandable>
</ParamField>
<Steps>
<Step title="Create an OIDC machine identity">
To create an OIDC machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/oidc-auth/general).
</Step>
<Step title="Obtain an access token">
Run the `login` command with the following flags to obtain an access token:
```bash
infisical login --method=oidc-auth --machine-identity-id=<machine-identity-id> --oidc-jwt=<oidc-jwt>
```
</Step>
</Steps>
</Accordion>
</AccordionGroup>
### Machine Identity Authentication Quick Start
In this example we'll be using the `universal-auth` method to login to obtain an Infisical access token, which we will then use to fetch secrets with.
<Steps>
@ -277,8 +332,8 @@ In this example we'll be using the `universal-auth` method to login to obtain an
```
Now that we've set the `INFISICAL_TOKEN` environment variable, we can use the CLI to interact with Infisical. The CLI will automatically check for the presence of the `INFISICAL_TOKEN` environment variable and use it for authentication.
Alternatively, if you would rather use the `--token` flag to pass the token directly, you can do so by running the following command:
```bash
@ -297,6 +352,7 @@ In this example we'll be using the `universal-auth` method to login to obtain an
The `--recursive`, and `--env` flag is optional and will fetch all secrets in subfolders. The default environment is `dev` if no `--env` flag is provided.
</Info>
</Step>
</Steps>
And that's it! Now you're ready to start using the Infisical CLI to interact with your secrets, with the use of Machine Identities.

View File

@ -136,7 +136,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-storybook": "^0.6.12",
"postcss": "^8.4.14",
"postcss": "^8.4.39",
"prettier": "^2.8.3",
"prettier-plugin-tailwindcss": "^0.2.2",
"storybook": "^7.6.20",

View File

@ -144,7 +144,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-storybook": "^0.6.12",
"postcss": "^8.4.14",
"postcss": "^8.4.39",
"prettier": "^2.8.3",
"prettier-plugin-tailwindcss": "^0.2.2",
"storybook": "^7.6.20",

View File

@ -25,7 +25,7 @@ export const DeleteActionModal = ({
deleteKey,
onDeleteApproved,
title,
subTitle = "This action is irreversible!",
subTitle = "This action is irreversible.",
buttonText = "Delete"
}: Props): JSX.Element => {
const [inputData, setInputData] = useState("");
@ -86,7 +86,7 @@ export const DeleteActionModal = ({
<FormControl
label={
<div className="break-words pb-2 text-sm">
Type <span className="font-bold">{deleteKey}</span> to delete the resource
Type <span className="font-bold">{deleteKey}</span> to perform this action
</div>
}
className="mb-0"
@ -94,7 +94,7 @@ export const DeleteActionModal = ({
<Input
value={inputData}
onChange={(e) => setInputData(e.target.value)}
placeholder="Type to delete..."
placeholder="Type confirm..."
/>
</FormControl>
</form>

View File

@ -16,6 +16,8 @@ export {
useGetMyIp,
useGetMyOrganizationProjects,
useGetMySessions,
useGetOrgMembership,
useGetOrgMembershipProjectMemberships,
useGetOrgUsers,
useGetUser,
useGetUserAction,
@ -23,6 +25,5 @@ export {
useRegisterUserAction,
useRevokeMySessions,
useUpdateMfaEnabled,
useUpdateOrgUserRole,
useUpdateUserAuthMethods
} from "./queries";
useUpdateOrgMembership,
useUpdateUserAuthMethods} from "./queries";

View File

@ -57,8 +57,9 @@ export const useAddUserToWsNonE2EE = () => {
});
return data;
},
onSuccess: (_, { projectId }) => {
onSuccess: (_, { orgId, projectId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(projectId));
queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId));
}
});
};

View File

@ -13,7 +13,8 @@ import {
OrgUser,
RenameUserDTO,
TokenVersion,
UpdateOrgUserRoleDTO,
TWorkspaceUser,
UpdateOrgMembershipDTO,
User,
UserEnc
} from "./types";
@ -23,6 +24,13 @@ export const userKeys = {
getPrivateKey: ["user"] as const,
userAction: ["user-action"] as const,
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
getOrgMembership: (orgId: string, orgMembershipId: string) =>
[{ orgId, orgMembershipId }, "org-membership"] as const,
allOrgMembershipProjectMemberships: (orgId: string) => [orgId, "all-user-memberships"] as const,
forOrgMembershipProjectMemberships: (orgId: string, orgMembershipId: string) =>
[...userKeys.allOrgMembershipProjectMemberships(orgId), { orgMembershipId }] as const,
getOrgMembershipProjectMemberships: (orgId: string, username: string) =>
[{ orgId, username }, "org-membership-project-memberships"] as const,
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
myIp: ["ip"] as const,
myAPIKeys: ["api-keys"] as const,
@ -167,6 +175,41 @@ export const useAddUserToOrg = () => {
});
};
export const useGetOrgMembership = (organizationId: string, orgMembershipId: string) => {
return useQuery({
queryKey: userKeys.getOrgMembership(organizationId, orgMembershipId),
queryFn: async () => {
const {
data: { membership }
} = await apiRequest.get<{ membership: OrgUser }>(
`/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}`
);
return membership;
},
enabled: Boolean(organizationId) && Boolean(orgMembershipId)
});
};
export const useGetOrgMembershipProjectMemberships = (
organizationId: string,
orgMembershipId: string
) => {
return useQuery({
queryKey: userKeys.forOrgMembershipProjectMemberships(organizationId, orgMembershipId),
queryFn: async () => {
const {
data: { memberships }
} = await apiRequest.get<{ memberships: TWorkspaceUser[] }>(
`/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}/project-memberships`
);
return memberships;
},
enabled: Boolean(organizationId) && Boolean(orgMembershipId)
});
};
export const useDeleteOrgMembership = () => {
const queryClient = useQueryClient();
@ -180,24 +223,43 @@ export const useDeleteOrgMembership = () => {
});
};
export const useUpdateOrgUserRole = () => {
export const useDeactivateOrgMembership = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateOrgUserRoleDTO>({
mutationFn: ({ organizationId, membershipId, role }) => {
return useMutation<{}, {}, DeletOrgMembershipDTO>({
mutationFn: ({ membershipId, orgId }) => {
return apiRequest.post(
`/api/v2/organizations/${orgId}/memberships/${membershipId}/deactivate`
);
},
onSuccess: (_, { orgId, membershipId }) => {
queryClient.invalidateQueries(userKeys.getOrgUsers(orgId));
queryClient.invalidateQueries(userKeys.getOrgMembership(orgId, membershipId));
}
});
};
export const useUpdateOrgMembership = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateOrgMembershipDTO>({
mutationFn: ({ organizationId, membershipId, role, isActive }) => {
return apiRequest.patch(
`/api/v2/organizations/${organizationId}/memberships/${membershipId}`,
{
role
role,
isActive
}
);
},
onSuccess: (_, { organizationId }) => {
onSuccess: (_, { organizationId, membershipId }) => {
queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId));
queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId));
},
// to remove old states
onError: (_, { organizationId }) => {
onError: (_, { organizationId, membershipId }) => {
queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId));
queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId));
}
});
};

View File

@ -49,6 +49,7 @@ export type OrgUser = {
user: {
username: string;
email?: string;
isEmailVerified: boolean;
firstName: string;
lastName: string;
id: string;
@ -60,6 +61,7 @@ export type OrgUser = {
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
roleId: string;
isActive: boolean;
};
export type TProjectMembership = {
@ -81,6 +83,11 @@ export type TWorkspaceUser = {
id: string;
publicKey: string;
};
projectId: string;
project: {
id: string;
name: string;
};
inviteEmail: string;
organization: string;
roles: (
@ -126,12 +133,14 @@ export type AddUserToWsDTOE2EE = {
export type AddUserToWsDTONonE2EE = {
projectId: string;
usernames: string[];
orgId: string;
};
export type UpdateOrgUserRoleDTO = {
export type UpdateOrgMembershipDTO = {
organizationId: string;
membershipId: string;
role: string;
role?: string;
isActive?: boolean;
};
export type DeletOrgMembershipDTO = {

View File

@ -11,6 +11,7 @@ import { IdentityMembership } from "../identities/types";
import { IntegrationAuth } from "../integrationAuth/types";
import { TIntegration } from "../integrations/types";
import { EncryptedSecret } from "../secrets/types";
import { userKeys } from "../users/queries";
import { TWorkspaceUser } from "../users/types";
import {
CreateEnvironmentDTO,
@ -385,6 +386,7 @@ export const useDeleteUserFromWorkspace = () => {
}: {
workspaceId: string;
usernames: string[];
orgId: string;
}) => {
const {
data: { deletedMembership }
@ -393,8 +395,9 @@ export const useDeleteUserFromWorkspace = () => {
});
return deletedMembership;
},
onSuccess: (_, { workspaceId }) => {
onSuccess: (_, { orgId, workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId));
}
});
};

View File

@ -264,7 +264,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
usernames: orgUsers
.map((member) => member.user.username)
.filter((username) => username !== user.username),
projectId: newProjectId
projectId: newProjectId,
orgId: currentOrg.id
});
}

View File

@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { UserPage } from "@app/views/Org/UserPage";
export default function User() {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<UserPage />
</>
);
}
User.requireAuth = true;

View File

@ -541,7 +541,8 @@ const OrganizationPage = withPermission(
usernames: orgUsers
.map((member) => member.user.username)
.filter((username) => username !== user.username),
projectId: newProjectId
projectId: newProjectId,
orgId: currentOrg.id
});
}

View File

@ -5,7 +5,7 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useOrganization,useWorkspace } from "@app/context";
import {
useAddIdentityToWorkspace,
useGetIdentityProjectMemberships,
@ -33,6 +33,7 @@ type Props = {
};
export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { workspaces } = useWorkspace();
const { mutateAsync: addIdentityToWorkspace } = useAddIdentityToWorkspace();
@ -58,7 +59,9 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
wsWorkspaceIds.set(projectMembership.project.id, true);
});
return (workspaces || []).filter(({ id }) => !wsWorkspaceIds.has(id));
return (workspaces || []).filter(
({ id, orgId }) => !wsWorkspaceIds.has(id) && orgId === currentOrg?.id
);
}, [workspaces, projectMemberships]);
const onFormSubmit = async ({ projectId: workspaceId, role }: FormData) => {

View File

@ -1,4 +1,4 @@
import { faKey } from "@fortawesome/free-solid-svg-icons";
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
@ -37,7 +37,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="identity-project-memberships" />}
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
{!isLoading &&
projectMemberships?.map((membership) => {
return (
@ -51,7 +51,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
</TBody>
</Table>
{!isLoading && !projectMemberships?.length && (
<EmptyState title="This identity has not been assigned to any projects" icon={faKey} />
<EmptyState title="This identity has not been assigned to any projects" icon={faFolder} />
)}
</TableContainer>
);

View File

@ -16,7 +16,7 @@ import {
useOrganization,
useSubscription
} from "@app/context";
import { useDeleteOrgMembership } from "@app/hooks/api";
import { useDeleteOrgMembership, useUpdateOrgMembership } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddOrgMemberModal } from "./AddOrgMemberModal";
@ -32,11 +32,13 @@ export const OrgMembersSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addMember",
"removeMember",
"deactivateMember",
"upgradePlan",
"setUpEmail"
] as const);
const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const isMoreUsersAllowed = subscription?.memberLimit
? subscription.membersUsed < subscription.memberLimit
@ -65,6 +67,29 @@ export const OrgMembersSection = () => {
handlePopUpOpen("addMember");
};
const onDeactivateMemberSubmit = async (orgMembershipId: string) => {
try {
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: false
});
createNotification({
text: "Successfully deactivated user in organization",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to deactivate user in organization",
type: "error"
});
}
handlePopUpClose("deactivateMember");
};
const onRemoveMemberSubmit = async (orgMembershipId: string) => {
try {
await deleteMutateAsync({
@ -128,6 +153,20 @@ export const OrgMembersSection = () => {
)
}
/>
<DeleteActionModal
isOpen={popUp.deactivateMember.isOpen}
title={`Are you sure want to deactivate member with username ${
(popUp?.deactivateMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deactivateMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeactivateMemberSubmit(
(popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
buttonText="Deactivate"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}

View File

@ -1,13 +1,18 @@
import { useCallback, useMemo, useState } from "react";
import { faMagnifyingGlass, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useRouter } from "next/router";
import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Select,
SelectItem,
@ -32,13 +37,13 @@ import {
useFetchServerStatus,
useGetOrgRoles,
useGetOrgUsers,
useUpdateOrgUserRole
useUpdateOrgMembership
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>,
popUpName: keyof UsePopUpState<["removeMember", "deactivateMember", "upgradePlan"]>,
data?: {
orgMembershipId?: string;
username?: string;
@ -49,6 +54,7 @@ type Props = {
};
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => {
const router = useRouter();
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { user } = useUser();
@ -63,14 +69,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
const { mutateAsync: addUserMutateAsync } = useAddUserToOrg();
const { mutateAsync: updateUserOrgRole } = useUpdateOrgUserRole();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?.id) return;
try {
// TODO: replace hardcoding default role
const isCustomRole = !["admin", "member"].includes(role);
const isCustomRole = !["admin", "member", "no-access"].includes(role);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
@ -79,7 +85,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
return;
}
await updateUserOrgRole({
await updateOrgMembership({
organizationId: currentOrg?.id,
membershipId,
role
@ -171,14 +177,18 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading &&
filterdUser?.map(
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status }) => {
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status, isActive }) => {
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{username}</Td>
<Tr
key={`org-membership-${orgMembershipId}`}
className="h-10 w-full cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
>
<Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td>
<Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
@ -186,7 +196,18 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
>
{(isAllowed) => (
<>
{status === "accepted" && (
{!isActive && (
<Button
isDisabled
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => {}}
>
Suspended
</Button>
)}
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
@ -207,7 +228,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
))}
</Select>
)}
{(status === "invited" || status === "verified") &&
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<Button
@ -226,34 +248,117 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
</Td>
<Td>
{userId !== u?.id && (
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<IconButton
onClick={() => {
if (currentOrg?.authEnforced) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", { orgMembershipId, username });
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</OrgPermissionCan>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
onClick={async (e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
if (!isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</Td>
</Tr>

View File

@ -0,0 +1,278 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useRouter } from "next/router";
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
UpgradePlanModal
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { withPermission } from "@app/hoc";
import {
useDeleteOrgMembership,
useGetOrgMembership,
useUpdateOrgMembership
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components";
export const UserPage = withPermission(
() => {
const router = useRouter();
const membershipId = router.query.membershipId as string;
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: membership } = useGetOrgMembership(orgId, membershipId);
const { mutateAsync: deleteOrgMembership } = useDeleteOrgMembership();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"removeMember",
"orgMembership",
"deactivateMember",
"upgradePlan"
] as const);
const onDeactivateMemberSubmit = async (orgMembershipId: string) => {
try {
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: false
});
createNotification({
text: "Successfully deactivated user in organization",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to deactivate user in organization",
type: "error"
});
}
handlePopUpClose("deactivateMember");
};
const onRemoveMemberSubmit = async (orgMembershipId: string) => {
try {
await deleteOrgMembership({
orgId,
membershipId: orgMembershipId
});
createNotification({
text: "Successfully removed user from org",
type: "success"
});
handlePopUpClose("removeMember");
router.push(`/org/${orgId}/members`);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
handlePopUpClose("removeMember");
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{membership && (
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
router.push(`/org/${orgId}/members`);
}}
className="mb-4"
>
Users
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName}`
: "-"}
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role
})
}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
membership.isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
onClick={async () => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
return;
}
if (!membership.isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId: membershipId,
username: membership.user.username
});
}}
disabled={!isAllowed}
>
{`${membership.isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId: membershipId,
username: membership.user.username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
<UserProjectsSection membershipId={membershipId} />
</div>
</div>
)}
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
title={`Are you sure want to remove member with username ${
(popUp?.removeMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveMemberSubmit(
(popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
/>
<DeleteActionModal
isOpen={popUp.deactivateMember.isOpen}
title={`Are you sure want to deactivate member with username ${
(popUp?.deactivateMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deactivateMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeactivateMemberSubmit(
(popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
buttonText="Deactivate"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
<UserOrgMembershipModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member }
);

View File

@ -0,0 +1,195 @@
import {
faCheck,
faCheckCircle,
faCircleXmark,
faCopy,
faPencil} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tooltip } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useUser
} from "@app/context";
import { useTimedReset } from "@app/hooks";
import {
useAddUserToOrg,
useFetchServerStatus,
useGetOrgMembership,
useGetOrgRoles
} from "@app/hooks/api";
import { OrgUser } from "@app/hooks/api/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
membershipId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["orgMembership"]>, data?: {}) => void;
};
export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => {
const [copyTextUsername, isCopyingUsername, setCopyTextUsername] = useTimedReset<string>({
initialState: "Copy username to clipboard"
});
const { user } = useUser();
const { currentOrg } = useOrganization();
const userId = user?.id || "";
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { data: serverDetails } = useFetchServerStatus();
const { data: membership } = useGetOrgMembership(orgId, membershipId);
const { mutateAsync: inviteUser, isLoading } = useAddUserToOrg();
const onResendInvite = async (email: string) => {
try {
const { data } = await inviteUser({
organizationId: orgId,
inviteeEmail: email
});
// setCompleteInviteLink(data?.completeInviteLink || "");
if (!data.completeInviteLink) {
createNotification({
text: `Successfully resent invite to ${email}`,
type: "success"
});
}
} catch (err) {
console.error(err);
createNotification({
text: `Failed to resend invite to ${email}`,
type: "error"
});
}
};
const getStatus = (m: OrgUser) => {
if (!m.isActive) {
return "Deactivated";
}
return m.status === "invited" ? "Invited" : "Active";
};
const roleName = roles?.find((r) => r.slug === membership?.role)?.name;
return membership ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3>
{userId !== membership.user.id && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (
<Tooltip content="Edit Membership">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
)}
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName}`
: "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Username</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{membership.user.username}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextUsername}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText("");
setCopyTextUsername("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingUsername ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Email</p>
<div className="flex items-center">
<p className="mr-2 text-sm text-mineshaft-300">{membership.user.email ?? "-"}</p>
<Tooltip
content={
membership.user.isEmailVerified
? "Email has been verified"
: "Email has not been verified"
}
>
<FontAwesomeIcon
size="sm"
icon={membership.user.isEmailVerified ? faCheckCircle : faCircleXmark}
/>
</Tooltip>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{roleName ?? "-"}</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">{getStatus(membership)}</p>
</div>
{membership.isActive &&
(membership.status === "invited" || membership.status === "verified") &&
membership.user.email &&
serverDetails?.emailConfigured && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
isLoading={isLoading}
onClick={() => {
onResendInvite(membership.user.email as string);
}}
>
Resend Invite
</Button>
);
}}
</OrgPermissionCan>
)}
</div>
</div>
) : (
<div />
);
};

View File

@ -0,0 +1,160 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
role: z.string()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["orgMembership"]>;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>, data?: {}) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["orgMembership"]>, state?: boolean) => void;
};
export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const popUpData = popUp?.orgMembership?.data as {
membershipId: string;
role: string;
};
useEffect(() => {
if (!roles?.length) return;
if (popUpData) {
reset({
role: popUpData.role
});
} else {
reset({
role: roles[0].slug
});
}
}, [popUp?.orgMembership?.data, roles]);
const onFormSubmit = async ({ role }: FormData) => {
try {
if (!orgId) return;
await updateOrgMembership({
organizationId: orgId,
membershipId: popUpData.membershipId,
role
});
handlePopUpToggle("orgMembership", false);
createNotification({
text: "Successfully updated user organization role",
type: "success"
});
reset();
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to update user organization role";
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.orgMembership?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("orgMembership", isOpen);
reset();
}}
>
<ModalContent title="Update Membership">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Update Organization Role"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
const isCustomRole = !["admin", "member", "no-access"].includes(e);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
description:
"You can assign custom roles to members if you upgrade your Infisical plan."
});
return;
}
onChange(e);
}}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Update
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("orgMembership", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@ -0,0 +1,145 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { useAddUserToWsNonE2EE, useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
projectId: z.string()
})
.required();
type FormData = z.infer<typeof schema>;
type Props = {
membershipId: string;
popUp: UsePopUpState<["addUserToProject"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["addUserToProject"]>,
state?: boolean
) => void;
};
export const UserAddToProjectModal = ({ membershipId, popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { workspaces } = useWorkspace();
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
const popupData = popUp.addUserToProject.data as {
username: string;
};
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const { data: projectMemberships } = useGetOrgMembershipProjectMemberships(orgId, membershipId);
const filteredWorkspaces = useMemo(() => {
const wsWorkspaceIds = new Map();
projectMemberships?.forEach((projectMembership) => {
wsWorkspaceIds.set(projectMembership.project.id, true);
});
return (workspaces || []).filter(
({ id, orgId: projectOrgId, version }) =>
!wsWorkspaceIds.has(id) && projectOrgId === currentOrg?.id && version === ProjectVersion.V2
);
}, [workspaces, projectMemberships]);
const onFormSubmit = async ({ projectId }: FormData) => {
try {
await addUserToWorkspaceNonE2EE({
projectId,
usernames: [popupData.username],
orgId
});
createNotification({
text: "Successfully added user to project",
type: "success"
});
reset();
handlePopUpToggle("addUserToProject", false);
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to add identity to project";
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addUserToProject?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addUserToProject", isOpen);
reset();
}}
>
<ModalContent title="Add User to Project">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="projectId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Project" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(filteredWorkspaces || []).map(({ id, name }) => (
<SelectItem value={id} key={`project-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("addUserToProject", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@ -0,0 +1,90 @@
import { useMemo } from "react";
import { useRouter } from "next/router";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
membership: TWorkspaceUser;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Admin) return "Admin";
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.Viewer) return "Viewer";
if (role === ProjectMembershipRole.NoAccess) return "No Access";
return role;
};
export const UserProjectRow = ({
membership: { id, project, user, roles },
handlePopUpOpen
}: Props) => {
const { workspaces } = useWorkspace();
const router = useRouter();
const isAccessible = useMemo(() => {
const workspaceIds = new Map();
workspaces?.forEach((workspace) => {
workspaceIds.set(workspace.id, true);
});
return workspaceIds.has(project.id);
}, [workspaces, project]);
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
key={`user-project-membership-${id}`}
onClick={() => {
if (isAccessible) {
router.push(`/project/${project.id}/members`);
return;
}
createNotification({
text: "Unable to access project",
type: "error"
});
}}
>
<Td>{project.name}</Td>
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td>
<Td>
{isAccessible && (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUserFromProject", {
username: user.username,
projectId: project.id,
projectName: project.name
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</Td>
</Tr>
);
};

View File

@ -0,0 +1,98 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { useOrganization,useUser } from "@app/context";
import { useDeleteUserFromWorkspace, useGetOrgMembership } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { UserAddToProjectModal } from "./UserAddToProjectModal";
import { UserProjectsTable } from "./UserProjectsTable";
type Props = {
membershipId: string;
};
export const UserProjectsSection = ({ membershipId }: Props) => {
const { user } = useUser();
const { currentOrg } = useOrganization();
const userId = user?.id || "";
const orgId = currentOrg?.id || "";
const { data: membership } = useGetOrgMembership(orgId, membershipId);
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addUserToProject",
"removeUserFromProject"
] as const);
const handleRemoveUser = async (projectId: string, username: string) => {
try {
await removeUserFromWorkspace({ workspaceId: projectId, usernames: [username], orgId });
createNotification({
text: "Successfully removed user from project",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the project",
type: "error"
});
}
handlePopUpClose("removeUserFromProject");
};
return membership ? (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Projects</h3>
{userId !== membership.user.id && (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addUserToProject", {
username: membership.user.username
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</div>
<div className="py-4">
<UserProjectsTable membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
<UserAddToProjectModal
membershipId={membershipId}
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.removeUserFromProject.isOpen}
deleteKey="remove"
title={`Do you want to remove this user from ${
(popUp?.removeUserFromProject?.data as { projectName: string })?.projectName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeUserFromProject", isOpen)}
onDeleteApproved={() => {
const popupData = popUp?.removeUserFromProject?.data as {
username: string;
projectId: string;
projectName: string;
};
return handleRemoveUser(popupData.projectId, popupData.username);
}}
/>
</div>
) : (
<div />
);
};

View File

@ -0,0 +1,62 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { UserProjectRow } from "./UserProjectRow";
type Props = {
membershipId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
};
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: projectMemberships, isLoading } = useGetOrgMembershipProjectMemberships(
orgId,
membershipId
);
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isLoading &&
projectMemberships?.map((membership) => {
return (
<UserProjectRow
key={`user-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{!isLoading && !projectMemberships?.length && (
<EmptyState title="This user has not been assigned to any projects" icon={faFolder} />
)}
</TableContainer>
);
};

View File

@ -0,0 +1 @@
export { UserProjectsSection } from "./UserProjectsSection";

View File

@ -0,0 +1,3 @@
export { UserDetailsSection } from "./UserDetailsSection";
export { UserOrgMembershipModal } from "./UserOrgMembershipModal";
export { UserProjectsSection } from "./UserProjectsSection";

View File

@ -0,0 +1 @@
export { UserPage } from "./UserPage";

View File

@ -6,14 +6,15 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button,FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useGetOrgUsers,
useGetUserWsKey,
useGetWorkspaceUsers} from "@app/hooks/api";
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@ -76,7 +77,8 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
} else if (currentWorkspace.version === ProjectVersion.V2) {
await addUserToWorkspaceNonE2EE({
projectId: workspaceId,
usernames: [orgUser.user.username]
usernames: [orgUser.user.username],
orgId
});
} else {
createNotification({

View File

@ -4,7 +4,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub , useOrganization, useWorkspace } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteUserFromWorkspace } from "@app/hooks/api";
@ -30,7 +35,11 @@ export const MembersSection = () => {
if (!currentWorkspace?.id) return;
try {
await removeUserFromWorkspace({ workspaceId: currentWorkspace.id, usernames: [username] });
await removeUserFromWorkspace({
workspaceId: currentWorkspace.id,
usernames: [username],
orgId: currentOrg.id
});
createNotification({
text: "Successfully removed user from project",
type: "success"