mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 07:26:45 +00:00
feature: revamp server admin UI and create org additions
This commit is contained in:
@@ -841,7 +841,9 @@ export const registerRoutes = async (
|
|||||||
licenseService,
|
licenseService,
|
||||||
kmsService,
|
kmsService,
|
||||||
microsoftTeamsService,
|
microsoftTeamsService,
|
||||||
invalidateCacheQueue
|
invalidateCacheQueue,
|
||||||
|
smtpService,
|
||||||
|
tokenService
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgAdminService = orgAdminServiceFactory({
|
const orgAdminService = orgAdminServiceFactory({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { crypto } from "@app/lib/crypto/cryptography";
|
|||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||||
|
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
|
||||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||||
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
|
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
@@ -215,7 +216,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
}),
|
}),
|
||||||
membershipId: z.string(),
|
membershipId: z.string(),
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
roleId: z.string().nullish()
|
roleId: z.string().nullish(),
|
||||||
|
status: z.string().nullish()
|
||||||
})
|
})
|
||||||
.array(),
|
.array(),
|
||||||
projects: z
|
projects: z
|
||||||
@@ -838,4 +840,60 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/organization-management/organizations",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
name: GenericResourceNameSchema,
|
||||||
|
inviteAdminEmails: z.string().email().array()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
organization: OrganizationsSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: (req, res, done) => {
|
||||||
|
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
|
||||||
|
verifySuperAdmin(req, res, done);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const organization = await server.services.superAdmin.createOrganization(req.body, req.permission);
|
||||||
|
return { organization };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/organization-management/organizations/:organizationId/memberships/:membershipId/resend-invite",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
organizationId: z.string(),
|
||||||
|
membershipId: z.string()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
organizationMembership: OrgMembershipsSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: (req, res, done) => {
|
||||||
|
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
|
||||||
|
verifySuperAdmin(req, res, done);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const organizationMembership = await server.services.superAdmin.resendOrgInvite(req.params, req.permission);
|
||||||
|
return { organizationMembership };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
|
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
|
||||||
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
|
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
|
||||||
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId"))
|
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId"))
|
||||||
|
.select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus"))
|
||||||
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName"));
|
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName"));
|
||||||
|
|
||||||
const formattedDocs = sqlNestRelationships({
|
const formattedDocs = sqlNestRelationships({
|
||||||
@@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
orgMembershipId,
|
orgMembershipId,
|
||||||
orgMembershipRole,
|
orgMembershipRole,
|
||||||
orgMembershipRoleName,
|
orgMembershipRoleName,
|
||||||
orgMembershipRoleId
|
orgMembershipRoleId,
|
||||||
|
orgMembershipStatus
|
||||||
}) => ({
|
}) => ({
|
||||||
user: {
|
user: {
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
firstName,
|
firstName,
|
||||||
lastName
|
lastName
|
||||||
},
|
},
|
||||||
|
status: orgMembershipStatus,
|
||||||
membershipId: orgMembershipId,
|
membershipId: orgMembershipId,
|
||||||
role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name
|
role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name
|
||||||
roleId: orgMembershipRoleId
|
roleId: orgMembershipRoleId
|
||||||
@@ -488,6 +491,15 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bulkCreateMemberships = async (data: TOrgMembershipsInsert[], tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const memberships = await (tx || db)(TableName.OrgMembership).insert(data).returning("*");
|
||||||
|
return memberships;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Create org memberships" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => {
|
const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
|
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
|
||||||
@@ -668,6 +680,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
findMembership,
|
findMembership,
|
||||||
findMembershipWithScimFilter,
|
findMembershipWithScimFilter,
|
||||||
createMembership,
|
createMembership,
|
||||||
|
bulkCreateMemberships,
|
||||||
updateMembershipById,
|
updateMembershipById,
|
||||||
deleteMembershipById,
|
deleteMembershipById,
|
||||||
deleteMembershipsById,
|
deleteMembershipsById,
|
||||||
|
|||||||
@@ -528,15 +528,18 @@ export const orgServiceFactory = ({
|
|||||||
/*
|
/*
|
||||||
* Create organization
|
* Create organization
|
||||||
* */
|
* */
|
||||||
const createOrganization = async ({
|
const createOrganization = async (
|
||||||
userId,
|
{
|
||||||
userEmail,
|
userId,
|
||||||
orgName
|
userEmail,
|
||||||
}: {
|
orgName
|
||||||
userId: string;
|
}: {
|
||||||
orgName: string;
|
userId?: string;
|
||||||
userEmail?: string | null;
|
orgName: string;
|
||||||
}) => {
|
userEmail?: string | null;
|
||||||
|
},
|
||||||
|
trx?: Knex
|
||||||
|
) => {
|
||||||
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
|
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
|
||||||
const key = crypto.randomBytes(32).toString("base64");
|
const key = crypto.randomBytes(32).toString("base64");
|
||||||
const {
|
const {
|
||||||
@@ -555,22 +558,25 @@ export const orgServiceFactory = ({
|
|||||||
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
|
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
|
||||||
|
|
||||||
const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail);
|
const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail);
|
||||||
const organization = await orgDAL.transaction(async (tx) => {
|
|
||||||
|
const createOrg = async (tx: Knex) => {
|
||||||
// akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy
|
// akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy
|
||||||
const org = await orgDAL.create(
|
const org = await orgDAL.create(
|
||||||
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
|
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
await orgDAL.createMembership(
|
if (userId) {
|
||||||
{
|
await orgDAL.createMembership(
|
||||||
userId,
|
{
|
||||||
orgId: org.id,
|
userId,
|
||||||
role: OrgMembershipRole.Admin,
|
orgId: org.id,
|
||||||
status: OrgMembershipStatus.Accepted,
|
role: OrgMembershipRole.Admin,
|
||||||
isActive: true
|
status: OrgMembershipStatus.Accepted,
|
||||||
},
|
isActive: true
|
||||||
tx
|
},
|
||||||
);
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
await orgBotDAL.create(
|
await orgBotDAL.create(
|
||||||
{
|
{
|
||||||
name: org.name,
|
name: org.name,
|
||||||
@@ -590,7 +596,9 @@ export const orgServiceFactory = ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
return org;
|
return org;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg));
|
||||||
|
|
||||||
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
|
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
|
||||||
return organization;
|
return organization;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Heading, Section, Text } from "@react-email/components";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { BaseButton } from "./BaseButton";
|
||||||
|
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
||||||
|
import { BaseLink } from "./BaseLink";
|
||||||
|
|
||||||
|
interface OrganizationAssignmentTemplateProps extends Omit<BaseEmailWrapperProps, "preview" | "title"> {
|
||||||
|
inviterFirstName?: string;
|
||||||
|
inviterUsername?: string;
|
||||||
|
organizationName: string;
|
||||||
|
email: string;
|
||||||
|
callback_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrganizationAssignmentTemplate = ({
|
||||||
|
organizationName,
|
||||||
|
inviterFirstName,
|
||||||
|
inviterUsername,
|
||||||
|
callback_url,
|
||||||
|
siteUrl
|
||||||
|
}: OrganizationAssignmentTemplateProps) => {
|
||||||
|
return (
|
||||||
|
<BaseEmailWrapper
|
||||||
|
title="New Organization"
|
||||||
|
preview="You've been added to a new organization on Infisical."
|
||||||
|
siteUrl={siteUrl}
|
||||||
|
>
|
||||||
|
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||||
|
You've been added to the organization
|
||||||
|
<br />
|
||||||
|
<strong>{organizationName}</strong> on <strong>Infisical</strong>
|
||||||
|
</Heading>
|
||||||
|
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border text-center border-solid border-gray-200 rounded-md bg-gray-50">
|
||||||
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
|
{inviterFirstName && inviterUsername ? (
|
||||||
|
<>
|
||||||
|
<strong>{inviterFirstName}</strong> (
|
||||||
|
<BaseLink href={`mailto:${inviterUsername}`}>{inviterUsername}</BaseLink>) has added you as an
|
||||||
|
organization admin to <strong>{organizationName}</strong>.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
An instance admin has added you as an organization admin to <strong>{organizationName}</strong>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="text-center">
|
||||||
|
<BaseButton href={callback_url}>View Dashboard</BaseButton>
|
||||||
|
</Section>
|
||||||
|
<Section className="mt-[24px] bg-gray-50 pt-[2px] pb-[16px] border border-solid border-gray-200 px-[24px] rounded-md text-gray-800">
|
||||||
|
<Text className="mb-[0px]">
|
||||||
|
<strong>About Infisical:</strong> Infisical is an all-in-one platform to securely manage application secrets,
|
||||||
|
certificates, SSH keys, and configurations across your team and infrastructure.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</BaseEmailWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrganizationAssignmentTemplate;
|
||||||
|
|
||||||
|
OrganizationAssignmentTemplate.PreviewProps = {
|
||||||
|
organizationName: "Example Organization",
|
||||||
|
inviterFirstName: "Jane",
|
||||||
|
inviterUsername: "[email protected]",
|
||||||
|
siteUrl: "https://infisical.com",
|
||||||
|
callback_url: "https://app.infisical.com"
|
||||||
|
} as OrganizationAssignmentTemplateProps;
|
||||||
@@ -9,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate";
|
|||||||
export * from "./NewDeviceLoginTemplate";
|
export * from "./NewDeviceLoginTemplate";
|
||||||
export * from "./OrgAdminBreakglassAccessTemplate";
|
export * from "./OrgAdminBreakglassAccessTemplate";
|
||||||
export * from "./OrgAdminProjectGrantAccessTemplate";
|
export * from "./OrgAdminProjectGrantAccessTemplate";
|
||||||
|
export * from "./OrganizationAssignmentTemplate";
|
||||||
export * from "./OrganizationInvitationTemplate";
|
export * from "./OrganizationInvitationTemplate";
|
||||||
export * from "./PasswordResetTemplate";
|
export * from "./PasswordResetTemplate";
|
||||||
export * from "./PasswordSetupTemplate";
|
export * from "./PasswordSetupTemplate";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
NewDeviceLoginTemplate,
|
NewDeviceLoginTemplate,
|
||||||
OrgAdminBreakglassAccessTemplate,
|
OrgAdminBreakglassAccessTemplate,
|
||||||
OrgAdminProjectGrantAccessTemplate,
|
OrgAdminProjectGrantAccessTemplate,
|
||||||
|
OrganizationAssignmentTemplate,
|
||||||
OrganizationInvitationTemplate,
|
OrganizationInvitationTemplate,
|
||||||
PasswordResetTemplate,
|
PasswordResetTemplate,
|
||||||
PasswordSetupTemplate,
|
PasswordSetupTemplate,
|
||||||
@@ -61,6 +62,7 @@ export enum SmtpTemplates {
|
|||||||
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
||||||
NewDeviceJoin = "newDevice",
|
NewDeviceJoin = "newDevice",
|
||||||
OrgInvite = "organizationInvitation",
|
OrgInvite = "organizationInvitation",
|
||||||
|
OrgAssignment = "organizationAssignment",
|
||||||
ResetPassword = "passwordReset",
|
ResetPassword = "passwordReset",
|
||||||
SetupPassword = "passwordSetup",
|
SetupPassword = "passwordSetup",
|
||||||
SecretLeakIncident = "secretLeakIncident",
|
SecretLeakIncident = "secretLeakIncident",
|
||||||
@@ -94,6 +96,7 @@ export enum SmtpHost {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
||||||
[SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate,
|
[SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate,
|
||||||
|
[SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate,
|
||||||
[SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate,
|
[SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate,
|
||||||
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
||||||
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { CronJob } from "cron";
|
import { CronJob } from "cron";
|
||||||
|
|
||||||
import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas";
|
import {
|
||||||
|
IdentityAuthMethod,
|
||||||
|
OrgMembershipRole,
|
||||||
|
OrgMembershipStatus,
|
||||||
|
TSuperAdmin,
|
||||||
|
TSuperAdminUpdate,
|
||||||
|
TUsers
|
||||||
|
} from "@app/db/schemas";
|
||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
|
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +20,12 @@ import {
|
|||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
import { crypto } from "@app/lib/crypto/cryptography";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
|
import { isDisposableEmail } from "@app/lib/validator";
|
||||||
|
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
||||||
|
import { TokenType } from "@app/services/auth-token/auth-token-types";
|
||||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||||
|
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||||
|
|
||||||
import { TAuthLoginFactory } from "../auth/auth-login-service";
|
import { TAuthLoginFactory } from "../auth/auth-login-service";
|
||||||
import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
|
import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
|
||||||
@@ -43,7 +55,9 @@ import {
|
|||||||
TAdminGetUsersDTO,
|
TAdminGetUsersDTO,
|
||||||
TAdminIntegrationConfig,
|
TAdminIntegrationConfig,
|
||||||
TAdminSignUpDTO,
|
TAdminSignUpDTO,
|
||||||
TGetOrganizationsDTO
|
TCreateOrganizationDTO,
|
||||||
|
TGetOrganizationsDTO,
|
||||||
|
TResendOrgInviteDTO
|
||||||
} from "./super-admin-types";
|
} from "./super-admin-types";
|
||||||
|
|
||||||
type TSuperAdminServiceFactoryDep = {
|
type TSuperAdminServiceFactoryDep = {
|
||||||
@@ -59,11 +73,13 @@ type TSuperAdminServiceFactoryDep = {
|
|||||||
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
|
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
|
||||||
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">;
|
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">;
|
||||||
kmsRootConfigDAL: TKmsRootConfigDALFactory;
|
kmsRootConfigDAL: TKmsRootConfigDALFactory;
|
||||||
orgService: Pick<TOrgServiceFactory, "createOrganization">;
|
orgService: Pick<TOrgServiceFactory, "createOrganization" | "inviteUserToOrganization">;
|
||||||
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">;
|
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">;
|
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">;
|
||||||
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">;
|
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">;
|
||||||
invalidateCacheQueue: TInvalidateCacheQueueFactory;
|
invalidateCacheQueue: TInvalidateCacheQueueFactory;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
|
tokenService: TAuthTokenServiceFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
|
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
|
||||||
@@ -123,7 +139,9 @@ export const superAdminServiceFactory = ({
|
|||||||
identityTokenAuthDAL,
|
identityTokenAuthDAL,
|
||||||
identityOrgMembershipDAL,
|
identityOrgMembershipDAL,
|
||||||
microsoftTeamsService,
|
microsoftTeamsService,
|
||||||
invalidateCacheQueue
|
invalidateCacheQueue,
|
||||||
|
smtpService,
|
||||||
|
tokenService
|
||||||
}: TSuperAdminServiceFactoryDep) => {
|
}: TSuperAdminServiceFactoryDep) => {
|
||||||
const initServerCfg = async () => {
|
const initServerCfg = async () => {
|
||||||
// TODO(akhilmhdh): bad pattern time less change this later to me itself
|
// TODO(akhilmhdh): bad pattern time less change this later to me itself
|
||||||
@@ -732,6 +750,157 @@ export const superAdminServiceFactory = ({
|
|||||||
return organizations;
|
return organizations;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createOrganization = async (
|
||||||
|
{ name, inviteAdminEmails: emails }: TCreateOrganizationDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
|
||||||
|
const inviteAdminEmails = [...new Set(emails)];
|
||||||
|
|
||||||
|
if (!appCfg.isDevelopmentMode && appCfg.isCloud)
|
||||||
|
throw new BadRequestError({ message: "This endpoint is not supported for cloud instances" });
|
||||||
|
|
||||||
|
const serverAdmin = await userDAL.findById(actor.id);
|
||||||
|
const plan = licenseService.onPremFeatures;
|
||||||
|
|
||||||
|
const isEmailInvalid = await isDisposableEmail(inviteAdminEmails);
|
||||||
|
if (isEmailInvalid) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Disposable emails are not allowed",
|
||||||
|
name: "InviteUser"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { organization, users: usersToEmail } = await orgDAL.transaction(async (tx) => {
|
||||||
|
const org = await orgService.createOrganization(
|
||||||
|
{
|
||||||
|
orgName: name,
|
||||||
|
userEmail: serverAdmin?.email ?? serverAdmin?.username // identities can be server admins so we can't require this
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username" | "isAccepted">[] = [];
|
||||||
|
|
||||||
|
for await (const inviteeEmail of inviteAdminEmails) {
|
||||||
|
const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx);
|
||||||
|
let inviteeUser =
|
||||||
|
usersByUsername?.length > 1
|
||||||
|
? usersByUsername.find((el) => el.username === inviteeEmail)
|
||||||
|
: usersByUsername?.[0];
|
||||||
|
|
||||||
|
// if the user doesn't exist we create the user with the email
|
||||||
|
if (!inviteeUser) {
|
||||||
|
// TODO(carlos): will be removed once the function receives usernames instead of emails
|
||||||
|
const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx);
|
||||||
|
if (usersByEmail?.length === 1) {
|
||||||
|
[inviteeUser] = usersByEmail;
|
||||||
|
} else {
|
||||||
|
inviteeUser = await userDAL.create(
|
||||||
|
{
|
||||||
|
isAccepted: false,
|
||||||
|
email: inviteeEmail,
|
||||||
|
username: inviteeEmail,
|
||||||
|
authMethods: [AuthMethod.EMAIL],
|
||||||
|
isGhost: false
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteeUserId = inviteeUser?.id;
|
||||||
|
const existingEncrytionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx);
|
||||||
|
|
||||||
|
// when user is missing the encrytion keys
|
||||||
|
// this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp
|
||||||
|
// So what we do is we generate a random secure password and then encrypt it with a random pub-private key
|
||||||
|
// Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password
|
||||||
|
if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncrytionKey)) {
|
||||||
|
await userDAL.createUserEncryption(
|
||||||
|
{
|
||||||
|
userId: inviteeUserId,
|
||||||
|
encryptionVersion: 2
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
|
||||||
|
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
|
||||||
|
throw new BadRequestError({
|
||||||
|
name: "InviteUser",
|
||||||
|
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await orgDAL.createMembership(
|
||||||
|
{
|
||||||
|
userId: inviteeUser.id,
|
||||||
|
inviteEmail: inviteeEmail,
|
||||||
|
orgId: org.id,
|
||||||
|
role: OrgMembershipRole.Admin,
|
||||||
|
status: inviteeUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
users.push(inviteeUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { organization: org, users };
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
usersToEmail.map(async (user) => {
|
||||||
|
if (!user.email) return;
|
||||||
|
|
||||||
|
if (user.isAccepted) {
|
||||||
|
return smtpService.sendMail({
|
||||||
|
template: SmtpTemplates.OrgAssignment,
|
||||||
|
subjectLine: "You've been added to an Infisical organization",
|
||||||
|
recipients: [user.email],
|
||||||
|
substitutions: {
|
||||||
|
inviterFirstName: serverAdmin?.firstName,
|
||||||
|
inviterUsername: serverAdmin?.email,
|
||||||
|
organizationName: organization.name,
|
||||||
|
email: user.email,
|
||||||
|
organizationId: organization.id,
|
||||||
|
callback_url: `${appCfg.SITE_URL}/login?org_id=${organization.id}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// new user, send regular invite
|
||||||
|
|
||||||
|
const token = await tokenService.createTokenForUser({
|
||||||
|
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
|
||||||
|
userId: user.id,
|
||||||
|
orgId: organization.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return smtpService.sendMail({
|
||||||
|
template: SmtpTemplates.OrgInvite,
|
||||||
|
subjectLine: "Infisical organization invitation",
|
||||||
|
recipients: [user.email],
|
||||||
|
substitutions: {
|
||||||
|
inviterFirstName: serverAdmin?.firstName,
|
||||||
|
inviterUsername: serverAdmin?.email,
|
||||||
|
organizationName: organization.name,
|
||||||
|
email: user.email,
|
||||||
|
organizationId: organization.id,
|
||||||
|
token,
|
||||||
|
callback_url: `${appCfg.SITE_URL}/signupinvite`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return organization;
|
||||||
|
};
|
||||||
|
|
||||||
const deleteOrganization = async (organizationId: string) => {
|
const deleteOrganization = async (organizationId: string) => {
|
||||||
const organization = await orgDAL.deleteById(organizationId);
|
const organization = await orgDAL.deleteById(organizationId);
|
||||||
return organization;
|
return organization;
|
||||||
@@ -763,6 +932,56 @@ export const superAdminServiceFactory = ({
|
|||||||
return organizationMembership;
|
return organizationMembership;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => {
|
||||||
|
const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId });
|
||||||
|
|
||||||
|
if (!orgMembership) {
|
||||||
|
throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgMembership.status === OrgMembershipStatus.Accepted) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "This user has already accepted their invitation."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orgMembership.userId) {
|
||||||
|
throw new NotFoundError({ message: "Cannot find user associated with Org Membership." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orgMembership.inviteEmail) {
|
||||||
|
throw new BadRequestError({ message: "No invite email associated with user." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = await orgDAL.findOrgById(orgMembership.orgId);
|
||||||
|
|
||||||
|
const appCfg = getConfig();
|
||||||
|
const serverAdmin = await userDAL.findById(actor.id);
|
||||||
|
|
||||||
|
const token = await tokenService.createTokenForUser({
|
||||||
|
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
|
||||||
|
userId: orgMembership.userId,
|
||||||
|
orgId: orgMembership.orgId
|
||||||
|
});
|
||||||
|
|
||||||
|
await smtpService.sendMail({
|
||||||
|
template: SmtpTemplates.OrgInvite,
|
||||||
|
subjectLine: "Infisical organization invitation",
|
||||||
|
recipients: [orgMembership.inviteEmail],
|
||||||
|
substitutions: {
|
||||||
|
inviterFirstName: serverAdmin?.firstName,
|
||||||
|
inviterUsername: serverAdmin?.email,
|
||||||
|
organizationName: org?.name,
|
||||||
|
email: orgMembership.inviteEmail,
|
||||||
|
organizationId: orgMembership.orgId,
|
||||||
|
token,
|
||||||
|
callback_url: `${appCfg.SITE_URL}/signupinvite`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return orgMembership;
|
||||||
|
};
|
||||||
|
|
||||||
const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
|
const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
|
||||||
const identities = await identityDAL.getIdentitiesByFilter({
|
const identities = await identityDAL.getIdentitiesByFilter({
|
||||||
limit,
|
limit,
|
||||||
@@ -901,6 +1120,8 @@ export const superAdminServiceFactory = ({
|
|||||||
initializeEnvConfigSync,
|
initializeEnvConfigSync,
|
||||||
getEnvOverrides,
|
getEnvOverrides,
|
||||||
getEnvOverridesOrganized,
|
getEnvOverridesOrganized,
|
||||||
deleteUsers
|
deleteUsers,
|
||||||
|
createOrganization,
|
||||||
|
resendOrgInvite
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ export type TGetOrganizationsDTO = {
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TCreateOrganizationDTO = {
|
||||||
|
name: string;
|
||||||
|
inviteAdminEmails: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TResendOrgInviteDTO = {
|
||||||
|
organizationId: string;
|
||||||
|
membershipId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export enum LoginMethod {
|
export enum LoginMethod {
|
||||||
EMAIL = "email",
|
EMAIL = "email",
|
||||||
GOOGLE = "google",
|
GOOGLE = "google",
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export const CreatableSelect = <T,>({
|
|||||||
isSelected && "text-mineshaft-200",
|
isSelected && "text-mineshaft-200",
|
||||||
"px-3 py-2 text-xs hover:cursor-pointer"
|
"px-3 py-2 text-xs hover:cursor-pointer"
|
||||||
),
|
),
|
||||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md",
|
||||||
|
loadingMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const FilterableSelect = <T,>({
|
|||||||
),
|
),
|
||||||
placeholder: () =>
|
placeholder: () =>
|
||||||
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
|
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
|
||||||
input: () => "pl-1",
|
input: () => `pl-1 ${isMulti ? "py-[0.22rem]" : ""}`,
|
||||||
valueContainer: () =>
|
valueContainer: () =>
|
||||||
`px-1 max-h-[8.2rem] ${
|
`px-1 max-h-[8.2rem] ${
|
||||||
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
|
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
|
||||||
@@ -114,7 +114,8 @@ export const FilterableSelect = <T,>({
|
|||||||
isSelected && "text-mineshaft-200",
|
isSelected && "text-mineshaft-200",
|
||||||
"rounded px-3 py-2 text-xs hover:cursor-pointer"
|
"rounded px-3 py-2 text-xs hover:cursor-pointer"
|
||||||
),
|
),
|
||||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md",
|
||||||
|
loadingMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
import { Organization } from "@app/hooks/api/organization/types";
|
||||||
|
|
||||||
import { organizationKeys } from "../organization/queries";
|
import { organizationKeys } from "../organization/queries";
|
||||||
import { User } from "../users/types";
|
import { User } from "../users/types";
|
||||||
@@ -8,7 +9,9 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries";
|
|||||||
import {
|
import {
|
||||||
RootKeyEncryptionStrategy,
|
RootKeyEncryptionStrategy,
|
||||||
TCreateAdminUserDTO,
|
TCreateAdminUserDTO,
|
||||||
|
TCreateOrganizationDTO,
|
||||||
TInvalidateCacheDTO,
|
TInvalidateCacheDTO,
|
||||||
|
TResendOrgInviteDTO,
|
||||||
TServerConfig,
|
TServerConfig,
|
||||||
TUpdateServerConfigDTO
|
TUpdateServerConfigDTO
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -193,3 +196,30 @@ export const useInvalidateCache = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useServerAdminCreateOrganization = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (opt: TCreateOrganizationDTO) => {
|
||||||
|
const { data } = await apiRequest.post<{ organization: Organization }>(
|
||||||
|
"/api/v1/admin/organization-management/organizations",
|
||||||
|
opt
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useServerAdminResendOrgInvite = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ organizationId, membershipId }: TResendOrgInviteDTO) => {
|
||||||
|
await apiRequest.post(
|
||||||
|
`/api/v1/admin/organization-management/organizations/${organizationId}/memberships/${membershipId}/resend-invite`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query";
|
import {
|
||||||
|
DefaultError,
|
||||||
|
InfiniteData,
|
||||||
|
UndefinedInitialDataInfiniteOptions,
|
||||||
|
useInfiniteQuery,
|
||||||
|
useQuery,
|
||||||
|
UseQueryOptions
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
import { Identity } from "@app/hooks/api/identities/types";
|
import { Identity } from "@app/hooks/api/identities/types";
|
||||||
@@ -25,8 +32,8 @@ export const adminStandaloneKeys = {
|
|||||||
export const adminQueryKeys = {
|
export const adminQueryKeys = {
|
||||||
serverConfig: () => ["server-config"] as const,
|
serverConfig: () => ["server-config"] as const,
|
||||||
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
||||||
getOrganizations: (filters: AdminGetOrganizationsFilters) =>
|
getOrganizations: (filters?: AdminGetOrganizationsFilters) =>
|
||||||
[adminStandaloneKeys.getOrganizations, { filters }] as const,
|
[adminStandaloneKeys.getOrganizations, ...(filters ? [{ filters }] : [])] as const,
|
||||||
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
||||||
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
||||||
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
||||||
@@ -83,7 +90,18 @@ export const useGetServerConfig = ({
|
|||||||
enabled: options?.enabled ?? true
|
enabled: options?.enabled ?? true
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
|
export const useAdminGetUsers = (
|
||||||
|
filters: AdminGetUsersFilters,
|
||||||
|
options?: Partial<
|
||||||
|
UndefinedInitialDataInfiniteOptions<
|
||||||
|
User[],
|
||||||
|
DefaultError,
|
||||||
|
InfiniteData<User[]>,
|
||||||
|
ReturnType<typeof adminQueryKeys.getUsers>,
|
||||||
|
number
|
||||||
|
>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryKey: adminQueryKeys.getUsers(filters),
|
queryKey: adminQueryKeys.getUsers(filters),
|
||||||
@@ -101,7 +119,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
|
|||||||
return data.users;
|
return data.users;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage, pages) =>
|
getNextPageParam: (lastPage, pages) =>
|
||||||
lastPage.length !== 0 ? pages.length * filters.limit : undefined
|
lastPage.length !== 0 ? pages.length * filters.limit : undefined,
|
||||||
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type OrganizationWithProjects = Organization & {
|
|||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
};
|
};
|
||||||
membershipId: string;
|
membershipId: string;
|
||||||
|
status: string;
|
||||||
role: string;
|
role: string;
|
||||||
roleId: string | null;
|
roleId: string | null;
|
||||||
}[];
|
}[];
|
||||||
@@ -142,3 +143,13 @@ export interface TGetEnvOverrides {
|
|||||||
fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[];
|
fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TCreateOrganizationDTO = {
|
||||||
|
name: string;
|
||||||
|
inviteAdminEmails: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TResendOrgInviteDTO = {
|
||||||
|
organizationId: string;
|
||||||
|
membershipId: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ export type User = {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string | null;
|
||||||
superAdmin: boolean;
|
superAdmin: boolean;
|
||||||
firstName?: string;
|
firstName?: string | null;
|
||||||
lastName?: string;
|
lastName?: string | null;
|
||||||
authProvider?: AuthMethod;
|
authProvider?: AuthMethod;
|
||||||
authMethods: AuthMethod[];
|
authMethods: AuthMethod[];
|
||||||
isMfaEnabled: boolean;
|
isMfaEnabled: boolean;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { faMobile } from "@fortawesome/free-solid-svg-icons";
|
import { faMobile } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Outlet, useRouterState } from "@tanstack/react-router";
|
import { Outlet } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { Banner } from "@app/components/page-frames/Banner";
|
import { Banner } from "@app/components/page-frames/Banner";
|
||||||
import { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2";
|
import { useServerConfig, useSubscription } from "@app/context";
|
||||||
import { useServerConfig } from "@app/context";
|
import { useFetchServerStatus } from "@app/hooks/api";
|
||||||
|
import { AuditLogBanner } from "@app/layouts/OrganizationLayout/components/AuditLogBanner";
|
||||||
|
import { Navbar } from "@app/layouts/OrganizationLayout/components/NavBar";
|
||||||
|
import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBanner";
|
||||||
|
import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner";
|
||||||
|
|
||||||
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
|
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
|
||||||
import { AdminSidebar } from "./Sidebar";
|
import { AdminSidebar } from "./Sidebar";
|
||||||
@@ -13,26 +17,27 @@ import { AdminSidebar } from "./Sidebar";
|
|||||||
export const AdminLayout = () => {
|
export const AdminLayout = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useServerConfig();
|
const { config } = useServerConfig();
|
||||||
|
const { data: serverDetails, isLoading } = useFetchServerStatus();
|
||||||
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
|
|
||||||
|
|
||||||
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
|
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Banner />
|
<Banner />
|
||||||
<div className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden md:flex`}>
|
<div
|
||||||
|
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
|
||||||
|
>
|
||||||
|
<Navbar />
|
||||||
|
{!isLoading && !serverDetails?.redisConfigured && <RedisBanner />}
|
||||||
|
{!isLoading && !serverDetails?.emailConfigured && <SmtpBanner />}
|
||||||
|
{!isLoading && subscription.auditLogs && <AuditLogBanner />}
|
||||||
{!window.isSecureContext && <InsecureConnectionBanner />}
|
{!window.isSecureContext && <InsecureConnectionBanner />}
|
||||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 dark:[color-scheme:dark]">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 pt-8 dark:[color-scheme:dark]">
|
||||||
{breadcrumbs ? (
|
|
||||||
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
|
||||||
) : null}
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
|
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
|
||||||
|
|||||||
@@ -1,57 +1,62 @@
|
|||||||
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import {
|
||||||
|
faBuilding,
|
||||||
|
faChevronLeft,
|
||||||
|
faCog,
|
||||||
|
faDatabase,
|
||||||
|
faKey,
|
||||||
|
faLock,
|
||||||
|
faPlug,
|
||||||
|
faUserTie
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
import { Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
||||||
|
|
||||||
const generalTabs = [
|
const generalTabs = [
|
||||||
{
|
{
|
||||||
label: "General",
|
label: "General",
|
||||||
icon: "settings-cog",
|
icon: faCog,
|
||||||
link: "/admin/"
|
link: "/admin/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Encryption",
|
label: "Encryption",
|
||||||
icon: "lock-closed",
|
icon: faLock,
|
||||||
link: "/admin/encryption"
|
link: "/admin/encryption"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Authentication",
|
label: "Authentication",
|
||||||
icon: "check",
|
icon: faCheckCircle,
|
||||||
link: "/admin/authentication"
|
link: "/admin/authentication"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Integrations",
|
label: "Integrations",
|
||||||
icon: "sliding-carousel",
|
icon: faPlug,
|
||||||
link: "/admin/integrations"
|
link: "/admin/integrations"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Caching",
|
label: "Caching",
|
||||||
icon: "note",
|
icon: faDatabase,
|
||||||
link: "/admin/caching"
|
link: "/admin/caching"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Environment Variables",
|
label: "Environment Variables",
|
||||||
icon: "unlock",
|
icon: faKey,
|
||||||
link: "/admin/environment"
|
link: "/admin/environment"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const resourceTabs = [
|
const othersTabs = [
|
||||||
{
|
{
|
||||||
label: "Organizations",
|
label: "Access Controls",
|
||||||
icon: "groups",
|
icon: faUserTie,
|
||||||
link: "/admin/resources/organizations"
|
link: "/admin/access-management"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "User Identities",
|
label: "Resource Overview",
|
||||||
icon: "user",
|
icon: faBuilding,
|
||||||
link: "/admin/resources/user-identities"
|
link: "/admin/resources/overview"
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Machine Identities",
|
|
||||||
icon: "wrench",
|
|
||||||
link: "/admin/resources/machine-identities"
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -61,6 +66,44 @@ export const AdminSidebar = () => {
|
|||||||
return (
|
return (
|
||||||
<aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
|
<aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
|
||||||
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
|
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Menu>
|
||||||
|
<MenuGroup title="Configuration">
|
||||||
|
{generalTabs.map((tab) => {
|
||||||
|
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
||||||
|
return (
|
||||||
|
<Link key={tab.link} to={tab.link}>
|
||||||
|
<MenuItem isSelected={Boolean(isActive)}>
|
||||||
|
<div className="mx-1 flex gap-2">
|
||||||
|
<div className="w-6">
|
||||||
|
<FontAwesomeIcon icon={tab.icon} />
|
||||||
|
</div>
|
||||||
|
{tab.label}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MenuGroup>
|
||||||
|
<MenuGroup title="Others">
|
||||||
|
{othersTabs.map((tab) => {
|
||||||
|
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
||||||
|
return (
|
||||||
|
<Link key={tab.link} to={tab.link}>
|
||||||
|
<MenuItem isSelected={Boolean(isActive)}>
|
||||||
|
<div className="mx-1 flex gap-2">
|
||||||
|
<div className="w-6">
|
||||||
|
<FontAwesomeIcon icon={tab.icon} />
|
||||||
|
</div>
|
||||||
|
{tab.label}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MenuGroup>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Link to="/organization/projects">
|
<Link to="/organization/projects">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -73,50 +116,10 @@ export const AdminSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Back to organization
|
Back to Organization
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu>
|
</Menu>
|
||||||
<div className="flex-grow">
|
|
||||||
<Menu>
|
|
||||||
<MenuGroup title="General">
|
|
||||||
{generalTabs.map((tab) => {
|
|
||||||
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
|
||||||
return (
|
|
||||||
<Link key={tab.link} to={tab.link}>
|
|
||||||
<MenuItem
|
|
||||||
className="relative flex items-center gap-2 overflow-hidden rounded-none"
|
|
||||||
leftIcon={
|
|
||||||
<Lottie className="inline-block h-6 w-6 shrink-0" icon={tab.icon} />
|
|
||||||
}
|
|
||||||
isSelected={Boolean(isActive)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</MenuItem>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MenuGroup>
|
|
||||||
<MenuGroup title="Resources">
|
|
||||||
{resourceTabs.map((tab) => {
|
|
||||||
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
|
||||||
return (
|
|
||||||
<Link key={tab.link} to={tab.link}>
|
|
||||||
<MenuItem
|
|
||||||
className="relative flex items-center gap-2 overflow-hidden rounded-none"
|
|
||||||
leftIcon={
|
|
||||||
<Lottie className="inline-block h-6 w-6 shrink-0" icon={tab.icon} />
|
|
||||||
}
|
|
||||||
isSelected={Boolean(isActive)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</MenuItem>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MenuGroup>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
faEnvelope,
|
faEnvelope,
|
||||||
faInfo,
|
faInfo,
|
||||||
faInfoCircle,
|
faInfoCircle,
|
||||||
|
faServer,
|
||||||
faSignOut,
|
faSignOut,
|
||||||
faUser,
|
faUser,
|
||||||
faUsers
|
faUsers
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { Mfa } from "@app/components/auth/Mfa";
|
import { Mfa } from "@app/components/auth/Mfa";
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
@@ -117,7 +118,7 @@ export const Navbar = () => {
|
|||||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const location = useLocation();
|
||||||
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
||||||
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
|
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
|
||||||
|
|
||||||
@@ -179,6 +180,8 @@ export const Navbar = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isServerAdminPanel = location.pathname.startsWith("/admin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4">
|
<div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -187,96 +190,113 @@ export const Navbar = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||||
<div className="flex items-center">
|
{isServerAdminPanel ? (
|
||||||
<DropdownMenu modal={false}>
|
<>
|
||||||
<Link to="/organization/projects">
|
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
|
||||||
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
|
|
||||||
<div>
|
|
||||||
<FontAwesomeIcon icon={faBuilding} className="text-xs text-bunker-300" />
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{currentOrg?.name}</div>
|
|
||||||
<div className="mr-1 rounded border border-mineshaft-500 px-1 text-xs text-bunker-300 !no-underline">
|
|
||||||
{getPlan(subscription)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<div>
|
<div>
|
||||||
<IconButton
|
<FontAwesomeIcon icon={faServer} className="text-xs text-bunker-300" />
|
||||||
variant="plain"
|
|
||||||
colorSchema="secondary"
|
|
||||||
ariaLabel="switch-org"
|
|
||||||
className="px-2 py-1"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
<div className="whitespace-nowrap">Server Console</div>
|
||||||
<DropdownMenuContent
|
</div>
|
||||||
align="start"
|
<p className="pl-3 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||||
side="bottom"
|
{breadcrumbs ? (
|
||||||
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
|
<BreadcrumbContainer breadcrumbs={breadcrumbs.slice(1) as TBreadcrumbFormat[]} />
|
||||||
style={{ minWidth: "220px" }}
|
) : null}
|
||||||
>
|
</>
|
||||||
<div className="px-2 py-1 text-xs capitalize text-mineshaft-400">organizations</div>
|
) : (
|
||||||
{orgs?.map((org) => {
|
<>
|
||||||
return (
|
<div className="flex items-center">
|
||||||
<DropdownMenuItem key={org.id}>
|
<DropdownMenu modal={false}>
|
||||||
<Button
|
<Link to="/organization/projects">
|
||||||
onClick={async () => {
|
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
|
||||||
if (currentOrg?.id === org.id) return;
|
<div>
|
||||||
|
<FontAwesomeIcon icon={faBuilding} className="text-xs text-bunker-300" />
|
||||||
if (org.authEnforced) {
|
</div>
|
||||||
// org has an org-level auth method enabled (e.g. SAML)
|
<div className="whitespace-nowrap">{currentOrg?.name}</div>
|
||||||
// -> logout + redirect to SAML SSO
|
<div className="mr-1 rounded border border-mineshaft-500 px-1 text-xs text-bunker-300 !no-underline">
|
||||||
|
{getPlan(subscription)}
|
||||||
await logout.mutateAsync();
|
</div>
|
||||||
if (org.orgAuthMethod === AuthMethod.OIDC) {
|
</div>
|
||||||
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
|
</Link>
|
||||||
} else {
|
<DropdownMenuTrigger asChild>
|
||||||
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
|
<div>
|
||||||
}
|
<IconButton
|
||||||
window.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (org.googleSsoAuthEnforced) {
|
|
||||||
await logout.mutateAsync();
|
|
||||||
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
|
|
||||||
window.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleOrgChange(org?.id);
|
|
||||||
}}
|
|
||||||
variant="plain"
|
variant="plain"
|
||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
size="xs"
|
ariaLabel="switch-org"
|
||||||
className="flex w-full items-center justify-start p-0 font-normal"
|
className="px-2 py-1"
|
||||||
leftIcon={
|
|
||||||
currentOrg?.id === org.id && (
|
|
||||||
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
|
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
|
||||||
{org.name}
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
|
||||||
|
style={{ minWidth: "220px" }}
|
||||||
|
>
|
||||||
|
<div className="px-2 py-1 text-xs capitalize text-mineshaft-400">organizations</div>
|
||||||
|
{orgs?.map((org) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={org.id}>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (currentOrg?.id === org.id) return;
|
||||||
|
|
||||||
|
if (org.authEnforced) {
|
||||||
|
// org has an org-level auth method enabled (e.g. SAML)
|
||||||
|
// -> logout + redirect to SAML SSO
|
||||||
|
|
||||||
|
await logout.mutateAsync();
|
||||||
|
if (org.orgAuthMethod === AuthMethod.OIDC) {
|
||||||
|
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
|
||||||
|
} else {
|
||||||
|
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
|
||||||
|
}
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.googleSsoAuthEnforced) {
|
||||||
|
await logout.mutateAsync();
|
||||||
|
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOrgChange(org?.id);
|
||||||
|
}}
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
size="xs"
|
||||||
|
className="flex w-full items-center justify-start p-0 font-normal"
|
||||||
|
leftIcon={
|
||||||
|
currentOrg?.id === org.id && (
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
|
||||||
|
{org.name}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||||
|
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
|
||||||
|
Log Out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
</DropdownMenuContent>
|
||||||
})}
|
</DropdownMenu>
|
||||||
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
</div>
|
||||||
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
|
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||||
Log Out
|
{breadcrumbs ? (
|
||||||
</DropdownMenuItem>
|
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
||||||
</DropdownMenuContent>
|
) : null}
|
||||||
</DropdownMenu>
|
</>
|
||||||
</div>
|
)}
|
||||||
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
|
||||||
{breadcrumbs ? (
|
|
||||||
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
|
||||||
) : null}
|
|
||||||
<div className="flex-grow" />
|
<div className="flex-grow" />
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
|
|||||||
+6
-6
@@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { PageHeader } from "@app/components/v2";
|
import { PageHeader } from "@app/components/v2";
|
||||||
|
|
||||||
import { OrganizationsTable } from "./components";
|
import { ServerAdminsTable } from "./components";
|
||||||
|
|
||||||
export const OrganizationResourcesPage = () => {
|
export const AccessManagementPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-bunker-800">
|
<div className="h-full bg-bunker-800">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
<title>{t("common.head-title", { title: "Access Control" })}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Organizations"
|
title="Access Control"
|
||||||
description="Manage all organizations within your Infisical instance."
|
description="Manage server admins within your Infisical instance."
|
||||||
/>
|
/>
|
||||||
<OrganizationsTable />
|
<ServerAdminsTable />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { useDebounce } from "@app/hooks";
|
||||||
|
import { useAdminGetUsers, useAdminGrantServerAdminAccess } from "@app/hooks/api";
|
||||||
|
import { User } from "@app/hooks/api/users/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContentProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserLabel = (user: Pick<User, "email" | "firstName" | "lastName" | "username" | "id">) => {
|
||||||
|
const { firstName, lastName, username, email } = user;
|
||||||
|
|
||||||
|
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||||
|
const userEmail = email || username;
|
||||||
|
|
||||||
|
if (!name) return userEmail;
|
||||||
|
|
||||||
|
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddServerAdminSchema = z.object({
|
||||||
|
user: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
firstName: z.string().nullish(),
|
||||||
|
lastName: z.string().nullish(),
|
||||||
|
email: z.string().nullish(),
|
||||||
|
username: z.string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof AddServerAdminSchema>;
|
||||||
|
|
||||||
|
const Content = ({ onClose }: ContentProps) => {
|
||||||
|
const grantAdmin = useAdminGrantServerAdminAccess();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(AddServerAdminSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||||
|
|
||||||
|
const { data, isFetching } = useAdminGetUsers(
|
||||||
|
{
|
||||||
|
limit: 20,
|
||||||
|
searchTerm: debouncedSearchTerm,
|
||||||
|
adminsOnly: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: (prev) => prev
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]);
|
||||||
|
|
||||||
|
const onSubmit = async ({ user }: FormData) => {
|
||||||
|
try {
|
||||||
|
await grantAdmin.mutateAsync(user.id);
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully granted server admin status"
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to grant server admin status",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Controller
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
label="Assign Organization Admins"
|
||||||
|
>
|
||||||
|
<FilterableSelect
|
||||||
|
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search users..."
|
||||||
|
options={users}
|
||||||
|
getOptionLabel={(user) => getUserLabel(user)}
|
||||||
|
getOptionValue={(user) => user.id}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onInputChange={(value) => {
|
||||||
|
setSearchUserFilter(value);
|
||||||
|
if (!value) setDebouncedSearchTerm("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="user"
|
||||||
|
/>
|
||||||
|
<div className="flex w-full gap-4 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
colorSchema="secondary"
|
||||||
|
>
|
||||||
|
Grant Server Admin
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddServerAdminModal = ({ isOpen, onOpenChange }: Props) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent bodyClassName="overflow-visible" title="Grant Server Admin">
|
||||||
|
<Content onClose={() => onOpenChange(false)} />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { Dispatch, SetStateAction, useState } from "react";
|
||||||
|
import {
|
||||||
|
faEllipsisV,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faPlus,
|
||||||
|
faShieldHalved,
|
||||||
|
faTrash,
|
||||||
|
faUsers,
|
||||||
|
faUserXmark,
|
||||||
|
faWarning,
|
||||||
|
faXmark
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { InfiniteData } from "@tanstack/react-query";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
DeleteActionModal,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tooltip,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useSubscription, useUser } from "@app/context";
|
||||||
|
import { useDebounce, usePopUp } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useAdminBulkDeleteUsers,
|
||||||
|
useAdminDeleteUser,
|
||||||
|
useAdminGetUsers,
|
||||||
|
useRemoveUserServerAdminAccess
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
import { User } from "@app/hooks/api/users/types";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
import { AddServerAdminModal } from "@app/pages/admin/AccessManagementPage/components/AddServerAdminModal";
|
||||||
|
|
||||||
|
const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user";
|
||||||
|
|
||||||
|
const ServerAdminsPanelTable = ({
|
||||||
|
handlePopUpOpen,
|
||||||
|
users: usersPages,
|
||||||
|
isPending,
|
||||||
|
searchUserFilter,
|
||||||
|
setSearchUserFilter,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
selectedUsers,
|
||||||
|
setSelectedUsers
|
||||||
|
}: {
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<
|
||||||
|
["removeUser", "upgradePlan", "addServerAdmin", "removeServerAdmin"]
|
||||||
|
>,
|
||||||
|
data?: {
|
||||||
|
username: string;
|
||||||
|
id: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
users: InfiniteData<User[], unknown> | undefined;
|
||||||
|
searchUserFilter: string;
|
||||||
|
setSearchUserFilter: (filter: string) => void;
|
||||||
|
selectedUsers: User[];
|
||||||
|
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}) => {
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
|
const users = usersPages?.pages.flat();
|
||||||
|
|
||||||
|
const isEmpty = !isPending && !users?.length;
|
||||||
|
|
||||||
|
const selectedUserIds = selectedUsers.map((user) => user.id);
|
||||||
|
|
||||||
|
const isPageSelected = users?.length
|
||||||
|
? users.every((user) => selectedUserIds.includes(user.id))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
const isPageIndeterminate = isPageSelected
|
||||||
|
? false
|
||||||
|
: users?.length
|
||||||
|
? users?.some((user) => selectedUserIds.includes(user.id))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Input
|
||||||
|
value={searchUserFilter}
|
||||||
|
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search admins..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => handlePopUpOpen("addServerAdmin")}
|
||||||
|
>
|
||||||
|
Add Admin
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th className="w-5">
|
||||||
|
<Checkbox
|
||||||
|
id="member-page-select"
|
||||||
|
isChecked={isPageSelected || isPageIndeterminate}
|
||||||
|
isIndeterminate={isPageIndeterminate}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (isPageSelected) {
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.filter((u) => !users?.find((user) => user.id === u.id))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedUsers((prev) => [
|
||||||
|
...prev,
|
||||||
|
...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? [])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Th>
|
||||||
|
<Th className="w-5/12">Name</Th>
|
||||||
|
<Th className="w-1/2">Username</Th>
|
||||||
|
<Th className="w-2/12" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isPending && <TableSkeleton columns={4} innerKey="users" />}
|
||||||
|
{!isPending &&
|
||||||
|
users?.map((user) => {
|
||||||
|
const { username, email, firstName, lastName, id } = user;
|
||||||
|
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
|
||||||
|
|
||||||
|
const isSelected = selectedUserIds.includes(id);
|
||||||
|
return (
|
||||||
|
<Tr key={`user-${id}`} className="w-full">
|
||||||
|
<Td>
|
||||||
|
<Checkbox
|
||||||
|
id={`select-user-${id}`}
|
||||||
|
isChecked={isSelected}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
isSelected ? prev.filter((u) => u.id !== id) : [...prev, user]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
<Td className="w-5/12 max-w-0">
|
||||||
|
<p className="truncate">
|
||||||
|
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||||
|
</p>
|
||||||
|
</Td>
|
||||||
|
<Td className="w-5/12 max-w-0">
|
||||||
|
<p className="truncate">{username || email}</p>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="Options"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="w-6"
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisV} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent sideOffset={2} align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePopUpOpen("removeUser", { username, id });
|
||||||
|
}}
|
||||||
|
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||||
|
>
|
||||||
|
Remove User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
icon={
|
||||||
|
<div className="relative">
|
||||||
|
<FontAwesomeIcon icon={faShieldHalved} />
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className="absolute -bottom-[0.01rem] -right-1"
|
||||||
|
size="2xs"
|
||||||
|
icon={faXmark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!subscription?.instanceUserManagement) {
|
||||||
|
handlePopUpOpen("upgradePlan", {
|
||||||
|
username,
|
||||||
|
id,
|
||||||
|
message: removeServerAdminUpgradePlanMessage
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePopUpOpen("removeServerAdmin", { username, id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove Server Admin
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
|
||||||
|
</TableContainer>
|
||||||
|
{!isEmpty && (
|
||||||
|
<Button
|
||||||
|
className="mt-4 py-3 text-sm"
|
||||||
|
isFullWidth
|
||||||
|
variant="outline_bg"
|
||||||
|
isLoading={isFetchingNextPage}
|
||||||
|
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
>
|
||||||
|
{hasNextPage ? "Load More" : "End of List"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerAdminsTable = () => {
|
||||||
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
|
"removeUser",
|
||||||
|
"upgradePlan",
|
||||||
|
"addServerAdmin",
|
||||||
|
"removeServerAdmin",
|
||||||
|
"removeUsers"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const {
|
||||||
|
user: { id: userId }
|
||||||
|
} = useUser();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||||
|
const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers();
|
||||||
|
const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess();
|
||||||
|
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||||
|
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||||
|
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: users,
|
||||||
|
isPending,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage
|
||||||
|
} = useAdminGetUsers({
|
||||||
|
limit: 20,
|
||||||
|
searchTerm: debouncedSearchTerm,
|
||||||
|
adminsOnly: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveUser = async () => {
|
||||||
|
const { id } = popUp?.removeUser?.data as { id: string; username: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteUser(id);
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted user"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Error deleting user"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePopUpClose("removeUser");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveServerAdminAccess = async () => {
|
||||||
|
const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeAdminAccess(id);
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully removed server admin access from user"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Error removing server admin access from user"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePopUpClose("removeServerAdmin");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveUsers = async () => {
|
||||||
|
try {
|
||||||
|
await deleteUsers(selectedUsers.map((user) => user.id));
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully removed users",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedUsers([]);
|
||||||
|
handlePopUpClose("removeUsers");
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to remove users",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"h-0 flex-shrink-0 overflow-hidden transition-all",
|
||||||
|
selectedUsers.length > 0 && "h-16"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
|
||||||
|
<div className="mr-2 text-sm">{selectedUsers.length} Selected</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
|
||||||
|
onClick={() => setSelectedUsers([])}
|
||||||
|
>
|
||||||
|
Unselect All
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
colorSchema="danger"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedUsers?.length) return;
|
||||||
|
|
||||||
|
handlePopUpOpen("removeUsers");
|
||||||
|
}}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<ServerAdminsPanelTable
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
users={users}
|
||||||
|
selectedUsers={selectedUsers}
|
||||||
|
setSelectedUsers={setSelectedUsers}
|
||||||
|
searchUserFilter={searchUserFilter}
|
||||||
|
setSearchUserFilter={setSearchUserFilter}
|
||||||
|
isPending={isPending}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
fetchNextPage={fetchNextPage}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.removeUser.isOpen}
|
||||||
|
deleteKey="remove"
|
||||||
|
title={`Are you sure you want to delete User with username ${
|
||||||
|
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
|
||||||
|
onDeleteApproved={handleRemoveUser}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.removeServerAdmin.isOpen}
|
||||||
|
title={`Are you sure you want to remove Server Admin permissions from ${
|
||||||
|
(popUp?.removeServerAdmin?.data as { id: string; username: string })?.username || ""
|
||||||
|
}?`}
|
||||||
|
subTitle=""
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("removeServerAdmin", isOpen)}
|
||||||
|
deleteKey="confirm"
|
||||||
|
onDeleteApproved={handleRemoveServerAdminAccess}
|
||||||
|
buttonText="Remove Access"
|
||||||
|
/>
|
||||||
|
<AddServerAdminModal
|
||||||
|
isOpen={popUp.addServerAdmin.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("addServerAdmin", isOpen)}
|
||||||
|
/>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.removeUsers.isOpen}
|
||||||
|
title="Are you sure you want to remove the following users?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
|
||||||
|
deleteKey="confirm"
|
||||||
|
onDeleteApproved={() => handleRemoveUsers()}
|
||||||
|
buttonText="Remove"
|
||||||
|
>
|
||||||
|
<div className="mt-4 text-sm text-mineshaft-400">
|
||||||
|
The following members will be removed:
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
|
||||||
|
<ul className="list-disc">
|
||||||
|
{selectedUsers?.map((user) => {
|
||||||
|
const email = user.email ?? user.username;
|
||||||
|
return (
|
||||||
|
<li key={user.id}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p>
|
||||||
|
{user.firstName || user.lastName ? (
|
||||||
|
<>
|
||||||
|
{`${`${user.firstName} ${user.lastName}`.trim()} `}(
|
||||||
|
<span className="break-all">{email}</span>)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="break-all">{email}</span>
|
||||||
|
)}{" "}
|
||||||
|
</p>
|
||||||
|
{userId === user.id && (
|
||||||
|
<Tooltip content="Are you sure you want to remove yourself from this instance?">
|
||||||
|
<div className="inline-block">
|
||||||
|
<Badge
|
||||||
|
variant="primary"
|
||||||
|
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faWarning} />
|
||||||
|
<span>Removing Yourself</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</DeleteActionModal>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ServerAdminsTable";
|
||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage";
|
import { AccessManagementPage } from "./AccessManagementPage";
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities"
|
"/_authenticate/_inject-org-details/admin/_admin-layout/access-management"
|
||||||
)({
|
)({
|
||||||
component: UserIdentitiesResourcesPage,
|
component: AccessManagementPage,
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
|||||||
link: linkOptions({ to: "/admin" })
|
link: linkOptions({ to: "/admin" })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "User Identities",
|
label: "Access Control",
|
||||||
link: linkOptions({
|
link: linkOptions({
|
||||||
to: "/admin/resources/user-identities"
|
to: "/admin/access-management"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { PageHeader } from "@app/components/v2";
|
|
||||||
|
|
||||||
import { MachineIdentitiesTable } from "./components";
|
|
||||||
|
|
||||||
export const MachineIdentitiesResourcesPage = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full bg-bunker-800">
|
|
||||||
<Helmet>
|
|
||||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
|
||||||
</Helmet>
|
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
|
||||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
|
||||||
<PageHeader
|
|
||||||
title="Machine Identities"
|
|
||||||
description="Manage all machine identities within your Infisical instance."
|
|
||||||
/>
|
|
||||||
<MachineIdentitiesTable />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { MachineIdentitiesTable } from "./MachineIdentitiesTable";
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage";
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
|
||||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities"
|
|
||||||
)({
|
|
||||||
component: MachineIdentitiesResourcesPage,
|
|
||||||
beforeLoad: async () => {
|
|
||||||
return {
|
|
||||||
breadcrumbs: [
|
|
||||||
{
|
|
||||||
label: "Admin",
|
|
||||||
link: linkOptions({ to: "/admin" })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Machine Identities",
|
|
||||||
link: linkOptions({
|
|
||||||
to: "/admin/resources/machine-identities"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
faBuilding,
|
|
||||||
faCircleQuestion,
|
|
||||||
faEllipsis,
|
|
||||||
faMagnifyingGlass
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
DeleteActionModal,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
EmptyState,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalContent,
|
|
||||||
Table,
|
|
||||||
TableContainer,
|
|
||||||
TableSkeleton,
|
|
||||||
TBody,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
THead,
|
|
||||||
Tooltip,
|
|
||||||
Tr
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import { useDebounce, usePopUp } from "@app/hooks";
|
|
||||||
import {
|
|
||||||
useAdminDeleteOrganization,
|
|
||||||
useAdminDeleteOrganizationMembership,
|
|
||||||
useAdminDeleteUser,
|
|
||||||
useAdminGetOrganizations
|
|
||||||
} from "@app/hooks/api";
|
|
||||||
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
|
||||||
|
|
||||||
const ViewMembersModalContent = ({
|
|
||||||
popUp,
|
|
||||||
handlePopUpOpen
|
|
||||||
}: {
|
|
||||||
popUp: UsePopUpState<["viewMembers"]>;
|
|
||||||
handlePopUpOpen: (
|
|
||||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
|
||||||
data?: {
|
|
||||||
username?: string;
|
|
||||||
membershipId?: string;
|
|
||||||
userId?: string;
|
|
||||||
orgName?: string;
|
|
||||||
orgId?: string;
|
|
||||||
organization?: OrganizationWithProjects;
|
|
||||||
}
|
|
||||||
) => void;
|
|
||||||
}) => {
|
|
||||||
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{organization?.members?.map((member) => (
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-md bg-mineshaft-700 px-4 py-2">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-xs text-mineshaft-100">
|
|
||||||
<div>
|
|
||||||
{member.user.firstName ? (
|
|
||||||
<div>
|
|
||||||
{member.user.firstName} {member.user.lastName}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-mineshaft-400">Not set</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 opacity-80">
|
|
||||||
<div>{member.user.username || member.user.email}</div>
|
|
||||||
<Badge variant="primary">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="capitalize">{member.role.replace("-", " ")}</span>
|
|
||||||
{Boolean(member.roleId) && (
|
|
||||||
<Tooltip content="This member has a custom role assigned.">
|
|
||||||
<FontAwesomeIcon icon={faCircleQuestion} className="text-xs" />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faEllipsis}
|
|
||||||
className="cursor-pointer text-sm text-mineshaft-400 transition-all hover:text-primary-500"
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="p-1">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
handlePopUpOpen("deleteOrganizationMembership", {
|
|
||||||
membershipId: member.membershipId,
|
|
||||||
orgId: organization.id,
|
|
||||||
username: member.user.username,
|
|
||||||
orgName: organization.name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Remove From Organization
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handlePopUpOpen("deleteUser", { userId: member.user.id })}
|
|
||||||
>
|
|
||||||
Delete User
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ViewMembersModal = ({
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
popUp,
|
|
||||||
handlePopUpOpen
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
|
||||||
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
|
||||||
handlePopUpOpen: (
|
|
||||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
|
||||||
) => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<ModalContent
|
|
||||||
onOpenAutoFocus={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
title="Organization Members"
|
|
||||||
subTitle="View the members of the organization."
|
|
||||||
>
|
|
||||||
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const OrganizationsPanelTable = ({
|
|
||||||
popUp,
|
|
||||||
handlePopUpOpen,
|
|
||||||
handlePopUpToggle
|
|
||||||
}: {
|
|
||||||
popUp: UsePopUpState<
|
|
||||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
|
||||||
>;
|
|
||||||
handlePopUpOpen: (
|
|
||||||
popUpName: keyof UsePopUpState<
|
|
||||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
|
||||||
>,
|
|
||||||
data?: {
|
|
||||||
orgName?: string;
|
|
||||||
orgId?: string;
|
|
||||||
message?: string;
|
|
||||||
organization?: OrganizationWithProjects;
|
|
||||||
}
|
|
||||||
) => void;
|
|
||||||
handlePopUpToggle: (
|
|
||||||
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
|
||||||
isOpen?: boolean
|
|
||||||
) => void;
|
|
||||||
}) => {
|
|
||||||
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
|
||||||
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
|
||||||
|
|
||||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
|
||||||
useAdminGetOrganizations({
|
|
||||||
limit: 20,
|
|
||||||
searchTerm: debouncedSearchTerm
|
|
||||||
});
|
|
||||||
|
|
||||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={searchOrganizationsFilter}
|
|
||||||
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
|
||||||
placeholder="Search organizations..."
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<TableContainer>
|
|
||||||
<Table>
|
|
||||||
<THead>
|
|
||||||
<Tr>
|
|
||||||
<Th className="w-5/12">Name</Th>
|
|
||||||
<Th className="w-5/12">Members</Th>
|
|
||||||
<Th className="w-5/12">Projects</Th>
|
|
||||||
<Th className="w-5" />
|
|
||||||
</Tr>
|
|
||||||
</THead>
|
|
||||||
<TBody>
|
|
||||||
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
|
||||||
{!isPending &&
|
|
||||||
data?.pages?.map((orgs) =>
|
|
||||||
orgs.map((org) => {
|
|
||||||
return (
|
|
||||||
<Tr key={`org-${org.id}`} className="w-full">
|
|
||||||
<Td className="w-5/12">
|
|
||||||
{org.name ? (
|
|
||||||
org.name
|
|
||||||
) : (
|
|
||||||
<span className="text-mineshaft-400">Not set</span>
|
|
||||||
)}
|
|
||||||
</Td>
|
|
||||||
<Td className="w-5/12">
|
|
||||||
{org.members.length} {org.members.length === 1 ? "member" : "members"}
|
|
||||||
<Button
|
|
||||||
variant="outline_bg"
|
|
||||||
size="xs"
|
|
||||||
className="ml-2"
|
|
||||||
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
|
|
||||||
>
|
|
||||||
View Members
|
|
||||||
</Button>
|
|
||||||
</Td>
|
|
||||||
<Td className="w-5/12">
|
|
||||||
{org.projects.length} {org.projects.length === 1 ? "project" : "projects"}
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
|
||||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
|
||||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="p-1">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePopUpOpen("deleteOrganization", {
|
|
||||||
orgId: org.id,
|
|
||||||
orgName: org.name
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Organization
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TBody>
|
|
||||||
</Table>
|
|
||||||
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
|
||||||
</TableContainer>
|
|
||||||
{!isEmpty && (
|
|
||||||
<Button
|
|
||||||
className="mt-4 py-3 text-sm"
|
|
||||||
isFullWidth
|
|
||||||
variant="outline_bg"
|
|
||||||
isLoading={isFetchingNextPage}
|
|
||||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
>
|
|
||||||
{hasNextPage ? "Load More" : "End of list"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ViewMembersModal
|
|
||||||
popUp={popUp}
|
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
|
||||||
isOpen={popUp.viewMembers.isOpen}
|
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganizationsTable = () => {
|
|
||||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
|
||||||
"deleteOrganization",
|
|
||||||
"deleteOrganizationMembership",
|
|
||||||
"deleteUser",
|
|
||||||
"viewMembers"
|
|
||||||
] as const);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
|
|
||||||
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
|
|
||||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
|
||||||
|
|
||||||
const handleDeleteOrganization = async () => {
|
|
||||||
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
|
|
||||||
|
|
||||||
await deleteOrganization(orgId);
|
|
||||||
createNotification({
|
|
||||||
type: "success",
|
|
||||||
text: "Successfully deleted organization"
|
|
||||||
});
|
|
||||||
|
|
||||||
handlePopUpClose("deleteOrganization");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteOrganizationMembership = async () => {
|
|
||||||
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
|
|
||||||
orgId: string;
|
|
||||||
membershipId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!orgId || !membershipId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
|
|
||||||
createNotification({
|
|
||||||
type: "success",
|
|
||||||
text: "Successfully removed user from organization"
|
|
||||||
});
|
|
||||||
|
|
||||||
handlePopUpClose("viewMembers");
|
|
||||||
handlePopUpClose("deleteOrganizationMembership");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUser = async () => {
|
|
||||||
const { userId } = popUp?.deleteUser?.data as { userId: string };
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteUser(userId);
|
|
||||||
createNotification({
|
|
||||||
type: "success",
|
|
||||||
text: "Successfully deleted user"
|
|
||||||
});
|
|
||||||
|
|
||||||
handlePopUpClose("viewMembers");
|
|
||||||
handlePopUpClose("deleteUser");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
|
||||||
<OrganizationsPanelTable
|
|
||||||
popUp={popUp}
|
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
|
||||||
/>
|
|
||||||
<DeleteActionModal
|
|
||||||
isOpen={popUp.deleteOrganization.isOpen}
|
|
||||||
deleteKey="delete"
|
|
||||||
title={`Are you sure you want to delete organization ${
|
|
||||||
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
|
||||||
}?`}
|
|
||||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
|
||||||
onDeleteApproved={handleDeleteOrganization}
|
|
||||||
/>
|
|
||||||
<DeleteActionModal
|
|
||||||
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
|
||||||
deleteKey="delete"
|
|
||||||
title={`Are you sure you want to remove ${
|
|
||||||
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
|
||||||
} from organization ${
|
|
||||||
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
|
||||||
}?`}
|
|
||||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
|
||||||
onDeleteApproved={handleDeleteOrganizationMembership}
|
|
||||||
/>
|
|
||||||
<DeleteActionModal
|
|
||||||
isOpen={popUp.deleteUser.isOpen}
|
|
||||||
deleteKey="delete"
|
|
||||||
title={`Are you sure you want to delete user ${
|
|
||||||
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
|
||||||
}?`}
|
|
||||||
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
|
||||||
onDeleteApproved={handleDeleteUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { OrganizationsTable } from "./OrganizationsTable";
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
|
|
||||||
|
import { MachineIdentitiesTable, OrganizationsTable, UserIdentitiesTable } from "./components";
|
||||||
|
|
||||||
|
export const ResourceOverviewPage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-bunker-800">
|
||||||
|
<Helmet>
|
||||||
|
<title>{t("common.head-title", { title: "Resource Overview" })}</title>
|
||||||
|
</Helmet>
|
||||||
|
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||||
|
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||||
|
<PageHeader
|
||||||
|
title="Resource Overview"
|
||||||
|
description="Manage resources within your Infisical instance."
|
||||||
|
/>
|
||||||
|
<Tabs defaultValue="tab-organizations">
|
||||||
|
<TabList>
|
||||||
|
<Tab value="tab-organizations">Organizations</Tab>
|
||||||
|
<Tab value="tab-users">Users</Tab>
|
||||||
|
<Tab value="tab-identities">Identities</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel value="tab-organizations">
|
||||||
|
<OrganizationsTable />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="tab-users">
|
||||||
|
<UserIdentitiesTable />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="tab-identities">
|
||||||
|
<MachineIdentitiesTable />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||||
|
import { useDebounce } from "@app/hooks";
|
||||||
|
import { useAdminGetUsers, useServerAdminCreateOrganization } from "@app/hooks/api";
|
||||||
|
import { User } from "@app/hooks/api/users/types";
|
||||||
|
import { GenericResourceNameSchema } from "@app/lib/schemas";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContentProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Invitee = Pick<User, "email" | "firstName" | "lastName" | "username" | "id">;
|
||||||
|
type NewOption = { label: string; value: string };
|
||||||
|
|
||||||
|
const getUserLabel = (user: Invitee | NewOption) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(user, "value")) {
|
||||||
|
return (user as NewOption).label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName, username, email } = user as Invitee;
|
||||||
|
|
||||||
|
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||||
|
const userEmail = email || username;
|
||||||
|
|
||||||
|
if (!name) return userEmail;
|
||||||
|
|
||||||
|
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddOrgSchema = z.object({
|
||||||
|
name: GenericResourceNameSchema.nonempty("Organization name required"),
|
||||||
|
invitees: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
firstName: z.string().nullish(),
|
||||||
|
lastName: z.string().nullish(),
|
||||||
|
email: z.string().nullish(),
|
||||||
|
username: z.string().nullish()
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.min(1, "At least one admin is required")
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof AddOrgSchema>;
|
||||||
|
|
||||||
|
const Content = ({ onClose }: ContentProps) => {
|
||||||
|
const createOrg = useServerAdminCreateOrganization();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
invitees: []
|
||||||
|
},
|
||||||
|
resolver: zodResolver(AddOrgSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||||
|
|
||||||
|
const { data, isFetching } = useAdminGetUsers(
|
||||||
|
{
|
||||||
|
limit: 20,
|
||||||
|
searchTerm: debouncedSearchTerm,
|
||||||
|
adminsOnly: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: (prev) => prev
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||||
|
|
||||||
|
const onSubmit = async ({ name, invitees }: FormData) => {
|
||||||
|
try {
|
||||||
|
await createOrg.mutateAsync({
|
||||||
|
name,
|
||||||
|
inviteAdminEmails: invitees
|
||||||
|
.filter((user) => Boolean(user.email))
|
||||||
|
.map((user) => user.email) as string[]
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully created organization"
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to create organization",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { append } = useFieldArray<FormData>({ control, name: "invitees" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Controller
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl isError={Boolean(error)} errorText={error?.message} label="Name">
|
||||||
|
<Input autoFocus value={value} onChange={onChange} placeholder="My Organization" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
label="Assign Organization Admins"
|
||||||
|
>
|
||||||
|
<CreatableSelect
|
||||||
|
/* eslint-disable-next-line react/no-unstable-nested-components */
|
||||||
|
noOptionsMessage={() => (
|
||||||
|
<p>Invite new users to this organization by typing out their email address.</p>
|
||||||
|
)}
|
||||||
|
onCreateOption={(inputValue) =>
|
||||||
|
append({ id: `${inputValue}_${Math.random()}`, email: inputValue })
|
||||||
|
}
|
||||||
|
formatCreateLabel={(inputValue) => `Invite "${inputValue}"`}
|
||||||
|
isValidNewOption={(input) =>
|
||||||
|
Boolean(input) &&
|
||||||
|
z.string().email().safeParse(input).success &&
|
||||||
|
!users
|
||||||
|
?.flatMap((user) => {
|
||||||
|
const emails: string[] = [];
|
||||||
|
|
||||||
|
if (user.email) {
|
||||||
|
emails.push(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.username) {
|
||||||
|
emails.push(user.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return emails;
|
||||||
|
})
|
||||||
|
.includes(input)
|
||||||
|
}
|
||||||
|
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search users or invite new ones..."
|
||||||
|
isMulti
|
||||||
|
name="members"
|
||||||
|
options={users}
|
||||||
|
getOptionLabel={(user) => getUserLabel(user)}
|
||||||
|
getOptionValue={(user) => user.id}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onInputChange={(value) => {
|
||||||
|
setSearchUserFilter(value);
|
||||||
|
if (!value) setDebouncedSearchTerm("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="invitees"
|
||||||
|
/>
|
||||||
|
<div className="flex w-full gap-4 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
colorSchema="secondary"
|
||||||
|
>
|
||||||
|
Add Organization
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddOrganizationModal = ({ isOpen, onOpenChange }: Props) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent bodyClassName="overflow-visible" title="Add Organization">
|
||||||
|
<Content onClose={() => onOpenChange(false)} />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
-2
@@ -2,8 +2,8 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
faEllipsisV,
|
faEllipsisV,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faServer,
|
|
||||||
faShieldHalved,
|
faShieldHalved,
|
||||||
|
faWrench,
|
||||||
faXmark
|
faXmark
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -136,7 +136,7 @@ const IdentityPanelTable = ({
|
|||||||
)}
|
)}
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
|
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<Button
|
<Button
|
||||||
@@ -183,6 +183,12 @@ export const MachineIdentitiesTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">Machine Identities</p>
|
||||||
|
<p className="text-sm text-bunker-300">Manage machine identities across your instance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
|
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.removeServerAdmin.isOpen}
|
isOpen={popUp.removeServerAdmin.isOpen}
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
|
faBuilding,
|
||||||
|
faCircleQuestion,
|
||||||
|
faEllipsisV,
|
||||||
|
faEye,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faPlus,
|
||||||
|
faTrash,
|
||||||
|
faUserMinus,
|
||||||
|
faUsers,
|
||||||
|
faUserXmark,
|
||||||
|
faWarning
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
DeleteActionModal,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Pagination,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tooltip,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||||
|
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useAdminDeleteOrganization,
|
||||||
|
useAdminDeleteOrganizationMembership,
|
||||||
|
useAdminDeleteUser,
|
||||||
|
useAdminGetOrganizations,
|
||||||
|
useServerAdminResendOrgInvite
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
||||||
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
import { AddOrganizationModal } from "@app/pages/admin/ResourceOverviewPage/components/AddOrganizationModal";
|
||||||
|
|
||||||
|
enum MembersOrderBy {
|
||||||
|
Name = "firstName",
|
||||||
|
Email = "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORG_MEMBERS_TABLE_LIMIT = 15;
|
||||||
|
|
||||||
|
const ViewMembersModalContent = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpOpen
|
||||||
|
}: {
|
||||||
|
popUp: UsePopUpState<["viewMembers"]>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
||||||
|
data?: {
|
||||||
|
username?: string;
|
||||||
|
membershipId?: string;
|
||||||
|
userId?: string;
|
||||||
|
orgName?: string;
|
||||||
|
orgId?: string;
|
||||||
|
organization?: OrganizationWithProjects;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
}) => {
|
||||||
|
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
||||||
|
const [resendInviteId, setResendInviteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const members = organization?.members ?? [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
setPage,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
offset,
|
||||||
|
orderDirection,
|
||||||
|
orderBy,
|
||||||
|
setOrderBy,
|
||||||
|
setOrderDirection,
|
||||||
|
toggleOrderDirection
|
||||||
|
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
|
||||||
|
initPerPage: ORG_MEMBERS_TABLE_LIMIT
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredMembers = useMemo(
|
||||||
|
() =>
|
||||||
|
members
|
||||||
|
?.filter(
|
||||||
|
({ user: u }) =>
|
||||||
|
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u?.email?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||||
|
|
||||||
|
let valueOne: string | null;
|
||||||
|
let valueTwo: string | null;
|
||||||
|
|
||||||
|
switch (orderBy) {
|
||||||
|
case MembersOrderBy.Email:
|
||||||
|
valueOne = memberOne.user.email || memberOne.user.username;
|
||||||
|
valueTwo = memberTwo.user.email || memberOne.user.username;
|
||||||
|
break;
|
||||||
|
case MembersOrderBy.Name:
|
||||||
|
default:
|
||||||
|
valueOne = memberOne.user.firstName ?? memberOne.user.lastName;
|
||||||
|
valueTwo = memberTwo.user.firstName ?? memberTwo.user.lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueOne) return 1;
|
||||||
|
if (!valueTwo) return -1;
|
||||||
|
|
||||||
|
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
|
||||||
|
}),
|
||||||
|
[members, search, orderBy, orderDirection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSort = (column: MembersOrderBy) => {
|
||||||
|
if (column === orderBy) {
|
||||||
|
toggleOrderDirection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderBy(column);
|
||||||
|
setOrderDirection(OrderByDirection.ASC);
|
||||||
|
};
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredMembers.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
|
const resendOrgInvite = useServerAdminResendOrgInvite();
|
||||||
|
|
||||||
|
const onResendInvite = async (membershipId: string) => {
|
||||||
|
setResendInviteId(membershipId);
|
||||||
|
try {
|
||||||
|
await resendOrgInvite.mutateAsync({
|
||||||
|
membershipId,
|
||||||
|
organizationId: organization.id
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully resent org invitation",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to resend org invitation",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setResendInviteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search members..."
|
||||||
|
/>
|
||||||
|
<TableContainer className="mt-4 flex flex-1 flex-col rounded-b-none bg-mineshaft-700">
|
||||||
|
<Table className="overflow-y-auto rounded-b-none bg-mineshaft-700">
|
||||||
|
<THead className="sticky top-0 z-50">
|
||||||
|
<Tr>
|
||||||
|
<Th className="w-1/3 border-none bg-mineshaft-700 p-0">
|
||||||
|
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||||
|
Name
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(MembersOrderBy.Name)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={
|
||||||
|
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
|
||||||
|
? faArrowUp
|
||||||
|
: faArrowDown
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
<Th className="w-1/2 border-none bg-mineshaft-700 p-0">
|
||||||
|
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||||
|
Email
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(MembersOrderBy.Email)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={
|
||||||
|
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
|
||||||
|
? faArrowUp
|
||||||
|
: faArrowDown
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
<Th className="w-1/5 border-none bg-mineshaft-700 p-0">
|
||||||
|
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||||
|
Role
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
<Th className="w-5 border-none bg-mineshaft-700 p-0">
|
||||||
|
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5" />
|
||||||
|
</Th>
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{filteredMembers.slice(offset, perPage * page).map((member) => {
|
||||||
|
const { username, email, firstName, lastName, id } = member.user;
|
||||||
|
const { role, status } = member;
|
||||||
|
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr key={`user-${id}`} className="w-full">
|
||||||
|
<Td className="max-w-0">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="truncate">
|
||||||
|
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td className="max-w-0">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="truncate">{username || email}</p>
|
||||||
|
{role === OrgMembershipRole.Admin && status !== "accepted" && (
|
||||||
|
<Button
|
||||||
|
isDisabled={resendOrgInvite.isPending}
|
||||||
|
className="ml-2 h-8 border-mineshaft-600 bg-mineshaft-800/50 font-normal"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
isLoading={
|
||||||
|
resendOrgInvite.isPending && resendInviteId === member.membershipId
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
onResendInvite(member.membershipId);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resend Invite
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge className="flex w-min items-center gap-x-1 whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
|
||||||
|
<span className="capitalize">{member.role.replace("-", " ")}</span>
|
||||||
|
{Boolean(member.roleId) && (
|
||||||
|
<Tooltip content="This member has a custom role assigned.">
|
||||||
|
<FontAwesomeIcon icon={faCircleQuestion} className="w-3" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="Options"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="w-6"
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisV} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent sideOffset={2} align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
icon={<FontAwesomeIcon icon={faUserMinus} />}
|
||||||
|
onClick={() =>
|
||||||
|
handlePopUpOpen("deleteOrganizationMembership", {
|
||||||
|
membershipId: member.membershipId,
|
||||||
|
orgId: organization.id,
|
||||||
|
username: member.user.username,
|
||||||
|
orgName: organization.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove From Organization
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||||
|
onClick={() =>
|
||||||
|
handlePopUpOpen("deleteUser", { userId: member.user.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{!filteredMembers.length && (
|
||||||
|
<EmptyState
|
||||||
|
className="my-auto bg-mineshaft-700"
|
||||||
|
title={
|
||||||
|
members.length
|
||||||
|
? "No organization members match search..."
|
||||||
|
: "No organization members found"
|
||||||
|
}
|
||||||
|
icon={faUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
{Boolean(filteredMembers.length) && (
|
||||||
|
<Pagination
|
||||||
|
className="rounded-b-md bg-mineshaft-700"
|
||||||
|
count={filteredMembers.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
perPageList={[ORG_MEMBERS_TABLE_LIMIT]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ViewMembersModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
popUp,
|
||||||
|
handlePopUpOpen
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
||||||
|
) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
title="Organization Members"
|
||||||
|
subTitle="View the members of the organization."
|
||||||
|
className="h-full max-w-4xl"
|
||||||
|
bodyClassName="flex flex-col h-full"
|
||||||
|
>
|
||||||
|
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationsPanelTable = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpOpen,
|
||||||
|
handlePopUpToggle
|
||||||
|
}: {
|
||||||
|
popUp: UsePopUpState<
|
||||||
|
[
|
||||||
|
"deleteOrganization",
|
||||||
|
"viewMembers",
|
||||||
|
"deleteOrganizationMembership",
|
||||||
|
"deleteUser",
|
||||||
|
"createOrganization"
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<
|
||||||
|
[
|
||||||
|
"deleteOrganization",
|
||||||
|
"viewMembers",
|
||||||
|
"deleteOrganizationMembership",
|
||||||
|
"deleteUser",
|
||||||
|
"createOrganization"
|
||||||
|
]
|
||||||
|
>,
|
||||||
|
data?: {
|
||||||
|
orgName?: string;
|
||||||
|
orgId?: string;
|
||||||
|
message?: string;
|
||||||
|
organization?: OrganizationWithProjects;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
handlePopUpToggle: (
|
||||||
|
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
||||||
|
isOpen?: boolean
|
||||||
|
) => void;
|
||||||
|
}) => {
|
||||||
|
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
||||||
|
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
||||||
|
|
||||||
|
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
|
useAdminGetOrganizations({
|
||||||
|
limit: 20,
|
||||||
|
searchTerm: debouncedSearchTerm
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={searchOrganizationsFilter}
|
||||||
|
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search organizations..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th className="w-5/12">Name</Th>
|
||||||
|
<Th className="w-5/12">Members</Th>
|
||||||
|
<Th className="w-5/12">Projects</Th>
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
||||||
|
{!isPending &&
|
||||||
|
data?.pages?.map((orgs) =>
|
||||||
|
orgs.map((org) => {
|
||||||
|
return (
|
||||||
|
<Tr key={`org-${org.id}`} className="w-full">
|
||||||
|
<Td className="w-5/12">
|
||||||
|
{org.name ? (
|
||||||
|
org.name
|
||||||
|
) : (
|
||||||
|
<span className="text-mineshaft-400">Not Set</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td className="w-5/12">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{org.members.length} {org.members.length === 1 ? "Member" : "Members"}
|
||||||
|
<Tooltip content="View Members">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="View Members"
|
||||||
|
variant="plain"
|
||||||
|
size="xs"
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() =>
|
||||||
|
handlePopUpOpen("viewMembers", { organization: org })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEye} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{!org.members.some(
|
||||||
|
(member) =>
|
||||||
|
member.role === OrgMembershipRole.Admin &&
|
||||||
|
member.status === "accepted"
|
||||||
|
) && (
|
||||||
|
<Tooltip content="No admins have accepted their invites.">
|
||||||
|
<div className="ml-1">
|
||||||
|
<Badge>
|
||||||
|
<FontAwesomeIcon icon={faWarning} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td className="w-5/12">
|
||||||
|
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="Options"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="w-6"
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisV} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent sideOffset={2} align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePopUpOpen("deleteOrganization", {
|
||||||
|
orgId: org.id,
|
||||||
|
orgName: org.name
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
>
|
||||||
|
Delete Organization
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
||||||
|
</TableContainer>
|
||||||
|
{!isEmpty && (
|
||||||
|
<Button
|
||||||
|
className="mt-4 py-3 text-sm"
|
||||||
|
isFullWidth
|
||||||
|
variant="outline_bg"
|
||||||
|
isLoading={isFetchingNextPage}
|
||||||
|
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
>
|
||||||
|
{hasNextPage ? "Load More" : "End of list"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ViewMembersModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
isOpen={popUp.viewMembers.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationsTable = () => {
|
||||||
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
|
"deleteOrganization",
|
||||||
|
"deleteOrganizationMembership",
|
||||||
|
"deleteUser",
|
||||||
|
"viewMembers",
|
||||||
|
"createOrganization"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
|
||||||
|
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
|
||||||
|
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||||
|
|
||||||
|
const handleDeleteOrganization = async () => {
|
||||||
|
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
|
||||||
|
|
||||||
|
await deleteOrganization(orgId);
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted organization"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("deleteOrganization");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteOrganizationMembership = async () => {
|
||||||
|
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
|
||||||
|
orgId: string;
|
||||||
|
membershipId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!orgId || !membershipId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully removed user from organization"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("viewMembers");
|
||||||
|
handlePopUpClose("deleteOrganizationMembership");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
const { userId } = popUp?.deleteUser?.data as { userId: string };
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUser(userId);
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted user"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("viewMembers");
|
||||||
|
handlePopUpClose("deleteUser");
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">Organizations</p>
|
||||||
|
<p className="text-sm text-bunker-300">
|
||||||
|
Manage, join and view organizations across your instance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
onClick={() => handlePopUpOpen("createOrganization")}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<OrganizationsPanelTable
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteOrganization.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={`Are you sure you want to delete organization ${
|
||||||
|
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
||||||
|
onDeleteApproved={handleDeleteOrganization}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={`Are you sure you want to remove ${
|
||||||
|
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
||||||
|
} from organization ${
|
||||||
|
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
||||||
|
onDeleteApproved={handleDeleteOrganizationMembership}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteUser.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={`Are you sure you want to delete user ${
|
||||||
|
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
||||||
|
onDeleteApproved={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
<AddOrganizationModal
|
||||||
|
isOpen={popUp.createOrganization.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("createOrganization", isOpen)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+7
-1
@@ -218,7 +218,7 @@ const UserPanelTable = ({
|
|||||||
</div>
|
</div>
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="w-5/12 max-w-0">
|
<Td className="w-5/12 max-w-0">
|
||||||
<p className="truncate">{email}</p>
|
<p className="truncate">{username || email}</p>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -463,6 +463,12 @@ export const UserIdentitiesTable = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">User Identities</p>
|
||||||
|
<p className="text-sm text-bunker-300">Manage user identities across your instance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<UserPanelTable
|
<UserPanelTable
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
users={users}
|
users={users}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./MachineIdentitiesTable";
|
||||||
|
export * from "./OrganizationsTable";
|
||||||
|
export * from "./UserIdentitiesTable";
|
||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { OrganizationResourcesPage } from "./OrganizationResourcesPage";
|
import { ResourceOverviewPage } from "./ResourceOverviewPage";
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations"
|
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview"
|
||||||
)({
|
)({
|
||||||
component: OrganizationResourcesPage,
|
component: ResourceOverviewPage,
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
|||||||
link: linkOptions({ to: "/admin" })
|
link: linkOptions({ to: "/admin" })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Organizations",
|
label: "Resource Overview",
|
||||||
link: linkOptions({
|
link: linkOptions({
|
||||||
to: "/admin/resources/organizations"
|
to: "/admin/resources/overview"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { PageHeader } from "@app/components/v2";
|
|
||||||
|
|
||||||
import { UserIdentitiesTable } from "./components";
|
|
||||||
|
|
||||||
export const UserIdentitiesResourcesPage = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full bg-bunker-800">
|
|
||||||
<Helmet>
|
|
||||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
|
||||||
</Helmet>
|
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
|
||||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
|
||||||
<PageHeader
|
|
||||||
title="User Identities"
|
|
||||||
description="Manage all user identities within your Infisical instance."
|
|
||||||
/>
|
|
||||||
<UserIdentitiesTable />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { UserIdentitiesTable } from "./UserIdentitiesTable";
|
|
||||||
@@ -15,7 +15,8 @@ import { setAuthToken } from "@app/hooks/api/reactQuery";
|
|||||||
|
|
||||||
const QueryParamsSchema = z.object({
|
const QueryParamsSchema = z.object({
|
||||||
callback_port: z.coerce.number().optional().catch(undefined),
|
callback_port: z.coerce.number().optional().catch(undefined),
|
||||||
force: z.boolean().optional()
|
force: z.boolean().optional(),
|
||||||
|
org_id: z.string().optional().catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AuthConsentWrapper = () => {
|
export const AuthConsentWrapper = () => {
|
||||||
@@ -102,6 +103,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (search.org_id) {
|
||||||
|
if (location.pathname.endsWith("select-organization")) return;
|
||||||
|
|
||||||
|
throw redirect({ to: "/login/select-organization", search: { org_id: search.org_id } });
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.organizationId) {
|
if (!data.organizationId) {
|
||||||
if (
|
if (
|
||||||
location.pathname.endsWith("select-organization") ||
|
location.pathname.endsWith("select-organization") ||
|
||||||
|
|||||||
+1
-1
@@ -93,7 +93,7 @@ export const AccessApprovalRequest = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedRequest, setSelectedRequest] = useState<
|
const [selectedRequest, setSelectedRequest] = useState<
|
||||||
| (TAccessApprovalRequest & {
|
| (TAccessApprovalRequest & {
|
||||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||||
isRequestedByCurrentUser: boolean;
|
isRequestedByCurrentUser: boolean;
|
||||||
isSelfApproveAllowed: boolean;
|
isSelfApproveAllowed: boolean;
|
||||||
isApprover: boolean;
|
isApprover: boolean;
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ export const ReviewAccessRequestModal = ({
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
request: TAccessApprovalRequest & {
|
request: TAccessApprovalRequest & {
|
||||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||||
isRequestedByCurrentUser: boolean;
|
isRequestedByCurrentUser: boolean;
|
||||||
isSelfApproveAllowed: boolean;
|
isSelfApproveAllowed: boolean;
|
||||||
isApprover: boolean;
|
isApprover: boolean;
|
||||||
|
|||||||
+4574
-4767
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,8 @@ const adminRoute = route("/admin", [
|
|||||||
route("/environment", "admin/EnvironmentPage/route.tsx"),
|
route("/environment", "admin/EnvironmentPage/route.tsx"),
|
||||||
route("/integrations", "admin/IntegrationsPage/route.tsx"),
|
route("/integrations", "admin/IntegrationsPage/route.tsx"),
|
||||||
route("/caching", "admin/CachingPage/route.tsx"),
|
route("/caching", "admin/CachingPage/route.tsx"),
|
||||||
route("/resources/organizations", "admin/OrganizationResourcesPage/route.tsx"),
|
route("/resources/overview", "admin/ResourceOverviewPage/route.tsx"),
|
||||||
route("/resources/user-identities", "admin/UserIdentitiesResourcesPage/route.tsx"),
|
route("/access-management", "admin/AccessManagementPage/route.tsx")
|
||||||
route("/resources/machine-identities", "admin/MachineIdentitiesResourcesPage/route.tsx")
|
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user