From 77e191d63e162ab3aafc84c677da96b57a47ce35 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 25 Sep 2024 12:32:18 +0530 Subject: [PATCH] feat: implemented ui and api for managing user,identity metadata --- backend/src/@types/knex.d.ts | 8 ++ .../20240924100329_identity-metadata.ts | 25 +++++ backend/src/db/schemas/identity-metadata.ts | 22 ++++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 2 + backend/src/lib/api-docs/constants.ts | 6 +- backend/src/server/routes/index.ts | 6 +- .../src/server/routes/v1/identity-router.ts | 20 +++- .../server/routes/v2/organization-router.ts | 39 ++++--- .../identity/identity-metadata-dal.ts | 10 ++ .../src/services/identity/identity-org-dal.ts | 86 +++++++++++---- .../src/services/identity/identity-service.ts | 38 ++++++- .../src/services/identity/identity-types.ts | 2 + .../org-membership/org-membership-dal.ts | 70 ++++++++++-- backend/src/services/org/org-service.ts | 41 +++++-- backend/src/services/org/org-types.ts | 1 + .../src/hooks/api/identities/mutations.tsx | 5 +- frontend/src/hooks/api/identities/types.ts | 3 + frontend/src/hooks/api/users/queries.tsx | 5 +- frontend/src/hooks/api/users/types.ts | 2 + .../components/IdentityDetailsSection.tsx | 35 +++++- .../IdentitySection/IdentityModal.tsx | 101 ++++++++++++++++-- .../components/UserDetailsSection.tsx | 34 +++++- .../components/UserOrgMembershipModal.tsx | 100 +++++++++++++++-- 24 files changed, 570 insertions(+), 92 deletions(-) create mode 100644 backend/src/db/migrations/20240924100329_identity-metadata.ts create mode 100644 backend/src/db/schemas/identity-metadata.ts create mode 100644 backend/src/services/identity/identity-metadata-dal.ts diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7cf86032f..624915276 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -101,6 +101,9 @@ import { TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate, + TIdentityMetadata, + TIdentityMetadataInsert, + TIdentityMetadataUpdate, TIdentityOidcAuths, TIdentityOidcAuthsInsert, TIdentityOidcAuthsUpdate, @@ -546,6 +549,11 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; + [TableName.IdentityMetadata]: KnexOriginal.CompositeTableType< + TIdentityMetadata, + TIdentityMetadataInsert, + TIdentityMetadataUpdate + >; [TableName.IdentityKubernetesAuth]: KnexOriginal.CompositeTableType< TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, diff --git a/backend/src/db/migrations/20240924100329_identity-metadata.ts b/backend/src/db/migrations/20240924100329_identity-metadata.ts new file mode 100644 index 000000000..3dce7b113 --- /dev/null +++ b/backend/src/db/migrations/20240924100329_identity-metadata.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityMetadata))) { + await knex.schema.createTable(TableName.IdentityMetadata, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("key").notNullable(); + tb.string("value").notNullable(); + tb.uuid("userOrgMembershipId"); + tb.foreign("userOrgMembershipId").references("id").inTable(TableName.OrgMembership).onDelete("CASCADE"); + tb.uuid("identityOrgMembershipId"); + tb.foreign("identityOrgMembershipId") + .references("id") + .inTable(TableName.IdentityOrgMembership) + .onDelete("CASCADE"); + tb.timestamps(true, true, true); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityMetadata); +} diff --git a/backend/src/db/schemas/identity-metadata.ts b/backend/src/db/schemas/identity-metadata.ts new file mode 100644 index 000000000..1adde49ec --- /dev/null +++ b/backend/src/db/schemas/identity-metadata.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityMetadataSchema = z.object({ + id: z.string().uuid(), + key: z.string(), + value: z.string(), + userOrgMembershipId: z.string().uuid().nullable().optional(), + identityOrgMembershipId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityMetadata = z.infer; +export type TIdentityMetadataInsert = Omit, TImmutableDBKeys>; +export type TIdentityMetadataUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index d856cab49..4fcf26c1a 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -31,6 +31,7 @@ export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; export * from "./identity-kubernetes-auths"; +export * from "./identity-metadata"; export * from "./identity-oidc-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 068ef74ad..08f3e79ce 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -70,6 +70,8 @@ export enum TableName { IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", + // used by both identity and users + IdentityMetadata = "identity_metadata", ScimToken = "scim_tokens", AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index cc189cb1c..75c3dc32a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -360,7 +360,11 @@ export const ORGANIZATIONS = { 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.", - isActive: "The active status of the membership" + isActive: "The active status of the membership", + metadata: { + key: "The key for user metadata tag.", + value: "The value for user metadata tag." + } }, DELETE_USER_MEMBERSHIP: { organizationId: "The ID of the organization to delete the membership from.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index eb2b00120..da6e25c3e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -101,6 +101,7 @@ import { groupProjectDALFactory } from "@app/services/group-project/group-projec import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { identityDALFactory } from "@app/services/identity/identity-dal"; +import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; @@ -265,6 +266,7 @@ export const registerRoutes = async ( const serviceTokenDAL = serviceTokenDALFactory(db); const identityDAL = identityDALFactory(db); + const identityMetadataDAL = identityMetadataDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); @@ -489,6 +491,7 @@ export const registerRoutes = async ( }); const orgService = orgServiceFactory({ userAliasDAL, + identityMetadataDAL, licenseService, samlConfigDAL, orgRoleDAL, @@ -1027,7 +1030,8 @@ export const registerRoutes = async ( identityDAL, identityOrgMembershipDAL, identityProjectDAL, - licenseService + licenseService, + identityMetadataDAL }); const identityAccessTokenService = identityAccessTokenServiceFactory({ diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index cf72ba2ff..163b560e2 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -29,7 +29,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().describe(IDENTITIES.CREATE.name), organizationId: z.string().trim().describe(IDENTITIES.CREATE.organizationId), - role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role) + role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role), + metadata: z + .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) + .array() + .optional() }), response: { 200: z.object({ @@ -93,7 +97,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { }), body: z.object({ name: z.string().trim().optional().describe(IDENTITIES.UPDATE.name), - role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role) + role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role), + metadata: z + .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) + .array() + .optional() }), response: { 200: z.object({ @@ -193,6 +201,14 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ identity: IdentityOrgMembershipsSchema.extend({ + metadata: z + .object({ + key: z.string().trim().min(1), + id: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional(), customRole: OrgRolesSchema.pick({ id: true, name: true, diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index d237bca22..5d34bc702 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -130,18 +130,24 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), 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 }) + membership: OrgMembershipsSchema.extend({ + metadata: z + .object({ + key: z.string().trim().min(1), + id: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional(), + user: UsersSchema.pick({ + username: true, + email: true, + isEmailVerified: true, + firstName: true, + lastName: true, + id: true + }).extend({ publicKey: z.string().nullable() }) + }).omit({ createdAt: true, updatedAt: true }) }) } }, @@ -178,7 +184,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), body: z.object({ role: z.string().trim().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role), - isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive) + isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive), + metadata: z + .object({ + key: z.string().trim().min(1).describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.metadata.key), + value: z.string().trim().min(1).describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.metadata.value) + }) + .array() + .optional() }), response: { 200: z.object({ diff --git a/backend/src/services/identity/identity-metadata-dal.ts b/backend/src/services/identity/identity-metadata-dal.ts new file mode 100644 index 000000000..535420cf3 --- /dev/null +++ b/backend/src/services/identity/identity-metadata-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityMetadataDALFactory = ReturnType; + +export const identityMetadataDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityMetadata); + return orm; +}; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index f309d3e29..fbf8cb57b 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName, TIdentityOrgMemberships } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; @@ -42,10 +42,25 @@ export const identityOrgDALFactory = (db: TDbClient) => { tx?: Knex ) => { try { + const paginatedFetchIdentity = (tx || db.replicaNode())(TableName.Identity) + .where((queryBuilder) => { + if (limit) { + void queryBuilder.offset(offset).limit(limit); + } + }) + .as(TableName.Identity); + const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) .where(filter) - .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) + .join>(paginatedFetchIdentity, (queryBuilder) => { + queryBuilder.on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`); + }) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .leftJoin( + TableName.IdentityMetadata, + `${TableName.IdentityMetadata}.identityOrgMembershipId`, + `${TableName.IdentityOrgMembership}.id` + ) .select(selectAllTableCols(TableName.IdentityOrgMembership)) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -55,12 +70,15 @@ export const identityOrgDALFactory = (db: TDbClient) => { .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) .select(db.ref("id").as("identityId").withSchema(TableName.Identity)) - .select(db.ref("name").as("identityName").withSchema(TableName.Identity)) - .select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); - - if (limit) { - void query.offset(offset).limit(limit); - } + .select( + db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity) + ) + .select( + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ); if (orderBy) { switch (orderBy) { @@ -80,9 +98,10 @@ export const identityOrgDALFactory = (db: TDbClient) => { } const docs = await query; - - return docs.map( - ({ + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: ({ crId, crDescription, crSlug, @@ -91,16 +110,21 @@ export const identityOrgDALFactory = (db: TDbClient) => { identityId, identityName, identityAuthMethod, - ...el + role, + roleId, + id, + orgId, + createdAt, + updatedAt }) => ({ - ...el, + role, + roleId, identityId, - identity: { - id: identityId, - name: identityName, - authMethod: identityAuthMethod - }, - customRole: el.roleId + id, + orgId, + createdAt, + updatedAt, + customRole: roleId ? { id: crId, name: crName, @@ -108,9 +132,27 @@ export const identityOrgDALFactory = (db: TDbClient) => { permissions: crPermission, description: crDescription } - : undefined - }) - ); + : undefined, + identity: { + id: identityId, + name: identityName, + authMethod: identityAuthMethod as string + } + }), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return formattedDocs; } catch (error) { throw new DatabaseError({ error, name: "FindByOrgId" }); } diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index b9cda4840..770eefb26 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -10,6 +10,7 @@ import { TIdentityProjectDALFactory } from "@app/services/identity-project/ident import { ActorType } from "../auth/auth-type"; import { TIdentityDALFactory } from "./identity-dal"; +import { TIdentityMetadataDALFactory } from "./identity-metadata-dal"; import { TIdentityOrgDALFactory } from "./identity-org-dal"; import { TCreateIdentityDTO, @@ -22,6 +23,7 @@ import { type TIdentityServiceFactoryDep = { identityDAL: TIdentityDALFactory; + identityMetadataDAL: TIdentityMetadataDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: Pick; permissionService: Pick; @@ -32,6 +34,7 @@ export type TIdentityServiceFactory = ReturnType; export const identityServiceFactory = ({ identityDAL, + identityMetadataDAL, identityOrgMembershipDAL, identityProjectDAL, permissionService, @@ -44,7 +47,8 @@ export const identityServiceFactory = ({ orgId, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + metadata }: TCreateIdentityDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); @@ -69,7 +73,7 @@ export const identityServiceFactory = ({ const identity = await identityDAL.transaction(async (tx) => { const newIdentity = await identityDAL.create({ name }, tx); - await identityOrgMembershipDAL.create( + const identityOrgMembership = await identityOrgMembershipDAL.create( { identityId: newIdentity.id, orgId, @@ -78,6 +82,16 @@ export const identityServiceFactory = ({ }, tx ); + if (metadata && metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + identityOrgMembershipId: identityOrgMembership.id, + key, + value + })), + tx + ); + } return newIdentity; }); await licenseService.updateSubscriptionOrgMemberCount(orgId); @@ -92,7 +106,8 @@ export const identityServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + metadata }: TUpdateIdentityDTO) => { const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); @@ -134,8 +149,8 @@ export const identityServiceFactory = ({ const identity = await identityDAL.transaction(async (tx) => { const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx); if (role) { - await identityOrgMembershipDAL.update( - { identityId: id }, + await identityOrgMembershipDAL.updateById( + identityOrgMembership.id, { role: customRole ? OrgMembershipRole.Custom : role, roleId: customRole?.id || null @@ -143,6 +158,19 @@ export const identityServiceFactory = ({ tx ); } + if (metadata) { + await identityMetadataDAL.delete({ identityOrgMembershipId: identityOrgMembership.id }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + identityOrgMembershipId: identityOrgMembership.id, + key, + value + })), + tx + ); + } + } return newIdentity; }); diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 8a5a81ba5..23110c3bd 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -4,12 +4,14 @@ import { OrderByDirection, TOrgPermission } from "@app/lib/types"; export type TCreateIdentityDTO = { role: string; name: string; + metadata?: { key: string; value: string }[]; } & TOrgPermission; export type TUpdateIdentityDTO = { id: string; role?: string; name?: string; + metadata?: { key: string; value: string }[]; } & Omit; export type TDeleteIdentityDTO = { diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index ccd995296..5f24ca628 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, sqlNestRelationships } from "@app/lib/knex"; export type TOrgMembershipDALFactory = ReturnType; @@ -19,6 +19,11 @@ export const orgMembershipDALFactory = (db: TDbClient) => { `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) + .leftJoin( + TableName.IdentityMetadata, + `${TableName.IdentityMetadata}.userOrgMembershipId`, + `${TableName.OrgMembership}.id` + ) .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("inviteEmail").withSchema(TableName.OrgMembership), @@ -33,19 +38,66 @@ export const orgMembershipDALFactory = (db: TDbClient) => { 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) + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") ) - .where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER - .first(); + .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER if (!member) return undefined; - const { email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data } = member; + const doc = sqlNestRelationships({ + data: member, + key: "id", + parentMapper: ({ + email, + isEmailVerified, + username, + firstName, + lastName, + userId, + publicKey, + roleId, + orgId, + id, + role, + status, + isActive, + inviteEmail + }) => ({ + roleId, + orgId, + id, + role, + status, + isActive, + inviteEmail, + user: { + id: userId, + email, + isEmailVerified, + username, + firstName, + lastName, + userId, + publicKey + } + }), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); - return { - ...data, - user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey } - }; + return doc?.[0]; } catch (error) { throw new DatabaseError({ error, name: "Find org membership by id" }); } diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index faa3adba6..09e5345a8 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -37,6 +37,7 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; @@ -72,6 +73,7 @@ type TOrgServiceFactoryDep = { userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; projectDAL: TProjectDALFactory; + identityMetadataDAL: Pick; projectMembershipDAL: Pick< TProjectMembershipDALFactory, "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" @@ -115,7 +117,8 @@ export const orgServiceFactory = ({ projectRoleDAL, samlConfigDAL, projectBotDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + identityMetadataDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -404,7 +407,8 @@ export const orgServiceFactory = ({ userId, membershipId, actorAuthMethod, - actorOrgId + actorOrgId, + metadata }: TUpdateOrgMembershipDTO) => { const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); @@ -418,6 +422,8 @@ export const orgServiceFactory = ({ throw new UnauthorizedError({ message: "Cannot update own organization membership" }); const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); + let userRole = role; + let userRoleId: string | null = null; if (role && isCustomRole) { const customRole = await orgRoleDAL.findOne({ slug: role, orgId }); if (!customRole) throw new BadRequestError({ name: "UpdateMembership", message: "Organization role not found" }); @@ -428,17 +434,30 @@ export const orgServiceFactory = ({ message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." }); - const [membership] = await orgDAL.updateMembership( - { id: membershipId, orgId }, - { - role: OrgMembershipRole.Custom, - roleId: customRole.id - } - ); - return membership; + userRole = OrgMembershipRole.Custom; + userRoleId = customRole.id; } + const membership = await orgDAL.transaction(async (tx) => { + const [updatedOrgMembership] = await orgDAL.updateMembership( + { id: membershipId, orgId }, + { role: userRole, roleId: userRoleId, isActive } + ); - const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null, isActive }); + if (metadata) { + await identityMetadataDAL.delete({ userOrgMembershipId: updatedOrgMembership.id }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + userOrgMembershipId: updatedOrgMembership.id, + key, + value + })), + tx + ); + } + } + return updatedOrgMembership; + }); return membership; }; /* diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index ef0a5606a..085226be0 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -9,6 +9,7 @@ export type TUpdateOrgMembershipDTO = { role?: string; isActive?: boolean; actorOrgId: string | undefined; + metadata?: { key: string; value: string }[]; actorAuthMethod: ActorAuthMethod; }; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index f4ee8bf9c..21c4c560e 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -67,12 +67,13 @@ export const useCreateIdentity = () => { export const useUpdateIdentity = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ identityId, name, role }) => { + mutationFn: async ({ identityId, name, role, metadata }) => { const { data: { identity } } = await apiRequest.patch(`/api/v1/identities/${identityId}`, { name, - role + role, + metadata }); return identity; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 697f0ed82..cdad71e9f 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -37,6 +37,7 @@ export type IdentityMembershipOrg = { id: string; identity: Identity; organization: string; + metadata: { key: string; value: string; id: string }[]; role: "admin" | "member" | "viewer" | "no-access" | "custom"; customRole?: TOrgRole; createdAt: string; @@ -79,6 +80,7 @@ export type CreateIdentityDTO = { name: string; organizationId: string; role?: string; + metadata?: { key: string; value: string }[]; }; export type UpdateIdentityDTO = { @@ -86,6 +88,7 @@ export type UpdateIdentityDTO = { name?: string; role?: string; organizationId: string; + metadata?: { key: string; value: string }[]; }; export type DeleteIdentityDTO = { diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index fc5243510..116b98d4f 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -235,12 +235,13 @@ export const useUpdateOrgMembership = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, UpdateOrgMembershipDTO>({ - mutationFn: ({ organizationId, membershipId, role, isActive }) => { + mutationFn: ({ organizationId, membershipId, role, isActive, metadata }) => { return apiRequest.patch( `/api/v2/organizations/${organizationId}/memberships/${membershipId}`, { role, - isActive + isActive, + metadata } ); }, diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index c96e3d735..86686a1a0 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -46,6 +46,7 @@ export type UserEnc = { export type OrgUser = { id: string; + metadata: { key: string; value: string; id: string }[]; user: { username: string; email?: string; @@ -142,6 +143,7 @@ export type UpdateOrgMembershipDTO = { membershipId: string; role?: string; isActive?: boolean; + metadata?: { key: string; value: string }[]; }; export type DeletOrgMembershipDTO = { diff --git a/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx b/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx index 223f4c9a0..c9cba17c6 100644 --- a/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx +++ b/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx @@ -1,8 +1,8 @@ -import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faCopy, faKey, faPencil } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OrgPermissionCan } from "@app/components/permissions"; -import { IconButton, Tooltip } from "@app/components/v2"; +import { IconButton, Tag, Tooltip } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useTimedReset } from "@app/hooks"; import { useGetIdentityById } from "@app/hooks/api"; @@ -40,7 +40,8 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) = identityId, name: data.identity.name, role: data.role, - customRole: data.customRole + customRole: data.customRole, + metadata: data.metadata }); }} > @@ -77,10 +78,36 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =

Name

{data.identity.name}

-
+

Organization Role

{data.role}

+
+

Metadata

+ {data?.metadata?.length ? ( +
+ {data.metadata?.map((el) => ( +
+ + +
{el.key}
+
+ +
{el.value}
+
+
+ ))} +
+ ) : ( +

-

+ )} +
) : ( diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index e5a1f8a02..5663c26d5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -1,13 +1,17 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; import { useRouter } from "next/router"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, + FormLabel, + IconButton, Input, Modal, ModalContent, @@ -22,14 +26,21 @@ import { } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const schema = yup +const schema = z .object({ - name: yup.string().required("MI name is required"), - role: yup.string() + name: z.string(), + role: z.string(), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional() }) .required(); -export type FormData = yup.InferType; +export type FormData = z.infer; type Props = { popUp: UsePopUpState<["identity"]>; @@ -61,12 +72,17 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { reset, formState: { isSubmitting } } = useForm({ - resolver: yupResolver(schema), + resolver: zodResolver(schema), defaultValues: { name: "" } }); + const metadataFormFields = useFieldArray({ + control, + name: "metadata" + }); + useEffect(() => { const identity = popUp?.identity?.data as { identityId: string; @@ -93,7 +109,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { } }, [popUp?.identity?.data, roles]); - const onFormSubmit = async ({ name, role }: FormData) => { + const onFormSubmit = async ({ name, role, metadata }: FormData) => { try { const identity = popUp?.identity?.data as { identityId: string; @@ -108,7 +124,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { identityId: identity.identityId, name, role: role || undefined, - organizationId: orgId + organizationId: orgId, + metadata }); handlePopUpToggle("identity", false); @@ -118,7 +135,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const { id: createdId } = await createMutateAsync({ name, role: role || undefined, - organizationId: orgId + organizationId: orgId, + metadata }); await addMutateAsync({ @@ -207,6 +225,67 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> +
+ +
+
+ {metadataFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ {i === 0 && Key} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ metadataFormFields.remove(i)} + > + + +
+ ))} +
+ +
+
-
+

Status

{getStatus(membership)}

+
+

Metadata

+ {membership?.metadata?.length ? ( +
+ {membership.metadata?.map((el) => ( +
+ + +
{el.key}
+
+ +
{el.value}
+
+
+ ))} +
+ ) : ( +

-

+ )} +
{membership.isActive && (membership.status === "invited" || membership.status === "verified") && membership.user.email && diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx index ce3db38d7..57c8cebb2 100644 --- a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -1,16 +1,35 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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, + FormLabel, + IconButton, + Input, + 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() + role: z.string(), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional() }); export type FormData = z.infer; @@ -39,9 +58,15 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg resolver: zodResolver(schema) }); + const metadataFormFields = useFieldArray({ + control, + name: "metadata" + }); + const popUpData = popUp?.orgMembership?.data as { membershipId: string; role: string; + metadata: { key: string; value: string }[]; }; useEffect(() => { @@ -49,7 +74,8 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg if (popUpData) { reset({ - role: popUpData.role + role: popUpData.role, + metadata: popUpData.metadata }); } else { reset({ @@ -58,14 +84,15 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg } }, [popUp?.orgMembership?.data, roles]); - const onFormSubmit = async ({ role }: FormData) => { + const onFormSubmit = async ({ role, metadata }: FormData) => { try { if (!orgId) return; await updateOrgMembership({ organizationId: orgId, membershipId: popUpData.membershipId, - role + role, + metadata }); handlePopUpToggle("orgMembership", false); @@ -135,6 +162,67 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg )} /> +
+ +
+
+ {metadataFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ {i === 0 && Key} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ metadataFormFields.remove(i)} + > + + +
+ ))} +
+ +
+