mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +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,
|
||||
kmsService,
|
||||
microsoftTeamsService,
|
||||
invalidateCacheQueue
|
||||
invalidateCacheQueue,
|
||||
smtpService,
|
||||
tokenService
|
||||
});
|
||||
|
||||
const orgAdminService = orgAdminServiceFactory({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
|
||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
@@ -215,7 +216,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
}),
|
||||
membershipId: z.string(),
|
||||
role: z.string(),
|
||||
roleId: z.string().nullish()
|
||||
roleId: z.string().nullish(),
|
||||
status: z.string().nullish()
|
||||
})
|
||||
.array(),
|
||||
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("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
|
||||
.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"));
|
||||
|
||||
const formattedDocs = sqlNestRelationships({
|
||||
@@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => {
|
||||
orgMembershipId,
|
||||
orgMembershipRole,
|
||||
orgMembershipRoleName,
|
||||
orgMembershipRoleId
|
||||
orgMembershipRoleId,
|
||||
orgMembershipStatus
|
||||
}) => ({
|
||||
user: {
|
||||
id: userId,
|
||||
@@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
||||
firstName,
|
||||
lastName
|
||||
},
|
||||
status: orgMembershipStatus,
|
||||
membershipId: orgMembershipId,
|
||||
role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name
|
||||
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) => {
|
||||
try {
|
||||
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
|
||||
@@ -668,6 +680,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
||||
findMembership,
|
||||
findMembershipWithScimFilter,
|
||||
createMembership,
|
||||
bulkCreateMemberships,
|
||||
updateMembershipById,
|
||||
deleteMembershipById,
|
||||
deleteMembershipsById,
|
||||
|
||||
@@ -528,15 +528,18 @@ export const orgServiceFactory = ({
|
||||
/*
|
||||
* Create organization
|
||||
* */
|
||||
const createOrganization = async ({
|
||||
userId,
|
||||
userEmail,
|
||||
orgName
|
||||
}: {
|
||||
userId: string;
|
||||
orgName: string;
|
||||
userEmail?: string | null;
|
||||
}) => {
|
||||
const createOrganization = async (
|
||||
{
|
||||
userId,
|
||||
userEmail,
|
||||
orgName
|
||||
}: {
|
||||
userId?: string;
|
||||
orgName: string;
|
||||
userEmail?: string | null;
|
||||
},
|
||||
trx?: Knex
|
||||
) => {
|
||||
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
|
||||
const key = crypto.randomBytes(32).toString("base64");
|
||||
const {
|
||||
@@ -555,22 +558,25 @@ export const orgServiceFactory = ({
|
||||
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
|
||||
|
||||
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
|
||||
const org = await orgDAL.create(
|
||||
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
|
||||
tx
|
||||
);
|
||||
await orgDAL.createMembership(
|
||||
{
|
||||
userId,
|
||||
orgId: org.id,
|
||||
role: OrgMembershipRole.Admin,
|
||||
status: OrgMembershipStatus.Accepted,
|
||||
isActive: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (userId) {
|
||||
await orgDAL.createMembership(
|
||||
{
|
||||
userId,
|
||||
orgId: org.id,
|
||||
role: OrgMembershipRole.Admin,
|
||||
status: OrgMembershipStatus.Accepted,
|
||||
isActive: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
await orgBotDAL.create(
|
||||
{
|
||||
name: org.name,
|
||||
@@ -590,7 +596,9 @@ export const orgServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
return org;
|
||||
});
|
||||
};
|
||||
|
||||
const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg));
|
||||
|
||||
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
|
||||
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: "jane@infisical.com",
|
||||
siteUrl: "https://infisical.com",
|
||||
callback_url: "https://app.infisical.com"
|
||||
} as OrganizationAssignmentTemplateProps;
|
||||
@@ -9,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate";
|
||||
export * from "./NewDeviceLoginTemplate";
|
||||
export * from "./OrgAdminBreakglassAccessTemplate";
|
||||
export * from "./OrgAdminProjectGrantAccessTemplate";
|
||||
export * from "./OrganizationAssignmentTemplate";
|
||||
export * from "./OrganizationInvitationTemplate";
|
||||
export * from "./PasswordResetTemplate";
|
||||
export * from "./PasswordSetupTemplate";
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
NewDeviceLoginTemplate,
|
||||
OrgAdminBreakglassAccessTemplate,
|
||||
OrgAdminProjectGrantAccessTemplate,
|
||||
OrganizationAssignmentTemplate,
|
||||
OrganizationInvitationTemplate,
|
||||
PasswordResetTemplate,
|
||||
PasswordSetupTemplate,
|
||||
@@ -61,6 +62,7 @@ export enum SmtpTemplates {
|
||||
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
||||
NewDeviceJoin = "newDevice",
|
||||
OrgInvite = "organizationInvitation",
|
||||
OrgAssignment = "organizationAssignment",
|
||||
ResetPassword = "passwordReset",
|
||||
SetupPassword = "passwordSetup",
|
||||
SecretLeakIncident = "secretLeakIncident",
|
||||
@@ -94,6 +96,7 @@ export enum SmtpHost {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
||||
[SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate,
|
||||
[SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate,
|
||||
[SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate,
|
||||
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
||||
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
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 { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import {
|
||||
@@ -13,7 +20,12 @@ import {
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
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 { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
|
||||
import { TAuthLoginFactory } from "../auth/auth-login-service";
|
||||
import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
|
||||
@@ -43,7 +55,9 @@ import {
|
||||
TAdminGetUsersDTO,
|
||||
TAdminIntegrationConfig,
|
||||
TAdminSignUpDTO,
|
||||
TGetOrganizationsDTO
|
||||
TCreateOrganizationDTO,
|
||||
TGetOrganizationsDTO,
|
||||
TResendOrgInviteDTO
|
||||
} from "./super-admin-types";
|
||||
|
||||
type TSuperAdminServiceFactoryDep = {
|
||||
@@ -59,11 +73,13 @@ type TSuperAdminServiceFactoryDep = {
|
||||
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
|
||||
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">;
|
||||
kmsRootConfigDAL: TKmsRootConfigDALFactory;
|
||||
orgService: Pick<TOrgServiceFactory, "createOrganization">;
|
||||
orgService: Pick<TOrgServiceFactory, "createOrganization" | "inviteUserToOrganization">;
|
||||
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">;
|
||||
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">;
|
||||
invalidateCacheQueue: TInvalidateCacheQueueFactory;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
tokenService: TAuthTokenServiceFactory;
|
||||
};
|
||||
|
||||
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
|
||||
@@ -123,7 +139,9 @@ export const superAdminServiceFactory = ({
|
||||
identityTokenAuthDAL,
|
||||
identityOrgMembershipDAL,
|
||||
microsoftTeamsService,
|
||||
invalidateCacheQueue
|
||||
invalidateCacheQueue,
|
||||
smtpService,
|
||||
tokenService
|
||||
}: TSuperAdminServiceFactoryDep) => {
|
||||
const initServerCfg = async () => {
|
||||
// TODO(akhilmhdh): bad pattern time less change this later to me itself
|
||||
@@ -732,6 +750,157 @@ export const superAdminServiceFactory = ({
|
||||
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 organization = await orgDAL.deleteById(organizationId);
|
||||
return organization;
|
||||
@@ -763,6 +932,56 @@ export const superAdminServiceFactory = ({
|
||||
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 identities = await identityDAL.getIdentitiesByFilter({
|
||||
limit,
|
||||
@@ -901,6 +1120,8 @@ export const superAdminServiceFactory = ({
|
||||
initializeEnvConfigSync,
|
||||
getEnvOverrides,
|
||||
getEnvOverridesOrganized,
|
||||
deleteUsers
|
||||
deleteUsers,
|
||||
createOrganization,
|
||||
resendOrgInvite
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,6 +34,16 @@ export type TGetOrganizationsDTO = {
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export type TCreateOrganizationDTO = {
|
||||
name: string;
|
||||
inviteAdminEmails: string[];
|
||||
};
|
||||
|
||||
export type TResendOrgInviteDTO = {
|
||||
organizationId: string;
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
export enum LoginMethod {
|
||||
EMAIL = "email",
|
||||
GOOGLE = "google",
|
||||
|
||||
@@ -60,7 +60,8 @@ export const CreatableSelect = <T,>({
|
||||
isSelected && "text-mineshaft-200",
|
||||
"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}
|
||||
/>
|
||||
|
||||
@@ -91,7 +91,7 @@ export const FilterableSelect = <T,>({
|
||||
),
|
||||
placeholder: () =>
|
||||
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
|
||||
input: () => "pl-1",
|
||||
input: () => `pl-1 ${isMulti ? "py-[0.22rem]" : ""}`,
|
||||
valueContainer: () =>
|
||||
`px-1 max-h-[8.2rem] ${
|
||||
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
|
||||
@@ -114,7 +114,8 @@ export const FilterableSelect = <T,>({
|
||||
isSelected && "text-mineshaft-200",
|
||||
"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}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { Organization } from "@app/hooks/api/organization/types";
|
||||
|
||||
import { organizationKeys } from "../organization/queries";
|
||||
import { User } from "../users/types";
|
||||
@@ -8,7 +9,9 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries";
|
||||
import {
|
||||
RootKeyEncryptionStrategy,
|
||||
TCreateAdminUserDTO,
|
||||
TCreateOrganizationDTO,
|
||||
TInvalidateCacheDTO,
|
||||
TResendOrgInviteDTO,
|
||||
TServerConfig,
|
||||
TUpdateServerConfigDTO
|
||||
} 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 { Identity } from "@app/hooks/api/identities/types";
|
||||
@@ -25,8 +32,8 @@ export const adminStandaloneKeys = {
|
||||
export const adminQueryKeys = {
|
||||
serverConfig: () => ["server-config"] as const,
|
||||
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
||||
getOrganizations: (filters: AdminGetOrganizationsFilters) =>
|
||||
[adminStandaloneKeys.getOrganizations, { filters }] as const,
|
||||
getOrganizations: (filters?: AdminGetOrganizationsFilters) =>
|
||||
[adminStandaloneKeys.getOrganizations, ...(filters ? [{ filters }] : [])] as const,
|
||||
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
||||
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
||||
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
||||
@@ -83,7 +90,18 @@ export const useGetServerConfig = ({
|
||||
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({
|
||||
initialPageParam: 0,
|
||||
queryKey: adminQueryKeys.getUsers(filters),
|
||||
@@ -101,7 +119,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
|
||||
return data.users;
|
||||
},
|
||||
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;
|
||||
};
|
||||
membershipId: string;
|
||||
status: string;
|
||||
role: string;
|
||||
roleId: string | null;
|
||||
}[];
|
||||
@@ -142,3 +143,13 @@ export interface TGetEnvOverrides {
|
||||
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;
|
||||
updatedAt: Date;
|
||||
username: string;
|
||||
email?: string;
|
||||
email?: string | null;
|
||||
superAdmin: boolean;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
authProvider?: AuthMethod;
|
||||
authMethods: AuthMethod[];
|
||||
isMfaEnabled: boolean;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faMobile } from "@fortawesome/free-solid-svg-icons";
|
||||
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 { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2";
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { useServerConfig, useSubscription } 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 { AdminSidebar } from "./Sidebar";
|
||||
@@ -13,26 +17,27 @@ import { AdminSidebar } from "./Sidebar";
|
||||
export const AdminLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useServerConfig();
|
||||
|
||||
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
||||
|
||||
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
|
||||
const { data: serverDetails, isLoading } = useFetchServerStatus();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 />}
|
||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 dark:[color-scheme:dark]">
|
||||
{breadcrumbs ? (
|
||||
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
||||
) : null}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 pt-8 dark:[color-scheme:dark]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 { 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 = [
|
||||
{
|
||||
label: "General",
|
||||
icon: "settings-cog",
|
||||
icon: faCog,
|
||||
link: "/admin/"
|
||||
},
|
||||
{
|
||||
label: "Encryption",
|
||||
icon: "lock-closed",
|
||||
icon: faLock,
|
||||
link: "/admin/encryption"
|
||||
},
|
||||
{
|
||||
label: "Authentication",
|
||||
icon: "check",
|
||||
icon: faCheckCircle,
|
||||
link: "/admin/authentication"
|
||||
},
|
||||
{
|
||||
label: "Integrations",
|
||||
icon: "sliding-carousel",
|
||||
icon: faPlug,
|
||||
link: "/admin/integrations"
|
||||
},
|
||||
{
|
||||
label: "Caching",
|
||||
icon: "note",
|
||||
icon: faDatabase,
|
||||
link: "/admin/caching"
|
||||
},
|
||||
{
|
||||
label: "Environment Variables",
|
||||
icon: "unlock",
|
||||
icon: faKey,
|
||||
link: "/admin/environment"
|
||||
}
|
||||
];
|
||||
|
||||
const resourceTabs = [
|
||||
const othersTabs = [
|
||||
{
|
||||
label: "Organizations",
|
||||
icon: "groups",
|
||||
link: "/admin/resources/organizations"
|
||||
label: "Access Controls",
|
||||
icon: faUserTie,
|
||||
link: "/admin/access-management"
|
||||
},
|
||||
{
|
||||
label: "User Identities",
|
||||
icon: "user",
|
||||
link: "/admin/resources/user-identities"
|
||||
},
|
||||
{
|
||||
label: "Machine Identities",
|
||||
icon: "wrench",
|
||||
link: "/admin/resources/machine-identities"
|
||||
label: "Resource Overview",
|
||||
icon: faBuilding,
|
||||
link: "/admin/resources/overview"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -61,6 +66,44 @@ export const AdminSidebar = () => {
|
||||
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">
|
||||
<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>
|
||||
<Link to="/organization/projects">
|
||||
<MenuItem
|
||||
@@ -73,50 +116,10 @@ export const AdminSidebar = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
Back to organization
|
||||
Back to Organization
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
faEnvelope,
|
||||
faInfo,
|
||||
faInfoCircle,
|
||||
faServer,
|
||||
faSignOut,
|
||||
faUser,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 { createNotification } from "@app/components/notifications";
|
||||
@@ -117,7 +118,7 @@ export const Navbar = () => {
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const location = useLocation();
|
||||
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
||||
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
|
||||
|
||||
@@ -179,6 +180,8 @@ export const Navbar = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isServerAdminPanel = location.pathname.startsWith("/admin");
|
||||
|
||||
return (
|
||||
<div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4">
|
||||
<div>
|
||||
@@ -187,96 +190,113 @@ export const Navbar = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
<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>
|
||||
{isServerAdminPanel ? (
|
||||
<>
|
||||
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
|
||||
<div>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="switch-org"
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
|
||||
</IconButton>
|
||||
<FontAwesomeIcon icon={faServer} className="text-xs text-bunker-300" />
|
||||
</div>
|
||||
</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);
|
||||
}}
|
||||
<div className="whitespace-nowrap">Server Console</div>
|
||||
</div>
|
||||
<p className="pl-3 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||
{breadcrumbs ? (
|
||||
<BreadcrumbContainer breadcrumbs={breadcrumbs.slice(1) as TBreadcrumbFormat[]} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
<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>
|
||||
<IconButton
|
||||
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" />
|
||||
)
|
||||
}
|
||||
ariaLabel="switch-org"
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
|
||||
{org.name}
|
||||
</div>
|
||||
</Button>
|
||||
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
|
||||
Log Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||
{breadcrumbs ? (
|
||||
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</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" />
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
|
||||
@@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { OrganizationsTable } from "./components";
|
||||
import { ServerAdminsTable } from "./components";
|
||||
|
||||
export const OrganizationResourcesPage = () => {
|
||||
export const AccessManagementPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
<title>{t("common.head-title", { title: "Access Control" })}</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="Organizations"
|
||||
description="Manage all organizations within your Infisical instance."
|
||||
title="Access Control"
|
||||
description="Manage server admins within your Infisical instance."
|
||||
/>
|
||||
<OrganizationsTable />
|
||||
<ServerAdminsTable />
|
||||
</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";
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage";
|
||||
import { AccessManagementPage } from "./AccessManagementPage";
|
||||
|
||||
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 () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "User Identities",
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/user-identities"
|
||||
to: "/admin/access-management"
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import {
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faServer,
|
||||
faShieldHalved,
|
||||
faWrench,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -136,7 +136,7 @@ const IdentityPanelTable = ({
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
@@ -183,6 +183,12 @@ export const MachineIdentitiesTable = () => {
|
||||
|
||||
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">Machine Identities</p>
|
||||
<p className="text-sm text-bunker-300">Manage machine identities across your instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<DeleteActionModal
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -218,7 +218,7 @@ const UserPanelTable = ({
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">{email}</p>
|
||||
<p className="truncate">{username || email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
@@ -463,6 +463,12 @@ export const UserIdentitiesTable = () => {
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
users={users}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./MachineIdentitiesTable";
|
||||
export * from "./OrganizationsTable";
|
||||
export * from "./UserIdentitiesTable";
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { OrganizationResourcesPage } from "./OrganizationResourcesPage";
|
||||
import { ResourceOverviewPage } from "./ResourceOverviewPage";
|
||||
|
||||
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 () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "Organizations",
|
||||
label: "Resource Overview",
|
||||
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({
|
||||
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 = () => {
|
||||
@@ -102,6 +103,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({
|
||||
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 (
|
||||
location.pathname.endsWith("select-organization") ||
|
||||
|
||||
@@ -93,7 +93,7 @@ export const AccessApprovalRequest = ({
|
||||
}) => {
|
||||
const [selectedRequest, setSelectedRequest] = useState<
|
||||
| (TAccessApprovalRequest & {
|
||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isSelfApproveAllowed: boolean;
|
||||
isApprover: boolean;
|
||||
|
||||
@@ -85,7 +85,7 @@ export const ReviewAccessRequestModal = ({
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
request: TAccessApprovalRequest & {
|
||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isSelfApproveAllowed: boolean;
|
||||
isApprover: boolean;
|
||||
|
||||
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("/integrations", "admin/IntegrationsPage/route.tsx"),
|
||||
route("/caching", "admin/CachingPage/route.tsx"),
|
||||
route("/resources/organizations", "admin/OrganizationResourcesPage/route.tsx"),
|
||||
route("/resources/user-identities", "admin/UserIdentitiesResourcesPage/route.tsx"),
|
||||
route("/resources/machine-identities", "admin/MachineIdentitiesResourcesPage/route.tsx")
|
||||
route("/resources/overview", "admin/ResourceOverviewPage/route.tsx"),
|
||||
route("/access-management", "admin/AccessManagementPage/route.tsx")
|
||||
])
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user