feat: implemented ui and api for managing user,identity metadata

This commit is contained in:
=
2024-10-02 00:39:43 +05:30
parent 15c98a1d2e
commit 77e191d63e
24 changed files with 570 additions and 92 deletions
+8
View File
@@ -101,6 +101,9 @@ import {
TIdentityKubernetesAuths, TIdentityKubernetesAuths,
TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsInsert,
TIdentityKubernetesAuthsUpdate, TIdentityKubernetesAuthsUpdate,
TIdentityMetadata,
TIdentityMetadataInsert,
TIdentityMetadataUpdate,
TIdentityOidcAuths, TIdentityOidcAuths,
TIdentityOidcAuthsInsert, TIdentityOidcAuthsInsert,
TIdentityOidcAuthsUpdate, TIdentityOidcAuthsUpdate,
@@ -546,6 +549,11 @@ declare module "knex/types/tables" {
TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsInsert,
TIdentityUniversalAuthsUpdate TIdentityUniversalAuthsUpdate
>; >;
[TableName.IdentityMetadata]: KnexOriginal.CompositeTableType<
TIdentityMetadata,
TIdentityMetadataInsert,
TIdentityMetadataUpdate
>;
[TableName.IdentityKubernetesAuth]: KnexOriginal.CompositeTableType< [TableName.IdentityKubernetesAuth]: KnexOriginal.CompositeTableType<
TIdentityKubernetesAuths, TIdentityKubernetesAuths,
TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsInsert,
@@ -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.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<void> {
await knex.schema.dropTableIfExists(TableName.IdentityMetadata);
}
@@ -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<typeof IdentityMetadataSchema>;
export type TIdentityMetadataInsert = Omit<z.input<typeof IdentityMetadataSchema>, TImmutableDBKeys>;
export type TIdentityMetadataUpdate = Partial<Omit<z.input<typeof IdentityMetadataSchema>, TImmutableDBKeys>>;
+1
View File
@@ -31,6 +31,7 @@ export * from "./identity-aws-auths";
export * from "./identity-azure-auths"; export * from "./identity-azure-auths";
export * from "./identity-gcp-auths"; export * from "./identity-gcp-auths";
export * from "./identity-kubernetes-auths"; export * from "./identity-kubernetes-auths";
export * from "./identity-metadata";
export * from "./identity-oidc-auths"; export * from "./identity-oidc-auths";
export * from "./identity-org-memberships"; export * from "./identity-org-memberships";
export * from "./identity-project-additional-privilege"; export * from "./identity-project-additional-privilege";
+2
View File
@@ -70,6 +70,8 @@ export enum TableName {
IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembership = "identity_project_memberships",
IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectMembershipRole = "identity_project_membership_role",
IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege",
// used by both identity and users
IdentityMetadata = "identity_metadata",
ScimToken = "scim_tokens", ScimToken = "scim_tokens",
AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicy = "access_approval_policies",
AccessApprovalPolicyApprover = "access_approval_policies_approvers", AccessApprovalPolicyApprover = "access_approval_policies_approvers",
+5 -1
View File
@@ -360,7 +360,11 @@ export const ORGANIZATIONS = {
organizationId: "The ID of the organization to update the membership for.", organizationId: "The ID of the organization to update the membership for.",
membershipId: "The ID of the membership to update.", 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" 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: { DELETE_USER_MEMBERSHIP: {
organizationId: "The ID of the organization to delete the membership from.", organizationId: "The ID of the organization to delete the membership from.",
+5 -1
View File
@@ -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 { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal";
import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service";
import { identityDALFactory } from "@app/services/identity/identity-dal"; 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 { identityOrgDALFactory } from "@app/services/identity/identity-org-dal";
import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityServiceFactory } from "@app/services/identity/identity-service";
import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; 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 serviceTokenDAL = serviceTokenDALFactory(db);
const identityDAL = identityDALFactory(db); const identityDAL = identityDALFactory(db);
const identityMetadataDAL = identityMetadataDALFactory(db);
const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db);
const identityProjectDAL = identityProjectDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db);
@@ -489,6 +491,7 @@ export const registerRoutes = async (
}); });
const orgService = orgServiceFactory({ const orgService = orgServiceFactory({
userAliasDAL, userAliasDAL,
identityMetadataDAL,
licenseService, licenseService,
samlConfigDAL, samlConfigDAL,
orgRoleDAL, orgRoleDAL,
@@ -1027,7 +1030,8 @@ export const registerRoutes = async (
identityDAL, identityDAL,
identityOrgMembershipDAL, identityOrgMembershipDAL,
identityProjectDAL, identityProjectDAL,
licenseService licenseService,
identityMetadataDAL
}); });
const identityAccessTokenService = identityAccessTokenServiceFactory({ const identityAccessTokenService = identityAccessTokenServiceFactory({
@@ -29,7 +29,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
body: z.object({ body: z.object({
name: z.string().trim().describe(IDENTITIES.CREATE.name), name: z.string().trim().describe(IDENTITIES.CREATE.name),
organizationId: z.string().trim().describe(IDENTITIES.CREATE.organizationId), 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: { response: {
200: z.object({ 200: z.object({
@@ -93,7 +97,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
}), }),
body: z.object({ body: z.object({
name: z.string().trim().optional().describe(IDENTITIES.UPDATE.name), 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: { response: {
200: z.object({ 200: z.object({
@@ -193,6 +201,14 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
identity: IdentityOrgMembershipsSchema.extend({ 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({ customRole: OrgRolesSchema.pick({
id: true, id: true,
name: true, name: true,
@@ -130,18 +130,24 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
membership: OrgMembershipsSchema.merge( membership: OrgMembershipsSchema.extend({
z.object({ metadata: z
user: UsersSchema.pick({ .object({
username: true, key: z.string().trim().min(1),
email: true, id: z.string().trim().min(1),
isEmailVerified: true, value: z.string().trim().min(1)
firstName: true, })
lastName: true, .array()
id: true .optional(),
}).merge(z.object({ publicKey: z.string().nullable() })) user: UsersSchema.pick({
}) username: true,
).omit({ createdAt: true, updatedAt: 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({ body: z.object({
role: z.string().trim().optional().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) 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: { response: {
200: z.object({ 200: z.object({
@@ -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<typeof identityMetadataDALFactory>;
export const identityMetadataDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.IdentityMetadata);
return orm;
};
@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TIdentityOrgMemberships } from "@app/db/schemas"; import { TableName, TIdentityOrgMemberships } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; 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 { OrderByDirection } from "@app/lib/types";
import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types";
@@ -42,10 +42,25 @@ export const identityOrgDALFactory = (db: TDbClient) => {
tx?: Knex tx?: Knex
) => { ) => {
try { 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) const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter) .where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) .join<Awaited<typeof paginatedFetchIdentity>>(paginatedFetchIdentity, (queryBuilder) => {
queryBuilder.on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`);
})
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.leftJoin(
TableName.IdentityMetadata,
`${TableName.IdentityMetadata}.identityOrgMembershipId`,
`${TableName.IdentityOrgMembership}.id`
)
.select(selectAllTableCols(TableName.IdentityOrgMembership)) .select(selectAllTableCols(TableName.IdentityOrgMembership))
// cr stands for custom role // cr stands for custom role
.select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) .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("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("id").as("identityId").withSchema(TableName.Identity))
.select(db.ref("name").as("identityName").withSchema(TableName.Identity)) .select(
.select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); db.ref("name").as("identityName").withSchema(TableName.Identity),
db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)
if (limit) { )
void query.offset(offset).limit(limit); .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) { if (orderBy) {
switch (orderBy) { switch (orderBy) {
@@ -80,9 +98,10 @@ export const identityOrgDALFactory = (db: TDbClient) => {
} }
const docs = await query; const docs = await query;
const formattedDocs = sqlNestRelationships({
return docs.map( data: docs,
({ key: "id",
parentMapper: ({
crId, crId,
crDescription, crDescription,
crSlug, crSlug,
@@ -91,16 +110,21 @@ export const identityOrgDALFactory = (db: TDbClient) => {
identityId, identityId,
identityName, identityName,
identityAuthMethod, identityAuthMethod,
...el role,
roleId,
id,
orgId,
createdAt,
updatedAt
}) => ({ }) => ({
...el, role,
roleId,
identityId, identityId,
identity: { id,
id: identityId, orgId,
name: identityName, createdAt,
authMethod: identityAuthMethod updatedAt,
}, customRole: roleId
customRole: el.roleId
? { ? {
id: crId, id: crId,
name: crName, name: crName,
@@ -108,9 +132,27 @@ export const identityOrgDALFactory = (db: TDbClient) => {
permissions: crPermission, permissions: crPermission,
description: crDescription 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) { } catch (error) {
throw new DatabaseError({ error, name: "FindByOrgId" }); throw new DatabaseError({ error, name: "FindByOrgId" });
} }
@@ -10,6 +10,7 @@ import { TIdentityProjectDALFactory } from "@app/services/identity-project/ident
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TIdentityDALFactory } from "./identity-dal"; import { TIdentityDALFactory } from "./identity-dal";
import { TIdentityMetadataDALFactory } from "./identity-metadata-dal";
import { TIdentityOrgDALFactory } from "./identity-org-dal"; import { TIdentityOrgDALFactory } from "./identity-org-dal";
import { import {
TCreateIdentityDTO, TCreateIdentityDTO,
@@ -22,6 +23,7 @@ import {
type TIdentityServiceFactoryDep = { type TIdentityServiceFactoryDep = {
identityDAL: TIdentityDALFactory; identityDAL: TIdentityDALFactory;
identityMetadataDAL: TIdentityMetadataDALFactory;
identityOrgMembershipDAL: TIdentityOrgDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory;
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">; identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
@@ -32,6 +34,7 @@ export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
export const identityServiceFactory = ({ export const identityServiceFactory = ({
identityDAL, identityDAL,
identityMetadataDAL,
identityOrgMembershipDAL, identityOrgMembershipDAL,
identityProjectDAL, identityProjectDAL,
permissionService, permissionService,
@@ -44,7 +47,8 @@ export const identityServiceFactory = ({
orgId, orgId,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId,
metadata
}: TCreateIdentityDTO) => { }: TCreateIdentityDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
@@ -69,7 +73,7 @@ export const identityServiceFactory = ({
const identity = await identityDAL.transaction(async (tx) => { const identity = await identityDAL.transaction(async (tx) => {
const newIdentity = await identityDAL.create({ name }, tx); const newIdentity = await identityDAL.create({ name }, tx);
await identityOrgMembershipDAL.create( const identityOrgMembership = await identityOrgMembershipDAL.create(
{ {
identityId: newIdentity.id, identityId: newIdentity.id,
orgId, orgId,
@@ -78,6 +82,16 @@ export const identityServiceFactory = ({
}, },
tx tx
); );
if (metadata && metadata.length) {
await identityMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
identityOrgMembershipId: identityOrgMembership.id,
key,
value
})),
tx
);
}
return newIdentity; return newIdentity;
}); });
await licenseService.updateSubscriptionOrgMemberCount(orgId); await licenseService.updateSubscriptionOrgMemberCount(orgId);
@@ -92,7 +106,8 @@ export const identityServiceFactory = ({
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId,
metadata
}: TUpdateIdentityDTO) => { }: TUpdateIdentityDTO) => {
const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id });
if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${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 identity = await identityDAL.transaction(async (tx) => {
const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx); const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx);
if (role) { if (role) {
await identityOrgMembershipDAL.update( await identityOrgMembershipDAL.updateById(
{ identityId: id }, identityOrgMembership.id,
{ {
role: customRole ? OrgMembershipRole.Custom : role, role: customRole ? OrgMembershipRole.Custom : role,
roleId: customRole?.id || null roleId: customRole?.id || null
@@ -143,6 +158,19 @@ export const identityServiceFactory = ({
tx 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; return newIdentity;
}); });
@@ -4,12 +4,14 @@ import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export type TCreateIdentityDTO = { export type TCreateIdentityDTO = {
role: string; role: string;
name: string; name: string;
metadata?: { key: string; value: string }[];
} & TOrgPermission; } & TOrgPermission;
export type TUpdateIdentityDTO = { export type TUpdateIdentityDTO = {
id: string; id: string;
role?: string; role?: string;
name?: string; name?: string;
metadata?: { key: string; value: string }[];
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
export type TDeleteIdentityDTO = { export type TDeleteIdentityDTO = {
@@ -1,7 +1,7 @@
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify, sqlNestRelationships } from "@app/lib/knex";
export type TOrgMembershipDALFactory = ReturnType<typeof orgMembershipDALFactory>; export type TOrgMembershipDALFactory = ReturnType<typeof orgMembershipDALFactory>;
@@ -19,6 +19,11 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
`${TableName.UserEncryptionKey}.userId`, `${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id` `${TableName.Users}.id`
) )
.leftJoin(
TableName.IdentityMetadata,
`${TableName.IdentityMetadata}.userOrgMembershipId`,
`${TableName.OrgMembership}.id`
)
.select( .select(
db.ref("id").withSchema(TableName.OrgMembership), db.ref("id").withSchema(TableName.OrgMembership),
db.ref("inviteEmail").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("lastName").withSchema(TableName.Users),
db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("isEmailVerified").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"), 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 .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER
.first();
if (!member) return undefined; 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 { return doc?.[0];
...data,
user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey }
};
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find org membership by id" }); throw new DatabaseError({ error, name: "Find org membership by id" });
} }
+30 -11
View File
@@ -37,6 +37,7 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types"; import { TokenType } from "../auth-token/auth-token-types";
import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { assignWorkspaceKeysToMembers } from "../project/project-fns";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
@@ -72,6 +73,7 @@ type TOrgServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
groupDAL: TGroupDALFactory; groupDAL: TGroupDALFactory;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
projectMembershipDAL: Pick< projectMembershipDAL: Pick<
TProjectMembershipDALFactory, TProjectMembershipDALFactory,
"findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction"
@@ -115,7 +117,8 @@ export const orgServiceFactory = ({
projectRoleDAL, projectRoleDAL,
samlConfigDAL, samlConfigDAL,
projectBotDAL, projectBotDAL,
projectUserMembershipRoleDAL projectUserMembershipRoleDAL,
identityMetadataDAL
}: TOrgServiceFactoryDep) => { }: TOrgServiceFactoryDep) => {
/* /*
* Get organization details by the organization id * Get organization details by the organization id
@@ -404,7 +407,8 @@ export const orgServiceFactory = ({
userId, userId,
membershipId, membershipId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId,
metadata
}: TUpdateOrgMembershipDTO) => { }: TUpdateOrgMembershipDTO) => {
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member);
@@ -418,6 +422,8 @@ export const orgServiceFactory = ({
throw new UnauthorizedError({ message: "Cannot update own organization membership" }); throw new UnauthorizedError({ message: "Cannot update own organization membership" });
const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole);
let userRole = role;
let userRoleId: string | null = null;
if (role && isCustomRole) { if (role && isCustomRole) {
const customRole = await orgRoleDAL.findOne({ slug: role, orgId }); const customRole = await orgRoleDAL.findOne({ slug: role, orgId });
if (!customRole) throw new BadRequestError({ name: "UpdateMembership", message: "Organization role not found" }); 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." message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member."
}); });
const [membership] = await orgDAL.updateMembership( userRole = OrgMembershipRole.Custom;
{ id: membershipId, orgId }, userRoleId = customRole.id;
{
role: OrgMembershipRole.Custom,
roleId: customRole.id
}
);
return membership;
} }
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; return membership;
}; };
/* /*
+1
View File
@@ -9,6 +9,7 @@ export type TUpdateOrgMembershipDTO = {
role?: string; role?: string;
isActive?: boolean; isActive?: boolean;
actorOrgId: string | undefined; actorOrgId: string | undefined;
metadata?: { key: string; value: string }[];
actorAuthMethod: ActorAuthMethod; actorAuthMethod: ActorAuthMethod;
}; };
@@ -67,12 +67,13 @@ export const useCreateIdentity = () => {
export const useUpdateIdentity = () => { export const useUpdateIdentity = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<Identity, {}, UpdateIdentityDTO>({ return useMutation<Identity, {}, UpdateIdentityDTO>({
mutationFn: async ({ identityId, name, role }) => { mutationFn: async ({ identityId, name, role, metadata }) => {
const { const {
data: { identity } data: { identity }
} = await apiRequest.patch(`/api/v1/identities/${identityId}`, { } = await apiRequest.patch(`/api/v1/identities/${identityId}`, {
name, name,
role role,
metadata
}); });
return identity; return identity;
@@ -37,6 +37,7 @@ export type IdentityMembershipOrg = {
id: string; id: string;
identity: Identity; identity: Identity;
organization: string; organization: string;
metadata: { key: string; value: string; id: string }[];
role: "admin" | "member" | "viewer" | "no-access" | "custom"; role: "admin" | "member" | "viewer" | "no-access" | "custom";
customRole?: TOrgRole; customRole?: TOrgRole;
createdAt: string; createdAt: string;
@@ -79,6 +80,7 @@ export type CreateIdentityDTO = {
name: string; name: string;
organizationId: string; organizationId: string;
role?: string; role?: string;
metadata?: { key: string; value: string }[];
}; };
export type UpdateIdentityDTO = { export type UpdateIdentityDTO = {
@@ -86,6 +88,7 @@ export type UpdateIdentityDTO = {
name?: string; name?: string;
role?: string; role?: string;
organizationId: string; organizationId: string;
metadata?: { key: string; value: string }[];
}; };
export type DeleteIdentityDTO = { export type DeleteIdentityDTO = {
+3 -2
View File
@@ -235,12 +235,13 @@ export const useUpdateOrgMembership = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateOrgMembershipDTO>({ return useMutation<{}, {}, UpdateOrgMembershipDTO>({
mutationFn: ({ organizationId, membershipId, role, isActive }) => { mutationFn: ({ organizationId, membershipId, role, isActive, metadata }) => {
return apiRequest.patch( return apiRequest.patch(
`/api/v2/organizations/${organizationId}/memberships/${membershipId}`, `/api/v2/organizations/${organizationId}/memberships/${membershipId}`,
{ {
role, role,
isActive isActive,
metadata
} }
); );
}, },
+2
View File
@@ -46,6 +46,7 @@ export type UserEnc = {
export type OrgUser = { export type OrgUser = {
id: string; id: string;
metadata: { key: string; value: string; id: string }[];
user: { user: {
username: string; username: string;
email?: string; email?: string;
@@ -142,6 +143,7 @@ export type UpdateOrgMembershipDTO = {
membershipId: string; membershipId: string;
role?: string; role?: string;
isActive?: boolean; isActive?: boolean;
metadata?: { key: string; value: string }[];
}; };
export type DeletOrgMembershipDTO = { export type DeletOrgMembershipDTO = {
@@ -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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions"; 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 { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useTimedReset } from "@app/hooks"; import { useTimedReset } from "@app/hooks";
import { useGetIdentityById } from "@app/hooks/api"; import { useGetIdentityById } from "@app/hooks/api";
@@ -40,7 +40,8 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
identityId, identityId,
name: data.identity.name, name: data.identity.name,
role: data.role, role: data.role,
customRole: data.customRole customRole: data.customRole,
metadata: data.metadata
}); });
}} }}
> >
@@ -77,10 +78,36 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
<p className="text-sm font-semibold text-mineshaft-300">Name</p> <p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.identity.name}</p> <p className="text-sm text-mineshaft-300">{data.identity.name}</p>
</div> </div>
<div> <div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p> <p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.role}</p> <p className="text-sm text-mineshaft-300">{data.role}</p>
</div> </div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Metadata</p>
{data?.metadata?.length ? (
<div className="mt-1 flex gap-2 text-sm text-mineshaft-300">
{data.metadata?.map((el) => (
<div key={el.id} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div>{el.value}</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -1,13 +1,17 @@
import { useEffect } from "react"; 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 { useRouter } from "next/router";
import { yupResolver } from "@hookform/resolvers/yup"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import * as yup from "yup"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Button, Button,
FormControl, FormControl,
FormLabel,
IconButton,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
@@ -22,14 +26,21 @@ import {
} from "@app/hooks/api/identities"; } from "@app/hooks/api/identities";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = yup const schema = z
.object({ .object({
name: yup.string().required("MI name is required"), name: z.string(),
role: yup.string() role: z.string(),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().min(1)
})
.array()
.optional()
}) })
.required(); .required();
export type FormData = yup.InferType<typeof schema>; export type FormData = z.infer<typeof schema>;
type Props = { type Props = {
popUp: UsePopUpState<["identity"]>; popUp: UsePopUpState<["identity"]>;
@@ -61,12 +72,17 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
reset, reset,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<FormData>({ } = useForm<FormData>({
resolver: yupResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
name: "" name: ""
} }
}); });
const metadataFormFields = useFieldArray({
control,
name: "metadata"
});
useEffect(() => { useEffect(() => {
const identity = popUp?.identity?.data as { const identity = popUp?.identity?.data as {
identityId: string; identityId: string;
@@ -93,7 +109,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
}, [popUp?.identity?.data, roles]); }, [popUp?.identity?.data, roles]);
const onFormSubmit = async ({ name, role }: FormData) => { const onFormSubmit = async ({ name, role, metadata }: FormData) => {
try { try {
const identity = popUp?.identity?.data as { const identity = popUp?.identity?.data as {
identityId: string; identityId: string;
@@ -108,7 +124,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
identityId: identity.identityId, identityId: identity.identityId,
name, name,
role: role || undefined, role: role || undefined,
organizationId: orgId organizationId: orgId,
metadata
}); });
handlePopUpToggle("identity", false); handlePopUpToggle("identity", false);
@@ -118,7 +135,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { id: createdId } = await createMutateAsync({ const { id: createdId } = await createMutateAsync({
name, name,
role: role || undefined, role: role || undefined,
organizationId: orgId organizationId: orgId,
metadata
}); });
await addMutateAsync({ await addMutateAsync({
@@ -207,6 +225,67 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
<div>
<FormLabel label="Metadata" />
</div>
<div className="mb-3 flex flex-col space-y-2">
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div key={metadataFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`metadata.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`metadata.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
onClick={() => metadataFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div className="mt-2 flex justify-end">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => metadataFormFields.append({ key: "", value: "" })}
>
Add Key
</Button>
</div>
</div>
<div className="flex items-center"> <div className="flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -3,13 +3,14 @@ import {
faCheckCircle, faCheckCircle,
faCircleXmark, faCircleXmark,
faCopy, faCopy,
faKey,
faPencil faPencil
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tooltip } from "@app/components/v2"; import { Button, IconButton, Tag, Tooltip } from "@app/components/v2";
import { import {
OrgPermissionActions, OrgPermissionActions,
OrgPermissionSubjects, OrgPermissionSubjects,
@@ -98,7 +99,8 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
onClick={() => { onClick={() => {
handlePopUpOpen("orgMembership", { handlePopUpOpen("orgMembership", {
membershipId: membership.id, membershipId: membership.id,
role: membership.role role: membership.role,
metadata: membership.metadata
}); });
}} }}
> >
@@ -162,10 +164,36 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p> <p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{roleName ?? "-"}</p> <p className="text-sm text-mineshaft-300">{roleName ?? "-"}</p>
</div> </div>
<div> <div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Status</p> <p className="text-sm font-semibold text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">{getStatus(membership)}</p> <p className="text-sm text-mineshaft-300">{getStatus(membership)}</p>
</div> </div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Metadata</p>
{membership?.metadata?.length ? (
<div className="mt-1 flex gap-2 text-sm text-mineshaft-300">
{membership.metadata?.map((el) => (
<div key={el.id} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div>{el.value}</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</div>
{membership.isActive && {membership.isActive &&
(membership.status === "invited" || membership.status === "verified") && (membership.status === "invited" || membership.status === "verified") &&
membership.user.email && membership.user.email &&
@@ -1,16 +1,35 @@
import { useEffect } from "react"; 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 { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; 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 { useOrganization, useSubscription } from "@app/context";
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api"; import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({ 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<typeof schema>; export type FormData = z.infer<typeof schema>;
@@ -39,9 +58,15 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
resolver: zodResolver(schema) resolver: zodResolver(schema)
}); });
const metadataFormFields = useFieldArray({
control,
name: "metadata"
});
const popUpData = popUp?.orgMembership?.data as { const popUpData = popUp?.orgMembership?.data as {
membershipId: string; membershipId: string;
role: string; role: string;
metadata: { key: string; value: string }[];
}; };
useEffect(() => { useEffect(() => {
@@ -49,7 +74,8 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
if (popUpData) { if (popUpData) {
reset({ reset({
role: popUpData.role role: popUpData.role,
metadata: popUpData.metadata
}); });
} else { } else {
reset({ reset({
@@ -58,14 +84,15 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
} }
}, [popUp?.orgMembership?.data, roles]); }, [popUp?.orgMembership?.data, roles]);
const onFormSubmit = async ({ role }: FormData) => { const onFormSubmit = async ({ role, metadata }: FormData) => {
try { try {
if (!orgId) return; if (!orgId) return;
await updateOrgMembership({ await updateOrgMembership({
organizationId: orgId, organizationId: orgId,
membershipId: popUpData.membershipId, membershipId: popUpData.membershipId,
role role,
metadata
}); });
handlePopUpToggle("orgMembership", false); handlePopUpToggle("orgMembership", false);
@@ -135,6 +162,67 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
</FormControl> </FormControl>
)} )}
/> />
<div>
<FormLabel label="Metadata" />
</div>
<div className="mb-3 flex flex-col space-y-2">
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div key={metadataFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`metadata.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`metadata.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
onClick={() => metadataFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div className="mt-2 flex justify-end">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => metadataFormFields.append({ key: "", value: "" })}
>
Add Key
</Button>
</div>
</div>
<div className="flex items-center"> <div className="flex items-center">
<Button <Button
className="mr-4" className="mr-4"