mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-25 14:05:03 +00:00
Merge pull request #1574 from akhilmhdh/feat/additional-privilege
feat: additional privilege for users and identity
This commit is contained in:
4
backend/src/@types/fastify.d.ts
vendored
4
backend/src/@types/fastify.d.ts
vendored
@ -5,9 +5,11 @@ import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-se
|
||||
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
|
||||
import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
|
||||
import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
|
||||
import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service";
|
||||
import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
||||
import { TScimServiceFactory } from "@app/ee/services/scim/scim-service";
|
||||
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||
@ -121,6 +123,8 @@ declare module "fastify" {
|
||||
telemetry: TTelemetryServiceFactory;
|
||||
dynamicSecret: TDynamicSecretServiceFactory;
|
||||
dynamicSecretLease: TDynamicSecretLeaseServiceFactory;
|
||||
projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory;
|
||||
identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory;
|
||||
};
|
||||
// this is exclusive use for middlewares in which we need to inject data
|
||||
// everywhere else access using service layer
|
||||
|
16
backend/src/@types/knex.d.ts
vendored
16
backend/src/@types/knex.d.ts
vendored
@ -38,6 +38,9 @@ import {
|
||||
TIdentityOrgMemberships,
|
||||
TIdentityOrgMembershipsInsert,
|
||||
TIdentityOrgMembershipsUpdate,
|
||||
TIdentityProjectAdditionalPrivilege,
|
||||
TIdentityProjectAdditionalPrivilegeInsert,
|
||||
TIdentityProjectAdditionalPrivilegeUpdate,
|
||||
TIdentityProjectMembershipRole,
|
||||
TIdentityProjectMembershipRoleInsert,
|
||||
TIdentityProjectMembershipRoleUpdate,
|
||||
@ -92,6 +95,9 @@ import {
|
||||
TProjects,
|
||||
TProjectsInsert,
|
||||
TProjectsUpdate,
|
||||
TProjectUserAdditionalPrivilege,
|
||||
TProjectUserAdditionalPrivilegeInsert,
|
||||
TProjectUserAdditionalPrivilegeUpdate,
|
||||
TProjectUserMembershipRoles,
|
||||
TProjectUserMembershipRolesInsert,
|
||||
TProjectUserMembershipRolesUpdate,
|
||||
@ -239,6 +245,11 @@ declare module "knex/types/tables" {
|
||||
TProjectUserMembershipRolesUpdate
|
||||
>;
|
||||
[TableName.ProjectRoles]: Knex.CompositeTableType<TProjectRoles, TProjectRolesInsert, TProjectRolesUpdate>;
|
||||
[TableName.ProjectUserAdditionalPrivilege]: Knex.CompositeTableType<
|
||||
TProjectUserAdditionalPrivilege,
|
||||
TProjectUserAdditionalPrivilegeInsert,
|
||||
TProjectUserAdditionalPrivilegeUpdate
|
||||
>;
|
||||
[TableName.ProjectKeys]: Knex.CompositeTableType<TProjectKeys, TProjectKeysInsert, TProjectKeysUpdate>;
|
||||
[TableName.Secret]: Knex.CompositeTableType<TSecrets, TSecretsInsert, TSecretsUpdate>;
|
||||
[TableName.SecretBlindIndex]: Knex.CompositeTableType<
|
||||
@ -294,6 +305,11 @@ declare module "knex/types/tables" {
|
||||
TIdentityProjectMembershipRoleInsert,
|
||||
TIdentityProjectMembershipRoleUpdate
|
||||
>;
|
||||
[TableName.IdentityProjectAdditionalPrivilege]: Knex.CompositeTableType<
|
||||
TIdentityProjectAdditionalPrivilege,
|
||||
TIdentityProjectAdditionalPrivilegeInsert,
|
||||
TIdentityProjectAdditionalPrivilegeUpdate
|
||||
>;
|
||||
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
|
||||
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
||||
TSecretApprovalPolicies,
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.ProjectUserAdditionalPrivilege))) {
|
||||
await knex.schema.createTable(TableName.ProjectUserAdditionalPrivilege, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("slug", 60).notNullable();
|
||||
t.uuid("projectMembershipId").notNullable();
|
||||
t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
|
||||
t.boolean("isTemporary").notNullable().defaultTo(false);
|
||||
t.string("temporaryMode");
|
||||
t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc
|
||||
t.datetime("temporaryAccessStartTime");
|
||||
t.datetime("temporaryAccessEndTime");
|
||||
t.jsonb("permissions").notNullable();
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await dropOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege);
|
||||
await knex.schema.dropTableIfExists(TableName.ProjectUserAdditionalPrivilege);
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.IdentityProjectAdditionalPrivilege))) {
|
||||
await knex.schema.createTable(TableName.IdentityProjectAdditionalPrivilege, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("slug", 60).notNullable();
|
||||
t.uuid("projectMembershipId").notNullable();
|
||||
t.foreign("projectMembershipId")
|
||||
.references("id")
|
||||
.inTable(TableName.IdentityProjectMembership)
|
||||
.onDelete("CASCADE");
|
||||
t.boolean("isTemporary").notNullable().defaultTo(false);
|
||||
t.string("temporaryMode");
|
||||
t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc
|
||||
t.datetime("temporaryAccessStartTime");
|
||||
t.datetime("temporaryAccessEndTime");
|
||||
t.jsonb("permissions").notNullable();
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await dropOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege);
|
||||
await knex.schema.dropTableIfExists(TableName.IdentityProjectAdditionalPrivilege);
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
// 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 IdentityProjectAdditionalPrivilegeSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
slug: z.string(),
|
||||
projectMembershipId: z.string().uuid(),
|
||||
isTemporary: z.boolean().default(false),
|
||||
temporaryMode: z.string().nullable().optional(),
|
||||
temporaryRange: z.string().nullable().optional(),
|
||||
temporaryAccessStartTime: z.date().nullable().optional(),
|
||||
temporaryAccessEndTime: z.date().nullable().optional(),
|
||||
permissions: z.unknown(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TIdentityProjectAdditionalPrivilege = z.infer<typeof IdentityProjectAdditionalPrivilegeSchema>;
|
||||
export type TIdentityProjectAdditionalPrivilegeInsert = Omit<
|
||||
z.input<typeof IdentityProjectAdditionalPrivilegeSchema>,
|
||||
TImmutableDBKeys
|
||||
>;
|
||||
export type TIdentityProjectAdditionalPrivilegeUpdate = Partial<
|
||||
Omit<z.input<typeof IdentityProjectAdditionalPrivilegeSchema>, TImmutableDBKeys>
|
||||
>;
|
@ -10,6 +10,7 @@ export * from "./git-app-org";
|
||||
export * from "./identities";
|
||||
export * from "./identity-access-tokens";
|
||||
export * from "./identity-org-memberships";
|
||||
export * from "./identity-project-additional-privilege";
|
||||
export * from "./identity-project-membership-role";
|
||||
export * from "./identity-project-memberships";
|
||||
export * from "./identity-ua-client-secrets";
|
||||
@ -28,6 +29,7 @@ export * from "./project-environments";
|
||||
export * from "./project-keys";
|
||||
export * from "./project-memberships";
|
||||
export * from "./project-roles";
|
||||
export * from "./project-user-additional-privilege";
|
||||
export * from "./project-user-membership-roles";
|
||||
export * from "./projects";
|
||||
export * from "./saml-configs";
|
||||
|
@ -20,6 +20,7 @@ export enum TableName {
|
||||
Environment = "project_environments",
|
||||
ProjectMembership = "project_memberships",
|
||||
ProjectRoles = "project_roles",
|
||||
ProjectUserAdditionalPrivilege = "project_user_additional_privilege",
|
||||
ProjectUserMembershipRole = "project_user_membership_roles",
|
||||
ProjectKeys = "project_keys",
|
||||
Secret = "secrets",
|
||||
@ -43,6 +44,7 @@ export enum TableName {
|
||||
IdentityOrgMembership = "identity_org_memberships",
|
||||
IdentityProjectMembership = "identity_project_memberships",
|
||||
IdentityProjectMembershipRole = "identity_project_membership_role",
|
||||
IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege",
|
||||
ScimToken = "scim_tokens",
|
||||
SecretApprovalPolicy = "secret_approval_policies",
|
||||
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
||||
|
31
backend/src/db/schemas/project-user-additional-privilege.ts
Normal file
31
backend/src/db/schemas/project-user-additional-privilege.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// 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 ProjectUserAdditionalPrivilegeSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
slug: z.string(),
|
||||
projectMembershipId: z.string().uuid(),
|
||||
isTemporary: z.boolean().default(false),
|
||||
temporaryMode: z.string().nullable().optional(),
|
||||
temporaryRange: z.string().nullable().optional(),
|
||||
temporaryAccessStartTime: z.date().nullable().optional(),
|
||||
temporaryAccessEndTime: z.date().nullable().optional(),
|
||||
permissions: z.unknown(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TProjectUserAdditionalPrivilege = z.infer<typeof ProjectUserAdditionalPrivilegeSchema>;
|
||||
export type TProjectUserAdditionalPrivilegeInsert = Omit<
|
||||
z.input<typeof ProjectUserAdditionalPrivilegeSchema>,
|
||||
TImmutableDBKeys
|
||||
>;
|
||||
export type TProjectUserAdditionalPrivilegeUpdate = Partial<
|
||||
Omit<z.input<typeof ProjectUserAdditionalPrivilegeSchema>, TImmutableDBKeys>
|
||||
>;
|
@ -0,0 +1,272 @@
|
||||
import { MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas";
|
||||
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types";
|
||||
import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission";
|
||||
import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/permanent",
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z.object({
|
||||
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId),
|
||||
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.trim()
|
||||
.default(slugify(alphaNumericNanoId(12)))
|
||||
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid slug"
|
||||
})
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug),
|
||||
permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: IdentityProjectAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.create({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
...req.body,
|
||||
isTemporary: false,
|
||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/temporary",
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z.object({
|
||||
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId),
|
||||
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.trim()
|
||||
.default(slugify(alphaNumericNanoId(12)))
|
||||
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid slug"
|
||||
})
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug),
|
||||
permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions),
|
||||
temporaryMode: z
|
||||
.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode)
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode),
|
||||
temporaryRange: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange),
|
||||
temporaryAccessStartTime: z
|
||||
.string()
|
||||
.datetime()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: IdentityProjectAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.create({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
...req.body,
|
||||
isTemporary: true,
|
||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "PATCH",
|
||||
schema: {
|
||||
body: z.object({
|
||||
// disallow empty string
|
||||
privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug),
|
||||
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.identityId),
|
||||
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug),
|
||||
privilegeDetails: z
|
||||
.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.trim()
|
||||
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid slug"
|
||||
})
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug),
|
||||
permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions),
|
||||
isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary),
|
||||
temporaryMode: z
|
||||
.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode)
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode),
|
||||
temporaryRange: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange),
|
||||
temporaryAccessStartTime: z
|
||||
.string()
|
||||
.datetime()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime)
|
||||
})
|
||||
.partial()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: IdentityProjectAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const updatedInfo = req.body.privilegeDetails;
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
slug: req.body.privilegeSlug,
|
||||
identityId: req.body.identityId,
|
||||
projectSlug: req.body.projectSlug,
|
||||
data: {
|
||||
...updatedInfo,
|
||||
permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined
|
||||
}
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "DELETE",
|
||||
schema: {
|
||||
body: z.object({
|
||||
privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.slug),
|
||||
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.identityId),
|
||||
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.projectSlug)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: IdentityProjectAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.deleteBySlug({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
slug: req.body.privilegeSlug,
|
||||
identityId: req.body.identityId,
|
||||
projectSlug: req.body.projectSlug
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:privilegeSlug",
|
||||
method: "GET",
|
||||
schema: {
|
||||
params: z.object({
|
||||
privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.slug)
|
||||
}),
|
||||
querystring: z.object({
|
||||
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.identityId),
|
||||
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.projectSlug)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: IdentityProjectAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsBySlug({
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
slug: req.params.privilegeSlug,
|
||||
...req.query
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "GET",
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.identityId),
|
||||
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug),
|
||||
unpacked: z
|
||||
.enum(["false", "true"])
|
||||
.transform((el) => el === "true")
|
||||
.default("true")
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.unpacked)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privileges: IdentityProjectAdditionalPrivilegeSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const privileges = await server.services.identityProjectAdditionalPrivilege.listIdentityProjectPrivileges({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.query
|
||||
});
|
||||
if (req.query.unpacked) {
|
||||
return {
|
||||
privileges: privileges.map(({ permissions, ...el }) => ({
|
||||
...el,
|
||||
permissions: unpackRules(permissions as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
|
||||
}))
|
||||
};
|
||||
}
|
||||
return { privileges };
|
||||
}
|
||||
});
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
|
||||
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
|
||||
import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router";
|
||||
import { registerLdapRouter } from "./ldap-router";
|
||||
import { registerLicenseRouter } from "./license-router";
|
||||
import { registerOrgRoleRouter } from "./org-role-router";
|
||||
@ -15,6 +16,7 @@ import { registerSecretScanningRouter } from "./secret-scanning-router";
|
||||
import { registerSecretVersionRouter } from "./secret-version-router";
|
||||
import { registerSnapshotRouter } from "./snapshot-router";
|
||||
import { registerTrustedIpRouter } from "./trusted-ip-router";
|
||||
import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router";
|
||||
|
||||
export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
// org role starts with organization
|
||||
@ -51,4 +53,11 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" });
|
||||
await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" });
|
||||
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
|
||||
await server.register(
|
||||
async (privilegeRouter) => {
|
||||
await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" });
|
||||
await privilegeRouter.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity" });
|
||||
},
|
||||
{ prefix: "/additional-privilege" }
|
||||
);
|
||||
};
|
||||
|
235
backend/src/ee/routes/v1/user-additional-privilege-router.ts
Normal file
235
backend/src/ee/routes/v1/user-additional-privilege-router.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas";
|
||||
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types";
|
||||
import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/permanent",
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z.object({
|
||||
projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.trim()
|
||||
.default(slugify(alphaNumericNanoId(12)))
|
||||
.refine((v) => v.toLowerCase() === v, "Slug must be lowercase")
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid slug"
|
||||
})
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug),
|
||||
permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: ProjectUserAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.projectUserAdditionalPrivilege.create({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
...req.body,
|
||||
isTemporary: false,
|
||||
permissions: JSON.stringify(req.body.permissions)
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/temporary",
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z.object({
|
||||
projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.trim()
|
||||
.default(`privilege-${slugify(alphaNumericNanoId(12))}`)
|
||||
.refine((v) => v.toLowerCase() === v, "Slug must be lowercase")
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid slug"
|
||||
})
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug),
|
||||
permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions),
|
||||
temporaryMode: z
|
||||
.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode)
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode),
|
||||
temporaryRange: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange),
|
||||
temporaryAccessStartTime: z
|
||||
.string()
|
||||
.datetime()
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: ProjectUserAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.projectUserAdditionalPrivilege.create({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
...req.body,
|
||||
isTemporary: true,
|
||||
permissions: JSON.stringify(req.body.permissions)
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:privilegeId",
|
||||
method: "PATCH",
|
||||
schema: {
|
||||
params: z.object({
|
||||
privilegeId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.privilegeId)
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
slug: z
|
||||
.string()
|
||||
.max(60)
|
||||
.trim()
|
||||
.refine((v) => v.toLowerCase() === v, "Slug must be lowercase")
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid slug"
|
||||
})
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug),
|
||||
permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions),
|
||||
isTemporary: z.boolean().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary),
|
||||
temporaryMode: z
|
||||
.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode)
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode),
|
||||
temporaryRange: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange),
|
||||
temporaryAccessStartTime: z
|
||||
.string()
|
||||
.datetime()
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime)
|
||||
})
|
||||
.partial(),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: ProjectUserAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.projectUserAdditionalPrivilege.updateById({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
...req.body,
|
||||
permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined,
|
||||
privilegeId: req.params.privilegeId
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:privilegeId",
|
||||
method: "DELETE",
|
||||
schema: {
|
||||
params: z.object({
|
||||
privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.DELETE.privilegeId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: ProjectUserAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.projectUserAdditionalPrivilege.deleteById({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
privilegeId: req.params.privilegeId
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "GET",
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
projectMembershipId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.LIST.projectMembershipId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privileges: ProjectUserAdditionalPrivilegeSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const privileges = await server.services.projectUserAdditionalPrivilege.listPrivileges({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
projectMembershipId: req.query.projectMembershipId
|
||||
});
|
||||
return { privileges };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:privilegeId",
|
||||
method: "GET",
|
||||
schema: {
|
||||
params: z.object({
|
||||
privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.GET_BY_PRIVILEGEID.privilegeId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
privilege: ProjectUserAdditionalPrivilegeSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const privilege = await server.services.projectUserAdditionalPrivilege.getPrivilegeDetailsById({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
privilegeId: req.params.privilegeId
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
});
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TIdentityProjectAdditionalPrivilegeDALFactory = ReturnType<
|
||||
typeof identityProjectAdditionalPrivilegeDALFactory
|
||||
>;
|
||||
|
||||
export const identityProjectAdditionalPrivilegeDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.IdentityProjectAdditionalPrivilege);
|
||||
return orm;
|
||||
};
|
@ -0,0 +1,297 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import ms from "ms";
|
||||
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal";
|
||||
import {
|
||||
IdentityProjectAdditionalPrivilegeTemporaryMode,
|
||||
TCreateIdentityPrivilegeDTO,
|
||||
TDeleteIdentityPrivilegeDTO,
|
||||
TGetIdentityPrivilegeDetailsDTO,
|
||||
TListIdentityPrivilegesDTO,
|
||||
TUpdateIdentityPrivilegeDTO
|
||||
} from "./identity-project-additional-privilege-types";
|
||||
|
||||
type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = {
|
||||
identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory;
|
||||
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findOne" | "findById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType<
|
||||
typeof identityProjectAdditionalPrivilegeServiceFactory
|
||||
>;
|
||||
|
||||
export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
identityProjectAdditionalPrivilegeDAL,
|
||||
identityProjectDAL,
|
||||
permissionService,
|
||||
projectDAL
|
||||
}: TIdentityProjectAdditionalPrivilegeServiceFactoryDep) => {
|
||||
const create = async ({
|
||||
slug,
|
||||
actor,
|
||||
actorId,
|
||||
identityId,
|
||||
projectSlug,
|
||||
permissions: customPermission,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
...dto
|
||||
}: TCreateIdentityPrivilegeDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
|
||||
if (!identityProjectMembership)
|
||||
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
|
||||
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
projectMembershipId: identityProjectMembership.id
|
||||
});
|
||||
if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
|
||||
|
||||
if (!dto.isTemporary) {
|
||||
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({
|
||||
projectMembershipId: identityProjectMembership.id,
|
||||
slug,
|
||||
permissions: customPermission
|
||||
});
|
||||
return additionalPrivilege;
|
||||
}
|
||||
|
||||
const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange);
|
||||
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({
|
||||
projectMembershipId: identityProjectMembership.id,
|
||||
slug,
|
||||
permissions: customPermission,
|
||||
isTemporary: true,
|
||||
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
|
||||
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
|
||||
});
|
||||
return additionalPrivilege;
|
||||
};
|
||||
|
||||
const updateBySlug = async ({
|
||||
projectSlug,
|
||||
slug,
|
||||
identityId,
|
||||
data,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod
|
||||
}: TUpdateIdentityPrivilegeDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
|
||||
if (!identityProjectMembership)
|
||||
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityProjectMembership.identityId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
|
||||
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
projectMembershipId: identityProjectMembership.id
|
||||
});
|
||||
if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" });
|
||||
if (data?.slug) {
|
||||
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug: data.slug,
|
||||
projectMembershipId: identityProjectMembership.id
|
||||
});
|
||||
if (existingSlug && existingSlug.id !== identityPrivilege.id)
|
||||
throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
|
||||
}
|
||||
|
||||
const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary;
|
||||
if (isTemporary) {
|
||||
const temporaryAccessStartTime = data?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime;
|
||||
const temporaryRange = data?.temporaryRange || identityPrivilege?.temporaryRange;
|
||||
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, {
|
||||
...data,
|
||||
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
|
||||
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
|
||||
});
|
||||
return additionalPrivilege;
|
||||
}
|
||||
|
||||
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, {
|
||||
...data,
|
||||
isTemporary: false,
|
||||
temporaryAccessStartTime: null,
|
||||
temporaryAccessEndTime: null,
|
||||
temporaryRange: null,
|
||||
temporaryMode: null
|
||||
});
|
||||
return additionalPrivilege;
|
||||
};
|
||||
|
||||
const deleteBySlug = async ({
|
||||
actorId,
|
||||
slug,
|
||||
identityId,
|
||||
projectSlug,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
}: TDeleteIdentityPrivilegeDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
|
||||
if (!identityProjectMembership)
|
||||
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityProjectMembership.identityId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" });
|
||||
|
||||
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
projectMembershipId: identityProjectMembership.id
|
||||
});
|
||||
if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" });
|
||||
|
||||
const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id);
|
||||
return deletedPrivilege;
|
||||
};
|
||||
|
||||
const getPrivilegeDetailsBySlug = async ({
|
||||
projectSlug,
|
||||
identityId,
|
||||
slug,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod
|
||||
}: TGetIdentityPrivilegeDetailsDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
|
||||
if (!identityProjectMembership)
|
||||
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
|
||||
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
projectMembershipId: identityProjectMembership.id
|
||||
});
|
||||
if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" });
|
||||
|
||||
return identityPrivilege;
|
||||
};
|
||||
|
||||
const listIdentityProjectPrivileges = async ({
|
||||
identityId,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
projectSlug
|
||||
}: TListIdentityPrivilegesDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
|
||||
if (!identityProjectMembership)
|
||||
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityProjectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
|
||||
const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({
|
||||
projectMembershipId: identityProjectMembership.id
|
||||
});
|
||||
return identityPrivileges;
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
updateBySlug,
|
||||
deleteBySlug,
|
||||
getPrivilegeDetailsBySlug,
|
||||
listIdentityProjectPrivileges
|
||||
};
|
||||
};
|
@ -0,0 +1,54 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
|
||||
Relative = "relative"
|
||||
}
|
||||
|
||||
export type TCreateIdentityPrivilegeDTO = {
|
||||
permissions: unknown;
|
||||
identityId: string;
|
||||
projectSlug: string;
|
||||
slug: string;
|
||||
} & (
|
||||
| {
|
||||
isTemporary: false;
|
||||
}
|
||||
| {
|
||||
isTemporary: true;
|
||||
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
) &
|
||||
Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateIdentityPrivilegeDTO = { slug: string; identityId: string; projectSlug: string } & Omit<
|
||||
TProjectPermission,
|
||||
"projectId"
|
||||
> & {
|
||||
data: Partial<{
|
||||
permissions: unknown;
|
||||
slug: string;
|
||||
isTemporary: boolean;
|
||||
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TDeleteIdentityPrivilegeDTO = Omit<TProjectPermission, "projectId"> & {
|
||||
slug: string;
|
||||
identityId: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export type TGetIdentityPrivilegeDetailsDTO = Omit<TProjectPermission, "projectId"> & {
|
||||
slug: string;
|
||||
identityId: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export type TListIdentityPrivilegesDTO = Omit<TProjectPermission, "projectId"> & {
|
||||
identityId: string;
|
||||
projectSlug: string;
|
||||
};
|
@ -56,6 +56,11 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
`${TableName.ProjectUserMembershipRole}.customRoleId`,
|
||||
`${TableName.ProjectRoles}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.ProjectUserAdditionalPrivilege,
|
||||
`${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`,
|
||||
`${TableName.ProjectMembership}.id`
|
||||
)
|
||||
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
|
||||
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
|
||||
.where("userId", userId)
|
||||
@ -69,9 +74,22 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"),
|
||||
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
||||
db.ref("orgId").withSchema(TableName.Project),
|
||||
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
|
||||
)
|
||||
.select("permissions");
|
||||
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
|
||||
db.ref("permissions").withSchema(TableName.ProjectRoles),
|
||||
db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"),
|
||||
db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"),
|
||||
db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"),
|
||||
db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"),
|
||||
db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"),
|
||||
db
|
||||
.ref("temporaryAccessStartTime")
|
||||
.withSchema(TableName.ProjectUserAdditionalPrivilege)
|
||||
.as("userApTemporaryAccessStartTime"),
|
||||
db
|
||||
.ref("temporaryAccessEndTime")
|
||||
.withSchema(TableName.ProjectUserAdditionalPrivilege)
|
||||
.as("userApTemporaryAccessEndTime")
|
||||
);
|
||||
|
||||
const permission = sqlNestRelationships({
|
||||
data: docs,
|
||||
@ -102,15 +120,44 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
permissions: z.unknown(),
|
||||
customRoleSlug: z.string().optional().nullable()
|
||||
}).parse(data)
|
||||
},
|
||||
{
|
||||
key: "userApId",
|
||||
label: "additionalPrivileges" as const,
|
||||
mapper: ({
|
||||
userApId,
|
||||
userApPermissions,
|
||||
userApIsTemporary,
|
||||
userApTemporaryMode,
|
||||
userApTemporaryRange,
|
||||
userApTemporaryAccessEndTime,
|
||||
userApTemporaryAccessStartTime
|
||||
}) => ({
|
||||
id: userApId,
|
||||
permissions: userApPermissions,
|
||||
temporaryRange: userApTemporaryRange,
|
||||
temporaryMode: userApTemporaryMode,
|
||||
temporaryAccessEndTime: userApTemporaryAccessEndTime,
|
||||
temporaryAccessStartTime: userApTemporaryAccessStartTime,
|
||||
isTemporary: userApIsTemporary
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!permission?.[0]) return undefined;
|
||||
// when introducting cron mode change it here
|
||||
const activeRoles = permission?.[0]?.roles.filter(
|
||||
const activeRoles = permission?.[0]?.roles?.filter(
|
||||
({ isTemporary, temporaryAccessEndTime }) =>
|
||||
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
|
||||
);
|
||||
return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined;
|
||||
|
||||
const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter(
|
||||
({ isTemporary, temporaryAccessEndTime }) =>
|
||||
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
|
||||
);
|
||||
|
||||
return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "GetProjectPermission" });
|
||||
}
|
||||
@ -129,6 +176,11 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
`${TableName.IdentityProjectMembershipRole}.customRoleId`,
|
||||
`${TableName.ProjectRoles}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.IdentityProjectAdditionalPrivilege,
|
||||
`${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`,
|
||||
`${TableName.IdentityProjectMembership}.id`
|
||||
)
|
||||
.join(
|
||||
// Join the Project table to later select orgId
|
||||
TableName.Project,
|
||||
@ -144,9 +196,28 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
db.ref("role").withSchema(TableName.IdentityProjectMembership).as("oldRoleField"),
|
||||
db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"),
|
||||
db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"),
|
||||
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
|
||||
)
|
||||
.select("permissions");
|
||||
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
|
||||
db.ref("permissions").withSchema(TableName.ProjectRoles),
|
||||
db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"),
|
||||
db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"),
|
||||
db
|
||||
.ref("temporaryMode")
|
||||
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
|
||||
.as("identityApTemporaryMode"),
|
||||
db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"),
|
||||
db
|
||||
.ref("temporaryRange")
|
||||
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
|
||||
.as("identityApTemporaryRange"),
|
||||
db
|
||||
.ref("temporaryAccessStartTime")
|
||||
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
|
||||
.as("identityApTemporaryAccessStartTime"),
|
||||
db
|
||||
.ref("temporaryAccessEndTime")
|
||||
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
|
||||
.as("identityApTemporaryAccessEndTime")
|
||||
);
|
||||
|
||||
const permission = sqlNestRelationships({
|
||||
data: docs,
|
||||
@ -171,16 +242,44 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
permissions: z.unknown(),
|
||||
customRoleSlug: z.string().optional().nullable()
|
||||
}).parse(data)
|
||||
},
|
||||
{
|
||||
key: "identityApId",
|
||||
label: "additionalPrivileges" as const,
|
||||
mapper: ({
|
||||
identityApId,
|
||||
identityApPermissions,
|
||||
identityApIsTemporary,
|
||||
identityApTemporaryMode,
|
||||
identityApTemporaryRange,
|
||||
identityApTemporaryAccessEndTime,
|
||||
identityApTemporaryAccessStartTime
|
||||
}) => ({
|
||||
id: identityApId,
|
||||
permissions: identityApPermissions,
|
||||
temporaryRange: identityApTemporaryRange,
|
||||
temporaryMode: identityApTemporaryMode,
|
||||
temporaryAccessEndTime: identityApTemporaryAccessEndTime,
|
||||
temporaryAccessStartTime: identityApTemporaryAccessStartTime,
|
||||
isTemporary: identityApIsTemporary
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!permission?.[0]) return undefined;
|
||||
|
||||
// when introducting cron mode change it here
|
||||
const activeRoles = permission?.[0]?.roles.filter(
|
||||
({ isTemporary, temporaryAccessEndTime }) =>
|
||||
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
|
||||
);
|
||||
return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined;
|
||||
const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter(
|
||||
({ isTemporary, temporaryAccessEndTime }) =>
|
||||
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
|
||||
);
|
||||
|
||||
return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "GetProjectIdentityPermission" });
|
||||
}
|
||||
|
@ -180,10 +180,12 @@ export const permissionServiceFactory = ({
|
||||
authMethod: ActorAuthMethod,
|
||||
userOrgId?: string
|
||||
): Promise<TProjectPermissionRT<ActorType.USER>> => {
|
||||
const membership = await permissionDAL.getProjectPermission(userId, projectId);
|
||||
if (!membership) throw new UnauthorizedError({ name: "User not in project" });
|
||||
const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId);
|
||||
if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" });
|
||||
|
||||
if (membership.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions)) {
|
||||
if (
|
||||
userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions)
|
||||
) {
|
||||
throw new BadRequestError({ name: "Custom permission not found" });
|
||||
}
|
||||
|
||||
@ -192,17 +194,27 @@ export const permissionServiceFactory = ({
|
||||
|
||||
// Extra: This means that when users are using API keys to make requests, they can't use slug-based routes.
|
||||
// Slug-based routes depend on the organization ID being present on the request, since project slugs aren't globally unique, and we need a way to filter by organization.
|
||||
if (userOrgId !== "API_KEY" && membership.orgId !== userOrgId) {
|
||||
if (userOrgId !== "API_KEY" && userProjectPermission.orgId !== userOrgId) {
|
||||
throw new UnauthorizedError({ name: "You are not logged into this organization" });
|
||||
}
|
||||
|
||||
validateOrgSAML(authMethod, membership.orgAuthEnforced);
|
||||
validateOrgSAML(authMethod, userProjectPermission.orgAuthEnforced);
|
||||
|
||||
// join two permissions and pass to build the final permission set
|
||||
const rolePermissions = userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || [];
|
||||
const additionalPrivileges =
|
||||
userProjectPermission.additionalPrivileges?.map(({ permissions }) => ({
|
||||
role: ProjectMembershipRole.Custom,
|
||||
permissions
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
permission: buildProjectPermission(membership.roles),
|
||||
membership,
|
||||
permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)),
|
||||
membership: userProjectPermission,
|
||||
hasRole: (role: string) =>
|
||||
membership.roles.findIndex(({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug) !== -1
|
||||
userProjectPermission.roles.findIndex(
|
||||
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
|
||||
) !== -1
|
||||
};
|
||||
};
|
||||
|
||||
@ -226,8 +238,16 @@ export const permissionServiceFactory = ({
|
||||
throw new UnauthorizedError({ name: "You are not a member of this organization" });
|
||||
}
|
||||
|
||||
const rolePermissions =
|
||||
identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || [];
|
||||
const additionalPrivileges =
|
||||
identityProjectPermission.additionalPrivileges?.map(({ permissions }) => ({
|
||||
role: ProjectMembershipRole.Custom,
|
||||
permissions
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
permission: buildProjectPermission(identityProjectPermission.roles),
|
||||
permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)),
|
||||
membership: identityProjectPermission,
|
||||
hasRole: (role: string) =>
|
||||
identityProjectPermission.roles.findIndex(
|
||||
|
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TProjectUserAdditionalPrivilegeDALFactory = ReturnType<typeof projectUserAdditionalPrivilegeDALFactory>;
|
||||
|
||||
export const projectUserAdditionalPrivilegeDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.ProjectUserAdditionalPrivilege);
|
||||
return orm;
|
||||
};
|
@ -0,0 +1,212 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import ms from "ms";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal";
|
||||
import {
|
||||
ProjectUserAdditionalPrivilegeTemporaryMode,
|
||||
TCreateUserPrivilegeDTO,
|
||||
TDeleteUserPrivilegeDTO,
|
||||
TGetUserPrivilegeDetailsDTO,
|
||||
TListUserPrivilegesDTO,
|
||||
TUpdateUserPrivilegeDTO
|
||||
} from "./project-user-additional-privilege-types";
|
||||
|
||||
type TProjectUserAdditionalPrivilegeServiceFactoryDep = {
|
||||
projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType<
|
||||
typeof projectUserAdditionalPrivilegeServiceFactory
|
||||
>;
|
||||
|
||||
export const projectUserAdditionalPrivilegeServiceFactory = ({
|
||||
projectUserAdditionalPrivilegeDAL,
|
||||
projectMembershipDAL,
|
||||
permissionService
|
||||
}: TProjectUserAdditionalPrivilegeServiceFactoryDep) => {
|
||||
const create = async ({
|
||||
slug,
|
||||
actor,
|
||||
actorId,
|
||||
permissions: customPermission,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectMembershipId,
|
||||
...dto
|
||||
}: TCreateUserPrivilegeDTO) => {
|
||||
const projectMembership = await projectMembershipDAL.findById(projectMembershipId);
|
||||
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
|
||||
|
||||
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, projectMembershipId });
|
||||
if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
|
||||
|
||||
if (!dto.isTemporary) {
|
||||
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({
|
||||
projectMembershipId,
|
||||
slug,
|
||||
permissions: customPermission
|
||||
});
|
||||
return additionalPrivilege;
|
||||
}
|
||||
|
||||
const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange);
|
||||
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({
|
||||
projectMembershipId,
|
||||
slug,
|
||||
permissions: customPermission,
|
||||
isTemporary: true,
|
||||
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
|
||||
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
|
||||
});
|
||||
return additionalPrivilege;
|
||||
};
|
||||
|
||||
const updateById = async ({
|
||||
privilegeId,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
...dto
|
||||
}: TUpdateUserPrivilegeDTO) => {
|
||||
const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId);
|
||||
if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" });
|
||||
|
||||
const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId);
|
||||
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
|
||||
|
||||
if (dto?.slug) {
|
||||
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({
|
||||
slug: dto.slug,
|
||||
projectMembershipId: projectMembership.id
|
||||
});
|
||||
if (existingSlug && existingSlug.id !== userPrivilege.id)
|
||||
throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
|
||||
}
|
||||
|
||||
const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary;
|
||||
if (isTemporary) {
|
||||
const temporaryAccessStartTime = dto?.temporaryAccessStartTime || userPrivilege?.temporaryAccessStartTime;
|
||||
const temporaryRange = dto?.temporaryRange || userPrivilege?.temporaryRange;
|
||||
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, {
|
||||
...dto,
|
||||
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
|
||||
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
|
||||
});
|
||||
return additionalPrivilege;
|
||||
}
|
||||
|
||||
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, {
|
||||
...dto,
|
||||
isTemporary: false,
|
||||
temporaryAccessStartTime: null,
|
||||
temporaryAccessEndTime: null,
|
||||
temporaryRange: null,
|
||||
temporaryMode: null
|
||||
});
|
||||
return additionalPrivilege;
|
||||
};
|
||||
|
||||
const deleteById = async ({ actorId, actor, actorOrgId, actorAuthMethod, privilegeId }: TDeleteUserPrivilegeDTO) => {
|
||||
const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId);
|
||||
if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" });
|
||||
|
||||
const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId);
|
||||
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
|
||||
|
||||
const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id);
|
||||
return deletedPrivilege;
|
||||
};
|
||||
|
||||
const getPrivilegeDetailsById = async ({
|
||||
privilegeId,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod
|
||||
}: TGetUserPrivilegeDetailsDTO) => {
|
||||
const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId);
|
||||
if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" });
|
||||
|
||||
const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId);
|
||||
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member);
|
||||
|
||||
return userPrivilege;
|
||||
};
|
||||
|
||||
const listPrivileges = async ({
|
||||
projectMembershipId,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod
|
||||
}: TListUserPrivilegesDTO) => {
|
||||
const projectMembership = await projectMembershipDAL.findById(projectMembershipId);
|
||||
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectMembership.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member);
|
||||
|
||||
const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ projectMembershipId });
|
||||
return userPrivileges;
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
updateById,
|
||||
deleteById,
|
||||
getPrivilegeDetailsById,
|
||||
listPrivileges
|
||||
};
|
||||
};
|
@ -0,0 +1,40 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum ProjectUserAdditionalPrivilegeTemporaryMode {
|
||||
Relative = "relative"
|
||||
}
|
||||
|
||||
export type TCreateUserPrivilegeDTO = (
|
||||
| {
|
||||
permissions: unknown;
|
||||
projectMembershipId: string;
|
||||
slug: string;
|
||||
isTemporary: false;
|
||||
}
|
||||
| {
|
||||
permissions: unknown;
|
||||
projectMembershipId: string;
|
||||
slug: string;
|
||||
isTemporary: true;
|
||||
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
) &
|
||||
Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateUserPrivilegeDTO = { privilegeId: string } & Omit<TProjectPermission, "projectId"> &
|
||||
Partial<{
|
||||
permissions: unknown;
|
||||
slug: string;
|
||||
isTemporary: boolean;
|
||||
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}>;
|
||||
|
||||
export type TDeleteUserPrivilegeDTO = Omit<TProjectPermission, "projectId"> & { privilegeId: string };
|
||||
|
||||
export type TGetUserPrivilegeDetailsDTO = Omit<TProjectPermission, "projectId"> & { privilegeId: string };
|
||||
|
||||
export type TListUserPrivilegesDTO = Omit<TProjectPermission, "projectId"> & { projectMembershipId: string };
|
@ -397,3 +397,85 @@ export const SECRET_TAGS = {
|
||||
projectId: "The ID of the project to delete the tag from."
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const IDENTITY_ADDITIONAL_PRIVILEGE = {
|
||||
CREATE: {
|
||||
projectSlug: "The slug of the project of the identity in.",
|
||||
identityId: "The ID of the identity to delete.",
|
||||
slug: "The slug of the privilege to create.",
|
||||
permissions:
|
||||
"The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape",
|
||||
isPackPermission: "Whether the server should pack(compact) the permission object.",
|
||||
isTemporary: "Whether the privilege is temporary.",
|
||||
temporaryMode: "Type of temporary access given. Types: relative",
|
||||
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
|
||||
temporaryAccessStartTime: "ISO time for which temporary access should begin."
|
||||
},
|
||||
UPDATE: {
|
||||
projectSlug: "The slug of the project of the identity in.",
|
||||
identityId: "The ID of the identity to update.",
|
||||
slug: "The slug of the privilege to update.",
|
||||
newSlug: "The new slug of the privilege to update.",
|
||||
permissions: `The permission object for the privilege.
|
||||
Example unpacked permission shape
|
||||
1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]]
|
||||
2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]]
|
||||
2. [["read", "secrets", {environment: "dev"}]]
|
||||
`,
|
||||
isPackPermission: "Whether the server should pack(compact) the permission object.",
|
||||
isTemporary: "Whether the privilege is temporary.",
|
||||
temporaryMode: "Type of temporary access given. Types: relative",
|
||||
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
|
||||
temporaryAccessStartTime: "ISO time for which temporary access should begin."
|
||||
},
|
||||
DELETE: {
|
||||
projectSlug: "The slug of the project of the identity in.",
|
||||
identityId: "The ID of the identity to delete.",
|
||||
slug: "The slug of the privilege to delete."
|
||||
},
|
||||
GET_BY_SLUG: {
|
||||
projectSlug: "The slug of the project of the identity in.",
|
||||
identityId: "The ID of the identity to list.",
|
||||
slug: "The slug of the privilege."
|
||||
},
|
||||
LIST: {
|
||||
projectSlug: "The slug of the project of the identity in.",
|
||||
identityId: "The ID of the identity to list.",
|
||||
unpacked: "Whether the system should send the permissions as unpacked"
|
||||
}
|
||||
};
|
||||
|
||||
export const PROJECT_USER_ADDITIONAL_PRIVILEGE = {
|
||||
CREATE: {
|
||||
projectMembershipId: "Project membership id of user",
|
||||
slug: "The slug of the privilege to create.",
|
||||
permissions:
|
||||
"The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape",
|
||||
isPackPermission: "Whether the server should pack(compact) the permission object.",
|
||||
isTemporary: "Whether the privilege is temporary.",
|
||||
temporaryMode: "Type of temporary access given. Types: relative",
|
||||
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
|
||||
temporaryAccessStartTime: "ISO time for which temporary access should begin."
|
||||
},
|
||||
UPDATE: {
|
||||
privilegeId: "The id of privilege object",
|
||||
slug: "The slug of the privilege to create.",
|
||||
newSlug: "The new slug of the privilege to create.",
|
||||
permissions:
|
||||
"The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape",
|
||||
isPackPermission: "Whether the server should pack(compact) the permission object.",
|
||||
isTemporary: "Whether the privilege is temporary.",
|
||||
temporaryMode: "Type of temporary access given. Types: relative",
|
||||
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
|
||||
temporaryAccessStartTime: "ISO time for which temporary access should begin."
|
||||
},
|
||||
DELETE: {
|
||||
privilegeId: "The id of privilege object"
|
||||
},
|
||||
GET_BY_PRIVILEGEID: {
|
||||
privilegeId: "The id of privilege object"
|
||||
},
|
||||
LIST: {
|
||||
projectMembershipId: "Project membership id of user"
|
||||
}
|
||||
};
|
||||
|
@ -11,12 +11,16 @@ import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/pro
|
||||
import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal";
|
||||
import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue";
|
||||
import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
|
||||
import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal";
|
||||
import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
|
||||
import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal";
|
||||
import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
|
||||
import { licenseDALFactory } from "@app/ee/services/license/license-dal";
|
||||
import { licenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
|
||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal";
|
||||
import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service";
|
||||
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
|
||||
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
||||
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
|
||||
@ -149,6 +153,7 @@ export const registerRoutes = async (
|
||||
|
||||
const projectDAL = projectDALFactory(db);
|
||||
const projectMembershipDAL = projectMembershipDALFactory(db);
|
||||
const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db);
|
||||
const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db);
|
||||
const projectRoleDAL = projectRoleDALFactory(db);
|
||||
const projectEnvDAL = projectEnvDALFactory(db);
|
||||
@ -174,6 +179,7 @@ export const registerRoutes = async (
|
||||
const identityOrgMembershipDAL = identityOrgDALFactory(db);
|
||||
const identityProjectDAL = identityProjectDALFactory(db);
|
||||
const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db);
|
||||
const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db);
|
||||
|
||||
const identityUaDAL = identityUaDALFactory(db);
|
||||
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
|
||||
@ -345,6 +351,11 @@ export const registerRoutes = async (
|
||||
projectRoleDAL,
|
||||
licenseService
|
||||
});
|
||||
const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({
|
||||
permissionService,
|
||||
projectMembershipDAL,
|
||||
projectUserAdditionalPrivilegeDAL
|
||||
});
|
||||
const projectKeyService = projectKeyServiceFactory({
|
||||
permissionService,
|
||||
projectKeyDAL,
|
||||
@ -548,6 +559,12 @@ export const registerRoutes = async (
|
||||
identityProjectMembershipRoleDAL,
|
||||
projectRoleDAL
|
||||
});
|
||||
const identityProjectAdditionalPrivilegeService = identityProjectAdditionalPrivilegeServiceFactory({
|
||||
projectDAL,
|
||||
identityProjectAdditionalPrivilegeDAL,
|
||||
permissionService,
|
||||
identityProjectDAL
|
||||
});
|
||||
const identityUaService = identityUaServiceFactory({
|
||||
identityOrgMembershipDAL,
|
||||
permissionService,
|
||||
@ -638,7 +655,9 @@ export const registerRoutes = async (
|
||||
trustedIp: trustedIpService,
|
||||
scim: scimService,
|
||||
secretBlindIndex: secretBlindIndexService,
|
||||
telemetry: telemetryService
|
||||
telemetry: telemetryService,
|
||||
projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService,
|
||||
identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService
|
||||
});
|
||||
|
||||
server.decorate<FastifyZodProvider["store"]>("store", {
|
||||
|
@ -158,7 +158,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
||||
])
|
||||
)
|
||||
.min(1)
|
||||
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least long lived role is required")
|
||||
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least one long lived role is required")
|
||||
.describe(PROJECTS.UPDATE_USER_MEMBERSHIP.roles)
|
||||
}),
|
||||
response: {
|
||||
|
@ -25,6 +25,11 @@ export const identityProjectDALFactory = (db: TDbClient) => {
|
||||
`${TableName.IdentityProjectMembershipRole}.customRoleId`,
|
||||
`${TableName.ProjectRoles}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.IdentityProjectAdditionalPrivilege,
|
||||
`${TableName.IdentityProjectMembership}.id`,
|
||||
`${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`
|
||||
)
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("createdAt").withSchema(TableName.IdentityProjectMembership),
|
||||
|
@ -655,7 +655,7 @@ export const secretServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
|
||||
@ -741,7 +741,7 @@ export const secretServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
|
||||
|
@ -20,6 +20,7 @@ const tagVariants = cva(
|
||||
green: "bg-primary-800 text-white"
|
||||
},
|
||||
size: {
|
||||
xs: "text-xs px-1 py-0.5",
|
||||
sm: "px-2 py-0.5"
|
||||
}
|
||||
}
|
||||
|
@ -29,18 +29,30 @@ export type IdentityMembershipOrg = {
|
||||
export type IdentityMembership = {
|
||||
id: string;
|
||||
identity: Identity;
|
||||
roles: {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||
customRoleId: string;
|
||||
customRoleName: string;
|
||||
customRoleSlug: string;
|
||||
isTemporary: boolean;
|
||||
temporaryMode: string | null;
|
||||
temporaryRange: string | null;
|
||||
temporaryAccessStartTime: string | null;
|
||||
temporaryAccessEndTime: string | null;
|
||||
}[];
|
||||
roles: Array<
|
||||
{
|
||||
id: string;
|
||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||
customRoleId: string;
|
||||
customRoleName: string;
|
||||
customRoleSlug: string;
|
||||
} & (
|
||||
| {
|
||||
isTemporary: false;
|
||||
temporaryRange: null;
|
||||
temporaryMode: null;
|
||||
temporaryAccessEndTime: null;
|
||||
temporaryAccessStartTime: null;
|
||||
}
|
||||
| {
|
||||
isTemporary: true;
|
||||
temporaryRange: string;
|
||||
temporaryMode: string;
|
||||
temporaryAccessEndTime: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
)
|
||||
>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
@ -0,0 +1,7 @@
|
||||
export {
|
||||
useCreateIdentityProjectAdditionalPrivilege,
|
||||
useDeleteIdentityProjectAdditionalPrivilege,
|
||||
useUpdateIdentityProjectAdditionalPrivilege
|
||||
} from "./mutation";
|
||||
export { useGetIdentityProjectPrivilegeDetails } from "./queries";
|
||||
export type { TIdentityProjectPrivilege } from "./types";
|
@ -0,0 +1,73 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { identitiyProjectPrivilegeKeys } from "./queries";
|
||||
import {
|
||||
TCreateIdentityProjectPrivilegeDTO,
|
||||
TDeleteIdentityProjectPrivilegeDTO,
|
||||
TIdentityProjectPrivilege,
|
||||
TUpdateIdentityProjectPrivlegeDTO
|
||||
} from "./types";
|
||||
|
||||
export const useCreateIdentityProjectAdditionalPrivilege = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TIdentityProjectPrivilege, {}, TCreateIdentityProjectPrivilegeDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.post(
|
||||
"/api/v1/additional-privilege/identity/permanent",
|
||||
dto
|
||||
);
|
||||
return data.privilege;
|
||||
},
|
||||
onSuccess: (_, { projectSlug, identityId }) => {
|
||||
queryClient.invalidateQueries(
|
||||
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateIdentityProjectAdditionalPrivilege = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TIdentityProjectPrivilege, {}, TUpdateIdentityProjectPrivlegeDTO>({
|
||||
mutationFn: async ({ privilegeSlug, projectSlug, identityId, privilegeDetails }) => {
|
||||
const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", {
|
||||
privilegeSlug,
|
||||
projectSlug,
|
||||
identityId,
|
||||
privilegeDetails
|
||||
});
|
||||
return res.privilege;
|
||||
},
|
||||
onSuccess: (_, { projectSlug, identityId }) => {
|
||||
queryClient.invalidateQueries(
|
||||
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteIdentityProjectAdditionalPrivilege = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TIdentityProjectPrivilege, {}, TDeleteIdentityProjectPrivilegeDTO>({
|
||||
mutationFn: async ({ identityId, projectSlug, privilegeSlug }) => {
|
||||
const { data } = await apiRequest.delete("/api/v1/additional-privilege/identity", {
|
||||
data: {
|
||||
identityId,
|
||||
projectSlug,
|
||||
privilegeSlug
|
||||
}
|
||||
});
|
||||
return data.privilege;
|
||||
},
|
||||
onSuccess: (_, { projectSlug, identityId }) => {
|
||||
queryClient.invalidateQueries(
|
||||
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
@ -0,0 +1,77 @@
|
||||
import { PackRule, unpackRules } from "@casl/ability/extra";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TProjectPermission } from "../roles/types";
|
||||
import {
|
||||
TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails,
|
||||
TIdentityProjectPrivilege,
|
||||
TListIdentityUserPrivileges as TListIdentityProjectPrivileges
|
||||
} from "./types";
|
||||
|
||||
export const identitiyProjectPrivilegeKeys = {
|
||||
details: ({ identityId, privilegeSlug, projectSlug }: TGetIdentityProjectPrivilegeDetails) =>
|
||||
[
|
||||
"identity-user-privilege",
|
||||
{
|
||||
identityId,
|
||||
projectSlug,
|
||||
privilegeSlug
|
||||
}
|
||||
] as const,
|
||||
list: ({ projectSlug, identityId }: TListIdentityProjectPrivileges) =>
|
||||
["identity-user-privileges", { identityId, projectSlug }] as const
|
||||
};
|
||||
|
||||
export const useGetIdentityProjectPrivilegeDetails = ({
|
||||
projectSlug,
|
||||
identityId,
|
||||
privilegeSlug
|
||||
}: TGetIdentityProjectPrivilegeDetails) => {
|
||||
return useQuery({
|
||||
enabled: Boolean(projectSlug && identityId && privilegeSlug),
|
||||
queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, privilegeSlug, identityId }),
|
||||
queryFn: async () => {
|
||||
const {
|
||||
data: { privilege }
|
||||
} = await apiRequest.get<{
|
||||
privilege: Omit<TIdentityProjectPrivilege, "permissions"> & { permissions: unknown };
|
||||
}>(`/api/v1/additional-privilege/identity/${privilegeSlug}`, {
|
||||
params: {
|
||||
identityId,
|
||||
projectSlug
|
||||
}
|
||||
});
|
||||
return {
|
||||
...privilege,
|
||||
permissions: unpackRules(privilege.permissions as PackRule<TProjectPermission>[])
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useListIdentityProjectPrivileges = ({
|
||||
projectSlug,
|
||||
identityId
|
||||
}: TListIdentityProjectPrivileges) => {
|
||||
return useQuery({
|
||||
enabled: Boolean(projectSlug && identityId),
|
||||
queryKey: identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }),
|
||||
queryFn: async () => {
|
||||
const {
|
||||
data: { privileges }
|
||||
} = await apiRequest.get<{
|
||||
privileges: Array<
|
||||
Omit<TIdentityProjectPrivilege, "permissions"> & { permissions: unknown }
|
||||
>;
|
||||
}>("/api/v1/additional-privilege/identity", {
|
||||
params: { identityId, projectSlug, unpacked: false }
|
||||
});
|
||||
return privileges.map((el) => ({
|
||||
...el,
|
||||
permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
import { TProjectPermission } from "../roles/types";
|
||||
|
||||
export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
|
||||
Relative = "relative"
|
||||
}
|
||||
|
||||
export type TIdentityProjectPrivilege = {
|
||||
projectMembershipId: string;
|
||||
slug: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
permissions?: TProjectPermission[];
|
||||
} & (
|
||||
| {
|
||||
isTemporary: true;
|
||||
temporaryMode: string;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
temporaryAccessEndTime?: string;
|
||||
}
|
||||
| {
|
||||
isTemporary: false;
|
||||
temporaryMode?: null;
|
||||
temporaryRange?: null;
|
||||
temporaryAccessStartTime?: null;
|
||||
temporaryAccessEndTime?: null;
|
||||
}
|
||||
);
|
||||
|
||||
export type TCreateIdentityProjectPrivilegeDTO = {
|
||||
identityId: string;
|
||||
projectSlug: string;
|
||||
slug?: string;
|
||||
isTemporary?: boolean;
|
||||
temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode;
|
||||
temporaryRange?: string;
|
||||
temporaryAccessStartTime?: string;
|
||||
permissions: TProjectPermission[];
|
||||
};
|
||||
|
||||
export type TUpdateIdentityProjectPrivlegeDTO = {
|
||||
projectSlug: string;
|
||||
identityId: string;
|
||||
privilegeSlug: string;
|
||||
privilegeDetails: Partial<Omit<TCreateIdentityProjectPrivilegeDTO, "projectMembershipId" | "projectId">>;
|
||||
};
|
||||
|
||||
export type TDeleteIdentityProjectPrivilegeDTO = {
|
||||
projectSlug: string;
|
||||
identityId: string;
|
||||
privilegeSlug: string;
|
||||
};
|
||||
|
||||
export type TListIdentityUserPrivileges = {
|
||||
projectSlug: string;
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export type TGetIdentityProejctPrivilegeDetails = {
|
||||
projectSlug: string;
|
||||
identityId: string;
|
||||
privilegeSlug: string;
|
||||
};
|
@ -6,12 +6,14 @@ export * from "./bots";
|
||||
export * from "./dynamicSecret";
|
||||
export * from "./dynamicSecretLease";
|
||||
export * from "./identities";
|
||||
export * from "./identityProjectAdditionalPrivilege";
|
||||
export * from "./incidentContacts";
|
||||
export * from "./integrationAuth";
|
||||
export * from "./integrations";
|
||||
export * from "./keys";
|
||||
export * from "./ldapConfig";
|
||||
export * from "./organization";
|
||||
export * from "./projectUserAdditionalPrivilege";
|
||||
export * from "./roles";
|
||||
export * from "./scim";
|
||||
export * from "./secretApproval";
|
||||
|
@ -0,0 +1,7 @@
|
||||
export {
|
||||
useCreateProjectUserAdditionalPrivilege,
|
||||
useDeleteProjectUserAdditionalPrivilege,
|
||||
useUpdateProjectUserAdditionalPrivilege
|
||||
} from "./mutation";
|
||||
export { useGetProjectUserPrivilegeDetails, useListProjectUserPrivileges } from "./queries";
|
||||
export type { TProjectUserPrivilege } from "./types";
|
@ -0,0 +1,62 @@
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { projectUserPrivilegeKeys } from "./queries";
|
||||
import {
|
||||
TCreateProjectUserPrivilegeDTO,
|
||||
TDeleteProjectUserPrivilegeDTO,
|
||||
TProjectUserPrivilege,
|
||||
TUpdateProjectUserPrivlegeDTO
|
||||
} from "./types";
|
||||
|
||||
export const useCreateProjectUserAdditionalPrivilege = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", {
|
||||
...dto,
|
||||
permissions: packRules(dto.permissions)
|
||||
});
|
||||
return data.privilege;
|
||||
},
|
||||
onSuccess: (_, { projectMembershipId }) => {
|
||||
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProjectUserAdditionalPrivilege = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TUpdateProjectUserPrivlegeDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.patch(
|
||||
`/api/v1/additional-privilege/users/${dto.privilegeId}`,
|
||||
{ ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined }
|
||||
);
|
||||
return data.privilege;
|
||||
},
|
||||
onSuccess: (_, { projectMembershipId }) => {
|
||||
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteProjectUserAdditionalPrivilege = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TDeleteProjectUserPrivilegeDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.delete(
|
||||
`/api/v1/additional-privilege/users/${dto.privilegeId}`
|
||||
);
|
||||
return data.privilege;
|
||||
},
|
||||
onSuccess: (_, { projectMembershipId }) => {
|
||||
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
|
||||
}
|
||||
});
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
import { PackRule, unpackRules } from "@casl/ability/extra";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TProjectPermission } from "../roles/types";
|
||||
import { TProjectUserPrivilege } from "./types";
|
||||
|
||||
export const projectUserPrivilegeKeys = {
|
||||
details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const,
|
||||
list: (projectMembershipId: string) =>
|
||||
["project-user-privileges", { projectMembershipId }] as const
|
||||
};
|
||||
|
||||
const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
|
||||
const {
|
||||
data: { privilege }
|
||||
} = await apiRequest.get<{
|
||||
privilege: Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown };
|
||||
}>(`/api/v1/additional-privilege/users/${privilegeId}`);
|
||||
return {
|
||||
...privilege,
|
||||
permissions: unpackRules(privilege.permissions as PackRule<TProjectPermission>[])
|
||||
};
|
||||
};
|
||||
|
||||
export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => {
|
||||
return useQuery({
|
||||
enabled: Boolean(privilegeId),
|
||||
queryKey: projectUserPrivilegeKeys.details(privilegeId),
|
||||
queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId)
|
||||
});
|
||||
};
|
||||
|
||||
export const useListProjectUserPrivileges = (projectMembershipId: string) => {
|
||||
return useQuery({
|
||||
enabled: Boolean(projectMembershipId),
|
||||
queryKey: projectUserPrivilegeKeys.list(projectMembershipId),
|
||||
queryFn: async () => {
|
||||
const {
|
||||
data: { privileges }
|
||||
} = await apiRequest.get<{
|
||||
privileges: Array<Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown }>;
|
||||
}>("/api/v1/additional-privilege/users", { params: { projectMembershipId } });
|
||||
return privileges.map((el) => ({
|
||||
...el,
|
||||
permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
@ -0,0 +1,57 @@
|
||||
import { TProjectPermission } from "../roles/types";
|
||||
|
||||
export enum ProjectUserAdditionalPrivilegeTemporaryMode {
|
||||
Relative = "relative"
|
||||
}
|
||||
|
||||
export type TProjectUserPrivilege = {
|
||||
projectMembershipId: string;
|
||||
slug: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
permissions?: TProjectPermission[];
|
||||
} & (
|
||||
| {
|
||||
isTemporary: true;
|
||||
temporaryMode: string;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
temporaryAccessEndTime?: string;
|
||||
}
|
||||
| {
|
||||
isTemporary: false;
|
||||
temporaryMode?: null;
|
||||
temporaryRange?: null;
|
||||
temporaryAccessStartTime?: null;
|
||||
temporaryAccessEndTime?: null;
|
||||
}
|
||||
);
|
||||
|
||||
export type TCreateProjectUserPrivilegeDTO = {
|
||||
projectMembershipId: string;
|
||||
slug?: string;
|
||||
isTemporary?: boolean;
|
||||
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
|
||||
temporaryRange?: string;
|
||||
temporaryAccessStartTime?: string;
|
||||
permissions: TProjectPermission[];
|
||||
};
|
||||
|
||||
export type TUpdateProjectUserPrivlegeDTO = {
|
||||
privilegeId: string;
|
||||
projectMembershipId: string;
|
||||
} & Partial<Omit<TCreateProjectUserPrivilegeDTO, "projectMembershipId">>;
|
||||
|
||||
export type TDeleteProjectUserPrivilegeDTO = {
|
||||
privilegeId: string;
|
||||
projectMembershipId: string;
|
||||
};
|
||||
|
||||
export type TGetProjectUserPrivilegeDetails = {
|
||||
privilegeId: string;
|
||||
};
|
||||
|
||||
export type TListProjectUserPrivileges = {
|
||||
projectMembershipId: string;
|
||||
};
|
@ -76,18 +76,32 @@ export type TWorkspaceUser = {
|
||||
};
|
||||
inviteEmail: string;
|
||||
organization: string;
|
||||
roles: {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||
customRoleId: string;
|
||||
customRoleName: string;
|
||||
customRoleSlug: string;
|
||||
isTemporary: boolean;
|
||||
temporaryMode: string | null;
|
||||
temporaryRange: string | null;
|
||||
temporaryAccessStartTime: string | null;
|
||||
temporaryAccessEndTime: string | null;
|
||||
}[];
|
||||
roles: (
|
||||
| {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||
customRoleId: string;
|
||||
customRoleName: string;
|
||||
customRoleSlug: string;
|
||||
isTemporary: false;
|
||||
temporaryRange: null;
|
||||
temporaryMode: null;
|
||||
temporaryAccessEndTime: null;
|
||||
temporaryAccessStartTime: null;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||
customRoleId: string;
|
||||
customRoleName: string;
|
||||
customRoleSlug: string;
|
||||
isTemporary: true;
|
||||
temporaryRange: string;
|
||||
temporaryMode: string;
|
||||
temporaryAccessEndTime: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
)[];
|
||||
status: "invited" | "accepted" | "verified" | "completed";
|
||||
deniedPermissions: any[];
|
||||
};
|
||||
|
@ -32,15 +32,7 @@ export const MembersPage = withProjectPermission(
|
||||
<Tab value={TabSections.Roles}>Project Roles</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={TabSections.Member}>
|
||||
<motion.div
|
||||
key="panel-1"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<MemberListTab />
|
||||
</motion.div>
|
||||
<MemberListTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSections.Identities}>
|
||||
<IdentityTab />
|
||||
|
@ -1,17 +1,351 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faClock,
|
||||
faEdit,
|
||||
faPlus,
|
||||
faServer,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { IdentitySection } from "./components";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
export const IdentityTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-identity"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<IdentitySection />
|
||||
</motion.div>
|
||||
);
|
||||
import { IdentityModal } from "./components/IdentityModal";
|
||||
import { IdentityRoleForm } from "./components/IdentityRoleForm";
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No access";
|
||||
return role;
|
||||
};
|
||||
export const IdentityTab = withProjectPermission(
|
||||
() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const workspaceId = currentWorkspace?.id ?? "";
|
||||
|
||||
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"identity",
|
||||
"deleteIdentity",
|
||||
"upgradePlan",
|
||||
"updateRole"
|
||||
] as const);
|
||||
|
||||
const onRemoveIdentitySubmit = async (identityId: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
identityId,
|
||||
workspaceId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteIdentity");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="identity-role-panel"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("identity")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add identity
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Added on</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
data.map((identityMember, index) => {
|
||||
const {
|
||||
identity: { id, name },
|
||||
roles,
|
||||
createdAt
|
||||
} = identityMember;
|
||||
return (
|
||||
<Tr className="h-10" key={`st-v3-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
|
||||
<Td>
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id: roleId,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={roleId}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="capitalize">
|
||||
{formatRoleName(role, customRoleName)}
|
||||
</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Timed role expired"
|
||||
: "Timed role access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id: roleId,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() >
|
||||
new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={roleId} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Access expired"
|
||||
: "Temporary access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() >
|
||||
new Date(
|
||||
temporaryAccessEndTime as string
|
||||
) && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<Tooltip content="Edit permission">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
ariaLabel="update-role"
|
||||
onClick={() =>
|
||||
handlePopUpOpen("updateRole", { ...identityMember, index })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteIdentity", {
|
||||
identityId: id,
|
||||
name
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={7}>
|
||||
<EmptyState
|
||||
title="No identities have been added to this project"
|
||||
icon={faServer}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Modal
|
||||
isOpen={popUp.updateRole.isOpen}
|
||||
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-3xl"
|
||||
title={`Manage Access for ${(popUp.updateRole.data as IdentityMembership)?.identity?.name
|
||||
}`}
|
||||
subTitle={`
|
||||
Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role.
|
||||
`}
|
||||
>
|
||||
<IdentityRoleForm
|
||||
onOpenUpgradeModal={(description) =>
|
||||
handlePopUpOpen("upgradePlan", { description })
|
||||
}
|
||||
identityProjectMember={
|
||||
data?.[
|
||||
(popUp.updateRole?.data as IdentityMembership & { index: number })?.index
|
||||
] as IdentityMembership
|
||||
}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
title={`Are you sure want to remove ${(popUp?.deleteIdentity?.data as { name: string })?.name || ""
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveIdentitySubmit(
|
||||
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
|
||||
);
|
||||
|
@ -0,0 +1,353 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
identityProjectMember: IdentityMembership;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isLoading) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
identityId: identityProjectMember.identity.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
roleForm.reset(undefined, { keepValues: true });
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-lg font-medium">Roles</div>
|
||||
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
|
||||
<div>
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non expiry access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"opacity-0",
|
||||
roleForm.formState.isDirty && "opacity-100"
|
||||
)}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
|
||||
import { IdentityRbacSection } from "./IdentityRbacSection";
|
||||
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
|
||||
|
||||
type Props = {
|
||||
identityProjectMember: IdentityMembership;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<IdentityRbacSection
|
||||
identityProjectMember={identityProjectMember}
|
||||
onOpenUpgradeModal={onOpenUpgradeModal}
|
||||
/>
|
||||
<SpecificPrivilegeSection identityId={identityProjectMember?.identity?.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,532 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowRotateLeft,
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faClock,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
TProjectUserPrivilege,
|
||||
useCreateIdentityProjectAdditionalPrivilege,
|
||||
useDeleteIdentityProjectAdditionalPrivilege,
|
||||
useUpdateIdentityProjectAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries";
|
||||
|
||||
const secretPermissionSchema = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
environmentSlug: z.string(),
|
||||
[ProjectPermissionActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Create]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Delete]: z.boolean().optional(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
});
|
||||
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||
const SpecificPrivilegeSecretForm = ({
|
||||
privilege,
|
||||
identityId
|
||||
}: {
|
||||
privilege: TProjectUserPrivilege;
|
||||
identityId: string;
|
||||
}) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
);
|
||||
|
||||
const updateIdentityPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
|
||||
const deleteIdentityPrivilege = useDeleteIdentityProjectAdditionalPrivilege();
|
||||
|
||||
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||
resolver: zodResolver(secretPermissionSchema),
|
||||
values: {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Read)
|
||||
),
|
||||
edit: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Edit)
|
||||
),
|
||||
create: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
// zod will pick it
|
||||
temporaryAccess: privilege
|
||||
}
|
||||
});
|
||||
|
||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||
const isTemporary = temporaryAccessField?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccessField.isTemporary &&
|
||||
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||
|
||||
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
||||
if (updateIdentityPrivilege.isLoading) return;
|
||||
try {
|
||||
const actions = [
|
||||
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||
];
|
||||
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||
if (data.secretPath) {
|
||||
conditions.secretPath = { $glob: data.secretPath };
|
||||
}
|
||||
await updateIdentityPrivilege.mutateAsync({
|
||||
privilegeDetails: {
|
||||
...data.temporaryAccess,
|
||||
permissions: actions
|
||||
.filter(({ allowed }) => allowed)
|
||||
.map(({ action }) => ({
|
||||
action,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions
|
||||
}))
|
||||
},
|
||||
privilegeSlug: privilege.slug,
|
||||
identityId,
|
||||
projectSlug
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePrivilege = async () => {
|
||||
if (deleteIdentityPrivilege.isLoading) return;
|
||||
try {
|
||||
await deleteIdentityPrivilege.mutateAsync({
|
||||
identityId,
|
||||
privilegeSlug: privilege.slug,
|
||||
projectSlug
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getAccessLabel = (exactTime = false) => {
|
||||
if (isExpired) return "Access expired";
|
||||
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||
if (exactTime)
|
||||
return `Until ${format(
|
||||
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`;
|
||||
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="environmentSlug"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="Env">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
{currentWorkspace?.environments?.map(({ slug, id }) => (
|
||||
<SelectItem value={slug} key={id}>
|
||||
{slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Secret Path">
|
||||
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-grow justify-between">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="read"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="View" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-read"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="create"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Create" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-create"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="edit"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Modify" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-modify"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="delete"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Delete" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-delete"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
<Tooltip content={getAccessLabel(true)}>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{getAccessLabel()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
defaultValue="1h"
|
||||
name="temporaryAccess.temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = privilegeForm.getValues(
|
||||
"temporaryAccess.temporaryRange"
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
privilegeForm.setError(
|
||||
"temporaryAccess.temporaryRange",
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
|
||||
privilegeForm.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccessField.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
privilegeForm.setValue("temporaryAccess", {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{privilegeForm.formState.isDirty ? (
|
||||
<>
|
||||
<Tooltip content="Cancel" className="mr-4">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
isDisabled={privilegeForm.formState.isSubmitting}
|
||||
onClick={() => privilegeForm.reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Save"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="border-none py-3"
|
||||
ariaLabel="save-privilege"
|
||||
type="submit"
|
||||
>
|
||||
{privilegeForm.formState.isSubmitting ? (
|
||||
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
onClick={() => handlePopUpOpen("deletePrivilege")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
title="Remove user additional privilege"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||
deleteKey="delete"
|
||||
onClose={() => handlePopUpClose("deletePrivilege")}
|
||||
onDeleteApproved={handleDeletePrivilege}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export const SpecificPrivilegeSection = ({ identityId }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
const { data: identityPrivileges, isLoading } = useListIdentityProjectPrivileges({
|
||||
identityId,
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const createIdentityPrivilege = useCreateIdentityProjectAdditionalPrivilege();
|
||||
|
||||
const handleCreatePrivilege = async () => {
|
||||
if (createIdentityPrivilege.isLoading) return;
|
||||
try {
|
||||
await createIdentityPrivilege.mutateAsync({
|
||||
permissions: [
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions: {
|
||||
environment: currentWorkspace?.environments?.[0].slug
|
||||
}
|
||||
}
|
||||
],
|
||||
identityId,
|
||||
projectSlug
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
|
||||
<div className="flex items-center space-x-2 text-lg font-medium">
|
||||
Additional Privileges
|
||||
{isLoading && <Spinner size="xs" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-mineshaft-400">
|
||||
Select individual privileges to associate with the identity.
|
||||
</p>
|
||||
<div>
|
||||
{identityPrivileges
|
||||
?.filter(({ permissions }) =>
|
||||
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
|
||||
)
|
||||
?.map((privilege) => (
|
||||
<SpecificPrivilegeSecretForm
|
||||
privilege={privilege as TProjectUserPrivilege}
|
||||
identityId={identityId}
|
||||
key={privilege?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
className="mt-4"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={handleCreatePrivilege}
|
||||
isLoading={createIdentityPrivilege.isLoading}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add additional privilege
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { IdentityRoleForm } from "./IdentityRoleForm";
|
@ -1,459 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
const temporaryRoleFormSchema = z.object({
|
||||
temporaryRange: z.string().min(1, "Required")
|
||||
});
|
||||
|
||||
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
||||
|
||||
type TTemporaryRoleFormProps = {
|
||||
temporaryConfig?: {
|
||||
isTemporary?: boolean;
|
||||
temporaryAccessEndTime?: string | null;
|
||||
temporaryAccessStartTime?: string | null;
|
||||
temporaryRange?: string | null;
|
||||
};
|
||||
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
|
||||
onRemoveTemporary: () => void;
|
||||
};
|
||||
|
||||
const IdentityTemporaryRoleForm = ({
|
||||
temporaryConfig: defaultValues = {},
|
||||
onSetTemporary,
|
||||
onRemoveTemporary
|
||||
}: TTemporaryRoleFormProps) => {
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
|
||||
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
|
||||
resolver: zodResolver(temporaryRoleFormSchema),
|
||||
values: {
|
||||
temporaryRange: defaultValues.temporaryRange || "1h"
|
||||
}
|
||||
});
|
||||
const isTemporaryFieldValue = defaultValues.isTemporary;
|
||||
const isExpired =
|
||||
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popUp.setTempRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("setTempRole", isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<IconButton ariaLabel="role-temp" variant="plain" size="md">
|
||||
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
isTemporaryFieldValue && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Set Role Temporarily
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Validity"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={
|
||||
<span>
|
||||
1m, 2h, 3d.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isTemporaryFieldValue && (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime: new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{!isTemporaryFieldValue ? (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() =>
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime:
|
||||
defaultValues.temporaryAccessStartTime || new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})()
|
||||
}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
onRemoveTemporary();
|
||||
handlePopUpToggle("setTempRole");
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const formSchema = z.record(
|
||||
z.object({
|
||||
isChecked: z.boolean().optional(),
|
||||
temporaryAccess: z.union([
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.boolean()
|
||||
])
|
||||
})
|
||||
);
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
identityId: string;
|
||||
roles: TWorkspaceUser["roles"];
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const IdentityRoles = ({
|
||||
roles = [],
|
||||
disableEdit = false,
|
||||
identityId
|
||||
}: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
.filter((el) => Boolean(data[el].isChecked))
|
||||
.map((el) => {
|
||||
const isTemporary = Boolean(data[el].temporaryAccess);
|
||||
if (!isTemporary) {
|
||||
return { role: el, isTemporary: false as const };
|
||||
}
|
||||
|
||||
const tempCfg = data[el].temporaryAccess as {
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
};
|
||||
|
||||
return {
|
||||
role: el,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: tempCfg.temporaryRange,
|
||||
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await updateIdentityWorkspaceRole.mutateAsync({
|
||||
workspaceId,
|
||||
identityId,
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated identity role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
setSearchRoles("");
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update identity role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
return role;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() > new Date(temporaryAccessEndTime as string) &&
|
||||
"text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}{" "}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<div>
|
||||
<Popover
|
||||
open={popUp.editRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("editRole", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{!disableEdit && (
|
||||
<PopoverTrigger>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent hideCloseBtn className="pt-4">
|
||||
{isRolesLoading ? (
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
|
||||
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
|
||||
{projectRoles
|
||||
?.filter(
|
||||
({ name, slug }) =>
|
||||
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(searchRoles.toLowerCase())
|
||||
)
|
||||
?.map(({ id, name, slug }) => {
|
||||
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={Boolean(userProjectRoleDetails?.id)}
|
||||
name={`${slug}.isChecked`}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id={slug}
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
field.onChange(isChecked);
|
||||
setValue(`${slug}.temporaryAccess`, false);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`${slug}.temporaryAccess`}
|
||||
defaultValue={
|
||||
userProjectRoleDetails?.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime:
|
||||
userProjectRoleDetails.temporaryAccessStartTime as string,
|
||||
temporaryRange:
|
||||
userProjectRoleDetails.temporaryRange as string,
|
||||
temporaryAccessEndTime:
|
||||
userProjectRoleDetails.temporaryAccessEndTime
|
||||
}
|
||||
: false
|
||||
}
|
||||
render={({ field }) => (
|
||||
<IdentityTemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
console.log(data);
|
||||
field.onChange({ isTemporary: true, ...data });
|
||||
}}
|
||||
onRemoveTemporary={() => {
|
||||
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
|
||||
field.onChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
|
||||
<div>
|
||||
<Input
|
||||
className="w-full p-1.5 pl-8"
|
||||
size="xs"
|
||||
value={searchRoles}
|
||||
onChange={(el) => setSearchRoles(el.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
placeholder="Search roles.."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
form="role-update-form"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,107 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { useDeleteIdentityFromWorkspace } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityModal } from "./IdentityModal";
|
||||
import { IdentityTable } from "./IdentityTable";
|
||||
|
||||
export const IdentitySection = withProjectPermission(
|
||||
() => {
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const workspaceId = currentWorkspace?.id ?? "";
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"identity",
|
||||
"deleteIdentity",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onRemoveIdentitySubmit = async (identityId: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
identityId,
|
||||
workspaceId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteIdentity");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("identity")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add identity
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<IdentityTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveIdentitySubmit(
|
||||
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
|
||||
);
|
@ -1,108 +0,0 @@
|
||||
import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityRoles } from "./IdentityRoles";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
|
||||
data?: {
|
||||
identityId?: string;
|
||||
name?: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Added on</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
data.map(({ identity: { id, name }, roles, createdAt }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`st-v3-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteIdentity", {
|
||||
identityId: id,
|
||||
name
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={7}>
|
||||
<EmptyState title="No identities have been added to this project" icon={faServer} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
@ -1 +0,0 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
@ -2,9 +2,18 @@ import { useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { faMagnifyingGlass, faPlus, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faClock,
|
||||
faEdit,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faUsers,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@ -14,6 +23,9 @@ import {
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
@ -23,10 +35,12 @@ import {
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
@ -46,9 +60,11 @@ import {
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceUsers
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { MemberRoles } from "./MemberRoles";
|
||||
import { MemberRoleForm } from "./MemberRoleForm";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
orgMembershipId: z.string().trim()
|
||||
@ -56,8 +72,15 @@ const addMemberFormSchema = z.object({
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No access";
|
||||
return role;
|
||||
};
|
||||
|
||||
export const MemberListTab = () => {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
@ -77,7 +100,8 @@ export const MemberListTab = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addMember",
|
||||
"removeMember",
|
||||
"upgradePlan"
|
||||
"upgradePlan",
|
||||
"updateRole"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
@ -186,7 +210,14 @@ export const MemberListTab = () => {
|
||||
}, [orgUsers, members]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<motion.div
|
||||
key="user-role-1"
|
||||
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Members</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
|
||||
@ -223,7 +254,8 @@ export const MemberListTab = () => {
|
||||
<TBody>
|
||||
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||
{!isMembersLoading &&
|
||||
filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
|
||||
filterdUsers?.map((projectMember, index) => {
|
||||
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
|
||||
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
||||
const email = u?.email || inviteEmail;
|
||||
|
||||
@ -232,44 +264,136 @@ export const MemberListTab = () => {
|
||||
<Td>{name}</Td>
|
||||
<Td>{email}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<MemberRoles
|
||||
roles={roles}
|
||||
disableEdit={u.id === user?.id || !isAllowed}
|
||||
onOpenUpgradeModal={(description) =>
|
||||
handlePopUpOpen("upgradePlan", { description })
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="capitalize">
|
||||
{formatRoleName(role, customRoleName)}
|
||||
</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired ? "Timed role expired" : "Timed role access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
membershipId={membershipId}
|
||||
/>
|
||||
)}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() >
|
||||
new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Access expired"
|
||||
: "Temporary access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() >
|
||||
new Date(
|
||||
temporaryAccessEndTime as string
|
||||
) && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{userId !== u?.id && (
|
||||
<Tooltip content="Edit permission">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
ariaLabel="update-role"
|
||||
onClick={() =>
|
||||
handlePopUpOpen("updateRole", { ...projectMember, index })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== u?.id && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("removeMember", { username: u.username })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("removeMember", { username: u.username })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
@ -343,6 +467,27 @@ export const MemberListTab = () => {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={popUp.updateRole.isOpen}
|
||||
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title={`Manage Access for ${(popUp.updateRole.data as TWorkspaceUser)?.user?.email}`}
|
||||
subTitle={`
|
||||
Configure role-based access control by assigning Infisical users a mix of roles and specific privileges. A user will gain access to all actions within the roles assigned to them, not just the actions those roles share in common. You must choose at least one permanent role.
|
||||
`}
|
||||
>
|
||||
<MemberRoleForm
|
||||
onOpenUpgradeModal={(description) => handlePopUpOpen("upgradePlan", { description })}
|
||||
projectMember={
|
||||
filterdUsers?.[
|
||||
(popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index
|
||||
] as TWorkspaceUser
|
||||
}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
deleteKey="remove"
|
||||
@ -355,6 +500,6 @@ export const MemberListTab = () => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,350 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isLoading) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId: projectMember.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
roleForm.reset(undefined, { keepValues: true });
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-lg font-medium">Roles</div>
|
||||
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
|
||||
<div>
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non expiry access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"opacity-0",
|
||||
roleForm.formState.isDirty && "opacity-100"
|
||||
)}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
import { MemberRbacSection } from "./MemberRbacSection";
|
||||
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<MemberRbacSection
|
||||
projectMember={projectMember}
|
||||
onOpenUpgradeModal={onOpenUpgradeModal}
|
||||
/>
|
||||
<SpecificPrivilegeSection membershipId={projectMember?.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,514 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowRotateLeft,
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faClock,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
TProjectUserPrivilege,
|
||||
useCreateProjectUserAdditionalPrivilege,
|
||||
useDeleteProjectUserAdditionalPrivilege,
|
||||
useListProjectUserPrivileges,
|
||||
useUpdateProjectUserAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
|
||||
const secretPermissionSchema = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
environmentSlug: z.string(),
|
||||
[ProjectPermissionActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Create]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Delete]: z.boolean().optional(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
});
|
||||
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
||||
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
||||
|
||||
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||
resolver: zodResolver(secretPermissionSchema),
|
||||
values: {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Read)
|
||||
),
|
||||
edit: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Edit)
|
||||
),
|
||||
create: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
// zod will pick it
|
||||
temporaryAccess: privilege
|
||||
}
|
||||
});
|
||||
|
||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||
const isTemporary = temporaryAccessField?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccessField.isTemporary &&
|
||||
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||
|
||||
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
||||
if (updateUserPrivilege.isLoading) return;
|
||||
try {
|
||||
const actions = [
|
||||
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||
];
|
||||
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||
if (data.secretPath) {
|
||||
conditions.secretPath = { $glob: data.secretPath };
|
||||
}
|
||||
await updateUserPrivilege.mutateAsync({
|
||||
privilegeId: privilege.id,
|
||||
...data.temporaryAccess,
|
||||
permissions: actions
|
||||
.filter(({ allowed }) => allowed)
|
||||
.map(({ action }) => ({
|
||||
action,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions
|
||||
})),
|
||||
projectMembershipId: privilege.projectMembershipId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePrivilege = async () => {
|
||||
if (deleteUserPrivilege.isLoading) return;
|
||||
try {
|
||||
await deleteUserPrivilege.mutateAsync({
|
||||
privilegeId: privilege.id,
|
||||
projectMembershipId: privilege.projectMembershipId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getAccessLabel = (exactTime = false) => {
|
||||
if (isExpired) return "Access expired";
|
||||
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||
if (exactTime)
|
||||
return `Until ${format(
|
||||
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`;
|
||||
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="environmentSlug"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="Env">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
{currentWorkspace?.environments?.map(({ slug, id }) => (
|
||||
<SelectItem value={slug} key={id}>
|
||||
{slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Secret Path">
|
||||
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-grow justify-between">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="read"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="View" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-read"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="create"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Create" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-create"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="edit"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Modify" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-modify"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="delete"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Delete" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-delete"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
<Tooltip content={getAccessLabel(true)}>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{getAccessLabel()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
defaultValue="1h"
|
||||
name="temporaryAccess.temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = privilegeForm.getValues(
|
||||
"temporaryAccess.temporaryRange"
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
privilegeForm.setError(
|
||||
"temporaryAccess.temporaryRange",
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
|
||||
privilegeForm.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccessField.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
privilegeForm.setValue("temporaryAccess", {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{privilegeForm.formState.isDirty ? (
|
||||
<>
|
||||
<Tooltip content="Cancel" className="mr-4">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
isDisabled={privilegeForm.formState.isSubmitting}
|
||||
onClick={() => privilegeForm.reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Save"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="border-none py-3"
|
||||
ariaLabel="save-privilege"
|
||||
type="submit"
|
||||
>
|
||||
{privilegeForm.formState.isSubmitting ? (
|
||||
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
onClick={() => handlePopUpOpen("deletePrivilege")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
title="Remove user additional privilege"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||
deleteKey="delete"
|
||||
onClose={() => handlePopUpClose("deletePrivilege")}
|
||||
onDeleteApproved={handleDeletePrivilege}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
export const SpecificPrivilegeSection = ({ membershipId }: Props) => {
|
||||
const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const createUserPrivilege = useCreateProjectUserAdditionalPrivilege();
|
||||
|
||||
const handleCreatePrivilege = async () => {
|
||||
if (createUserPrivilege.isLoading) return;
|
||||
try {
|
||||
await createUserPrivilege.mutateAsync({
|
||||
permissions: [
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions: {
|
||||
environment: currentWorkspace?.environments?.[0].slug
|
||||
}
|
||||
}
|
||||
],
|
||||
projectMembershipId: membershipId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
|
||||
<div className="flex items-center space-x-2 text-lg font-medium">
|
||||
Additional Privileges
|
||||
{isLoading && <Spinner size="xs" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-mineshaft-400">
|
||||
Select individual privileges to associate with the user.
|
||||
</p>
|
||||
<div>
|
||||
{userPrivileges
|
||||
?.filter(({ permissions }) =>
|
||||
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
|
||||
)
|
||||
?.map((privilege) => (
|
||||
<SpecificPrivilegeSecretForm
|
||||
privilege={privilege as TProjectUserPrivilege}
|
||||
key={privilege?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
className="mt-4"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={handleCreatePrivilege}
|
||||
isLoading={createUserPrivilege.isLoading}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add additional privilege
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { MemberRoleForm } from "./MemberRoleForm";
|
@ -1,471 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
const temporaryRoleFormSchema = z.object({
|
||||
temporaryRange: z.string().min(1, "Required")
|
||||
});
|
||||
|
||||
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
||||
|
||||
type TTemporaryRoleFormProps = {
|
||||
temporaryConfig?: {
|
||||
isTemporary?: boolean;
|
||||
temporaryAccessEndTime?: string | null;
|
||||
temporaryAccessStartTime?: string | null;
|
||||
temporaryRange?: string | null;
|
||||
};
|
||||
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
|
||||
onRemoveTemporary: () => void;
|
||||
};
|
||||
|
||||
const TemporaryRoleForm = ({
|
||||
temporaryConfig: defaultValues = {},
|
||||
onSetTemporary,
|
||||
onRemoveTemporary
|
||||
}: TTemporaryRoleFormProps) => {
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
|
||||
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
|
||||
resolver: zodResolver(temporaryRoleFormSchema),
|
||||
values: {
|
||||
temporaryRange: defaultValues.temporaryRange || "1h"
|
||||
}
|
||||
});
|
||||
const isTemporaryFieldValue = defaultValues.isTemporary;
|
||||
const isExpired =
|
||||
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popUp.setTempRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("setTempRole", isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<IconButton ariaLabel="role-temp" variant="plain" size="md">
|
||||
<Tooltip content={isExpired ? "Timed access expired" : "Grant timed access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
isTemporaryFieldValue && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Validity"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={
|
||||
<span>
|
||||
1m, 2h, 3d.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isTemporaryFieldValue && (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime: new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{!isTemporaryFieldValue ? (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() =>
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime:
|
||||
defaultValues.temporaryAccessStartTime || new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})()
|
||||
}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
onRemoveTemporary();
|
||||
handlePopUpToggle("setTempRole");
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const formSchema = z.record(
|
||||
z.object({
|
||||
isChecked: z.boolean().optional(),
|
||||
temporaryAccess: z.union([
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.boolean()
|
||||
])
|
||||
})
|
||||
);
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
membershipId: string;
|
||||
onOpenUpgradeModal: (description: string) => void;
|
||||
roles: TWorkspaceUser["roles"];
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const MemberRoles = ({
|
||||
roles = [],
|
||||
disableEdit = false,
|
||||
membershipId,
|
||||
onOpenUpgradeModal
|
||||
}: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
.filter((el) => Boolean(data[el].isChecked))
|
||||
.map((el) => {
|
||||
const isTemporary = Boolean(data[el].temporaryAccess);
|
||||
if (!isTemporary) {
|
||||
return { role: el, isTemporary: false as const };
|
||||
}
|
||||
|
||||
const tempCfg = data[el].temporaryAccess as {
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
};
|
||||
|
||||
return {
|
||||
role: el,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: tempCfg.temporaryRange,
|
||||
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = selectedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId,
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
setSearchRoles("");
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
return role;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip content={isExpired ? "Timed role expired" : "Timed role access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip content={isExpired ? "Access expired" : "Temporary access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() > new Date(temporaryAccessEndTime as string) &&
|
||||
"text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}{" "}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<div>
|
||||
<Popover
|
||||
open={popUp.editRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("editRole", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{!disableEdit && (
|
||||
<PopoverTrigger>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent hideCloseBtn className="pt-4">
|
||||
{isRolesLoading ? (
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
|
||||
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
|
||||
{projectRoles
|
||||
?.filter(
|
||||
({ name, slug }) =>
|
||||
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(searchRoles.toLowerCase())
|
||||
)
|
||||
?.map(({ id, name, slug }) => {
|
||||
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={Boolean(userProjectRoleDetails?.id)}
|
||||
name={`${slug}.isChecked`}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id={slug}
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
field.onChange(isChecked);
|
||||
setValue(`${slug}.temporaryAccess`, false);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`${slug}.temporaryAccess`}
|
||||
defaultValue={
|
||||
userProjectRoleDetails?.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime:
|
||||
userProjectRoleDetails.temporaryAccessStartTime as string,
|
||||
temporaryRange:
|
||||
userProjectRoleDetails.temporaryRange as string,
|
||||
temporaryAccessEndTime:
|
||||
userProjectRoleDetails.temporaryAccessEndTime
|
||||
}
|
||||
: false
|
||||
}
|
||||
render={({ field }) => (
|
||||
<TemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
console.log(data);
|
||||
field.onChange({ isTemporary: true, ...data });
|
||||
}}
|
||||
onRemoveTemporary={() => {
|
||||
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
|
||||
field.onChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
|
||||
<div>
|
||||
<Input
|
||||
className="w-full p-1.5 pl-8"
|
||||
size="xs"
|
||||
value={searchRoles}
|
||||
onChange={(el) => setSearchRoles(el.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
placeholder="Search roles.."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
form="role-update-form"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { motion } from "framer-motion";
|
||||
@ -28,6 +28,7 @@ type Props = {
|
||||
formName: "secrets";
|
||||
isNonEditable?: boolean;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
getValue: UseFormGetValues<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@ -44,6 +45,7 @@ enum Permission {
|
||||
export const MultiEnvProjectPermission = ({
|
||||
isNonEditable,
|
||||
setValue,
|
||||
getValue,
|
||||
control,
|
||||
formName,
|
||||
title,
|
||||
@ -69,9 +71,12 @@ export const MultiEnvProjectPermission = ({
|
||||
|
||||
const handlePermissionChange = (val: Permission) => {
|
||||
switch (val) {
|
||||
case Permission.NoAccess:
|
||||
setValue(`permissions.${formName}`, undefined, { shouldDirty: true });
|
||||
case Permission.NoAccess: {
|
||||
const permissions = getValue("permissions");
|
||||
if (permissions) delete permissions[formName];
|
||||
setValue("permissions", permissions, { shouldDirty: true });
|
||||
break;
|
||||
}
|
||||
case Permission.FullAccess:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
@ -101,7 +106,7 @@ export const MultiEnvProjectPermission = ({
|
||||
className={twMerge(
|
||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
||||
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
|
||||
"border-l-2 border-primary-600"
|
||||
"border-l-2 border-primary-600"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
@ -128,6 +128,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
|
||||
register,
|
||||
formState: { isSubmitting, isDirty, errors },
|
||||
setValue,
|
||||
getValues,
|
||||
control
|
||||
} = useForm<TFormSchema>({
|
||||
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
|
||||
@ -226,6 +227,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
|
||||
</div>
|
||||
<div>
|
||||
<MultiEnvProjectPermission
|
||||
getValue={getValues}
|
||||
isNonEditable={isNonEditable}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
|
@ -31,6 +31,7 @@ export const formSchema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
|
||||
permissions: z
|
||||
.object({
|
||||
|
@ -416,7 +416,7 @@ export const ActionBar = ({
|
||||
{Object.keys(selectedSecrets).length} Selected
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
|
@ -119,9 +119,12 @@ export const AdminDashboardPage = () => {
|
||||
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
|
||||
Allow user sign up
|
||||
<div className="flex flex-col justify-start">
|
||||
<div className="mb-2 text-xl font-semibold text-mineshaft-100">
|
||||
Allow user signups
|
||||
</div>
|
||||
<div className="mb-4 text-sm max-w-sm text-mineshaft-400">
|
||||
Select if you want users to be able to signup freely into your Infisical instance.
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
@ -147,9 +150,9 @@ export const AdminDashboardPage = () => {
|
||||
/>
|
||||
</div>
|
||||
{signupMode === "anyone" && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="mb-4 flex text-mineshaft-100">
|
||||
Restrict sign up by email domain(s)
|
||||
<div className="mt-8 mb-8 flex flex-col justify-start">
|
||||
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
|
||||
Restrict signup by email domain(s)
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
|
Reference in New Issue
Block a user