Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

791 #1575

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions backend/src/db/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from "./incident-contacts";
export * from "./integration-auths";
export * from "./integrations";
export * from "./ldap-configs";
export * from "./members";
export * from "./models";
export * from "./org-bots";
export * from "./org-memberships";
Expand Down
20 changes: 20 additions & 0 deletions backend/src/db/schemas/members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from "zod";

export const MemberSchema = z.object({
id: z.string(),
inviteEmail: z.string().nullable(),
orgId: z.string(),
role: z.string(),
roleId: z.string().nullable(),
status: z.string(),
projects: z.array(z.string()).nullable(),
email: z.string(),
firstName: z.string(),
lastName: z.string(),
userId: z.string(),
publicKey: z.string()
});

export type MemberProps = z.infer<typeof MemberSchema>;

export type MembersProp = MemberProps[];
38 changes: 38 additions & 0 deletions backend/src/server/routes/v1/organization-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,44 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}
});

server.route({
method: "GET",
url: "/:organizationId/users/projects",
schema: {
params: z.object({
organizationId: z.string().trim()
}),
response: {
200: z.object({
users: OrgMembershipsSchema.merge(
z.object({
projects: z.array(z.string()),
user: UsersSchema.pick({
email: true,
firstName: true,
lastName: true,
id: true
}).merge(z.object({ publicKey: z.string().nullable() }))
})
)
.omit({ createdAt: true, updatedAt: true })
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const users = await server.services.org.findAllOrgMembersWithProjects(
req.permission.id,
req.params.organizationId,
req.permission.authMethod,
req.permission.orgId
);

return { users };
}
});

server.route({
method: "PATCH",
url: "/:organizationId",
Expand Down
58 changes: 58 additions & 0 deletions backend/src/server/routes/v2/project-membership-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,64 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
}
});

server.route({
method: "POST",
url: "/user/:email/memberships",
schema: {
params: z.object({
email: z.string().describe("The email of the user.")
}),
body: z.object({
projects: z.string().array().describe("Projects to register a user.")
}),
response: {
200: z.object({
memberships: ProjectMembershipsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const userEmail = req.params.email;
const { projects } = req.body;

const promises = projects.map(async (projectId) => {
const membershipsPromise = server.services.projectMembership.addUsersToProjectNonE2EE({
projectId,
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
emails: [userEmail],
usernames: []
});

return membershipsPromise.then(async (memberships) => {
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.ADD_BATCH_WORKSPACE_MEMBER,
metadata: memberships.map(({ userId, id }) => ({
userId: userId || "",
membershipId: id,
email: userEmail
}))
}
});

return memberships;
});
});

const allMemberships = await Promise.all(promises);

const flattenedMemberships = allMemberships.flat();

return { memberships: flattenedMemberships };
}
});

server.route({
method: "DELETE",
url: "/:projectId/memberships",
Expand Down
52 changes: 52 additions & 0 deletions backend/src/services/org/org-dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Knex } from "knex";

import { TDbClient } from "@app/db";
import {
MembersProp,
TableName,
TOrganizations,
TOrganizationsInsert,
Expand Down Expand Up @@ -89,6 +90,56 @@ export const orgDALFactory = (db: TDbClient) => {
}
};

const findAllOrgMembersWithProjects = async (orgId: string) => {
try {
const members: MembersProp = await db(TableName.OrgMembership)
.where(`${TableName.OrgMembership}.orgId`, orgId)
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.ProjectMembership, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.leftJoin(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("email").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
db.raw("json_agg(??) FILTER (WHERE ?? IS NOT NULL) AS ??", [
`${TableName.Project}.name`,
`${TableName.Project}.name`,
"projects"
])
)
.where({ isGhost: false })
.groupBy(
`${TableName.Users}.id`,
`${TableName.OrgMembership}.role`,
`${TableName.OrgMembership}.id`,
`${TableName.Users}.email`,
`${TableName.OrgMembership}.userId`,
`${TableName.UserEncryptionKey}.publicKey`
);

const users = members.map(({ email, firstName, lastName, userId, publicKey, projects, ...data }) => {
return {
...data,
projects: projects || [],
user: { email, firstName, lastName, id: userId, publicKey }
};
});

return users;
} catch (error) {
throw new DatabaseError({ error, name: "Find all org members" });
}
};

const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => {
try {
const members = await db(TableName.OrgMembership)
Expand Down Expand Up @@ -273,6 +324,7 @@ export const orgDALFactory = (db: TDbClient) => {
findAllOrgsByUserId,
ghostUserExists,
findOrgMembersByUsername,
findAllOrgMembersWithProjects,
findOrgGhostUser,
create,
updateById,
Expand Down
18 changes: 18 additions & 0 deletions backend/src/services/org/org-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ export const orgServiceFactory = ({
return members;
};

/*
* Get all workspace members with projects
* */
const findAllOrgMembersWithProjects = async (
userId: string,
orgId: string,
actorAuthMethod: ActorAuthMethod,
actorOrgId: string | undefined
) => {
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);

const members = await orgDAL.findAllOrgMembersWithProjects(orgId);

return members;
};

const findOrgMembersByUsername = async ({
actor,
actorId,
Expand Down Expand Up @@ -660,6 +677,7 @@ export const orgServiceFactory = ({
return {
findOrganizationById,
findAllOrgMembers,
findAllOrgMembersWithProjects,
findAllOrganizationOfUser,
inviteUserToOrganization,
verifyUserToOrg,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const AddProjectMemberDialog = ({
) : (
<Dialog.Title
as="h3"
className="z-50 text-lg font-medium text-mineshaft-300 mb-4"
className="z-50 mb-4 text-lg font-medium text-mineshaft-300"
>
{t("section.members.add-dialog.already-all-invited")}
</Dialog.Title>
Expand Down Expand Up @@ -127,7 +127,9 @@ const AddProjectMemberDialog = ({
</div>
) : (
<Button
onButtonPressed={() => router.push(`/org/${localStorage.getItem("orgData.id")}/members`)}
onButtonPressed={() =>
router.push(`/org/${localStorage.getItem("orgData.id")}/members`)
}
color="mineshaft"
text={t("section.members.add-dialog.add-user-to-org") as string}
size="md"
Expand Down
75 changes: 31 additions & 44 deletions frontend/src/components/basic/dialog/AddUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,76 +13,63 @@ type Props = {
orgName: string;
};

const AddUserDialog = ({
isOpen,
closeModal,
submitModal,
email,
setEmail,
orgName,
}: Props) => {
const AddUserDialog = ({ isOpen, closeModal, submitModal, email, setEmail, orgName }: Props) => {
const submit = () => {
submitModal(email);
};

return (
<div className='z-50'>
<div className="z-50">
<Transition appear show={isOpen} as={Fragment}>
<Dialog as='div' className='relative' onClose={closeModal}>
<Dialog as="div" className="relative" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-200'
leaveFrom='opacity-100'
leaveTo='opacity-0'
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className='fixed inset-0 bg-black bg-opacity-70' />
<div className="fixed inset-0 bg-black bg-opacity-70" />
</Transition.Child>

<div className='fixed inset-0 overflow-y-auto'>
<div className='flex min-h-full items-center justify-center p-4 text-center'>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
leave='ease-in duration-200'
leaveFrom='opacity-100 scale-100'
leaveTo='opacity-0 scale-95'
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className='w-full max-w-lg transform overflow-hidden rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-md border border-gray-700 bg-bunker-800 p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as='h3'
className='text-lg font-medium leading-6 text-gray-400 z-50'
as="h3"
className="z-50 text-lg font-medium leading-6 text-gray-400"
>
Invite others to {orgName}
</Dialog.Title>
<div className='mt-2 mb-4'>
<p className='text-sm text-gray-500'>
An invite is specific to an email address and expires
after 1 day. For security reasons, you will need to
separately add members to projects.
<div className="mt-2 mb-4">
<p className="text-sm text-gray-500">
An invite is specific to an email address and expires after 1 day. For
security reasons, you will need to separately add members to projects.
</p>
</div>
<div className='max-h-28'>
<div className="max-h-28">
<InputField
label='Email'
label="Email"
onChangeHandler={setEmail}
type='varName'
type="varName"
value={email}
placeholder=''
placeholder=""
isRequired
/>
</div>
<div className='mt-4 max-w-max'>
<Button
onButtonPressed={submit}
color='mineshaft'
text='Invite'
size='md'
/>
<div className="mt-4 max-w-max">
<Button onButtonPressed={submit} color="mineshaft" text="Invite" size="md" />
</div>
</Dialog.Panel>
{/* <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all">
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/v2/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,23 @@ export const Checkbox = ({
<div className="flex items-center font-inter text-bunker-300">
<CheckboxPrimitive.Root
className={twMerge(
"flex items-center flex-shrink-0 justify-center w-4 h-4 transition-all rounded shadow border border-mineshaft-400 hover:bg-mineshaft-500 bg-mineshaft-600",
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-mineshaft-400 bg-mineshaft-600 shadow transition-all hover:bg-mineshaft-500",
isDisabled && "bg-bunker-400 hover:bg-bunker-400",
isChecked && "bg-primary hover:bg-primary",
Boolean(children) && "mr-3",
className
)}
required={isRequired}
checked={isChecked}
disabled={isDisabled}
{...props}
checked={isChecked}
id={id}
>
<CheckboxPrimitive.Indicator className={`${checkIndicatorBg || "text-bunker-800"}`}>
<FontAwesomeIcon icon={faCheck} size="sm" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<label className="text-sm whitespace-nowrap truncate" htmlFor={id}>
<label className="truncate whitespace-nowrap text-sm" htmlFor={id}>
{children}
{isRequired && <span className="pl-1 text-red">*</span>}
</label>
Expand Down
Loading
Loading