mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: implemented ui and api for managing user,identity metadata
This commit is contained in:
8
backend/src/@types/knex.d.ts
vendored
8
backend/src/@types/knex.d.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
22
backend/src/db/schemas/identity-metadata.ts
Normal file
22
backend/src/db/schemas/identity-metadata.ts
Normal file
@@ -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>>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
10
backend/src/services/identity/identity-metadata-dal.ts
Normal file
10
backend/src/services/identity/identity-metadata-dal.ts
Normal file
@@ -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 { 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<Awaited<typeof paginatedFetchIdentity>>(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" });
|
||||
}
|
||||
|
||||
@@ -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<TIdentityProjectDALFactory, "findByIdentityId">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
|
||||
@@ -32,6 +34,7 @@ export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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<TOrgPermission, "orgId">;
|
||||
|
||||
export type TDeleteIdentityDTO = {
|
||||
|
||||
@@ -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<typeof orgMembershipDALFactory>;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
|
||||
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;
|
||||
};
|
||||
/*
|
||||
|
||||
@@ -9,6 +9,7 @@ export type TUpdateOrgMembershipDTO = {
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
actorOrgId: string | undefined;
|
||||
metadata?: { key: string; value: string }[];
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
};
|
||||
|
||||
|
||||
@@ -67,12 +67,13 @@ export const useCreateIdentity = () => {
|
||||
export const useUpdateIdentity = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Identity, {}, UpdateIdentityDTO>({
|
||||
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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) =
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.role}</p>
|
||||
</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>
|
||||
) : (
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["identity"]>;
|
||||
@@ -61,12 +72,17 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
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) => {
|
||||
</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">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
faCheckCircle,
|
||||
faCircleXmark,
|
||||
faCopy,
|
||||
faKey,
|
||||
faPencil
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { Button, IconButton, Tag, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
@@ -98,7 +99,8 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
onClick={() => {
|
||||
handlePopUpOpen("orgMembership", {
|
||||
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 text-mineshaft-300">{roleName ?? "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
|
||||
<p className="text-sm text-mineshaft-300">{getStatus(membership)}</p>
|
||||
</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.status === "invited" || membership.status === "verified") &&
|
||||
membership.user.email &&
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
@@ -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
|
||||
</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">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
Reference in New Issue
Block a user