Compare commits

...

20 Commits

Author SHA1 Message Date
Sheen Capadngan
2f29a513cc misc: make index creation concurrently 2025-07-01 03:36:55 +08:00
Sheen Capadngan
978a3e5828 misc: add indices for referencing columns in identity access token 2025-07-01 01:25:11 +08:00
Scott Wilson
27bf91e58f Merge pull request #3873 from Infisical/org-access-control-improvements
improvement(org-access-control): Standardize and improve org access control UI
2025-06-30 09:54:42 -07:00
Scott Wilson
f2c3c76c60 improvement: address feedback on remove rule policy edit 2025-06-30 09:21:00 -07:00
Scott Wilson
85023916e4 improvement: address feedback 2025-06-30 09:12:47 -07:00
Akhil Mohan
02afd6a8e7 Merge pull request #3882 from Infisical/feat/fix-access-token-ips
feat: resolved inefficient join for ip restriction in access token
2025-06-30 21:22:28 +05:30
=
929eac4350 feat: resolved inefficient join for ip restriction in access token 2025-06-30 20:13:26 +05:30
Vlad Matsiiako
c6074dd69a Merge pull request #3881 from Infisical/docs-update
update spend policy
2025-06-29 18:10:54 -07:00
Vladyslav Matsiiako
a9b26755ba update spend policy 2025-06-29 17:43:05 -07:00
Vlad Matsiiako
033e5d3f81 Merge pull request #3880 from Infisical/docs-update
update logos in docs
2025-06-28 16:38:05 -07:00
Vladyslav Matsiiako
90634e1913 update logos in docs 2025-06-28 16:26:58 -07:00
x032205
3c8ec7d7fb Merge pull request #3869 from Infisical/sequence-approval-policy-ui-additions
improvement(access-policies): Revamp approval sequence table display and access request modal
2025-06-28 04:07:41 -04:00
x032205
26a59286c5 Merge pull request #3877 from Infisical/remove-datadog-logs
Remove debug logs for DataDog stream
2025-06-28 03:45:14 -04:00
x032205
392792bb1e Remove debug logs for DataDog stream 2025-06-28 03:37:32 -04:00
Scott Wilson
48f40ff938 improvement: address feedback 2025-06-27 21:00:48 -07:00
Maidul Islam
969896e431 Merge pull request #3874 from Infisical/remove-certauth-join
Remove cert auth left join
2025-06-27 20:41:58 -04:00
Maidul Islam
fd85da5739 set trusted ip to empty 2025-06-27 20:36:32 -04:00
Maidul Islam
2caf6ff94b remove cert auth left join 2025-06-27 20:21:28 -04:00
Scott Wilson
ed7d709a70 improvement: standardize and improve org access control 2025-06-27 15:15:12 -07:00
Scott Wilson
953cc3a850 improvements: revise approval sequence table display and access request modal 2025-06-27 09:30:11 -07:00
30 changed files with 882 additions and 605 deletions

View File

@@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS ${TableName.IdentityAccessToken}_identityid_index
ON ${TableName.IdentityAccessToken} ("identityId")
`);
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS ${TableName.IdentityAccessToken}_identityuaclientsecretid_index
ON ${TableName.IdentityAccessToken} ("identityUAClientSecretId")
`);
}
export async function down(knex: Knex): Promise<void> {
await knex.raw(`
DROP INDEX IF EXISTS ${TableName.IdentityAccessToken}_identityid_index
`);
await knex.raw(`
DROP INDEX IF EXISTS ${TableName.IdentityAccessToken}_identityuaclientsecretid_index
`);
}
const config = { transaction: false };
export { config };

View File

@@ -131,7 +131,6 @@ export const auditLogQueueServiceFactory = async ({
});
try {
logger.info(`Streaming audit log [url=${url}] for org [orgId=${orgId}]`);
const response = await request.post(
url,
{ ...providerSpecificPayload(url), ...auditLog },
@@ -143,9 +142,6 @@ export const auditLogQueueServiceFactory = async ({
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
}
);
logger.info(
`Successfully streamed audit log [url=${url}] for org [orgId=${orgId}] [response=${JSON.stringify(response.data)}]`
);
return response;
} catch (error) {
logger.error(
@@ -237,7 +233,6 @@ export const auditLogQueueServiceFactory = async ({
});
try {
logger.info(`Streaming audit log [url=${url}] for org [orgId=${orgId}]`);
const response = await request.post(
url,
{ ...providerSpecificPayload(url), ...auditLog },
@@ -249,9 +244,6 @@ export const auditLogQueueServiceFactory = async ({
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
}
);
logger.info(
`Successfully streamed audit log [url=${url}] for org [orgId=${orgId}] [response=${JSON.stringify(response.data)}]`
);
return response;
} catch (error) {
logger.error(

View File

@@ -1419,7 +1419,8 @@ export const registerRoutes = async (
const identityAccessTokenService = identityAccessTokenServiceFactory({
identityAccessTokenDAL,
identityOrgMembershipDAL,
accessTokenQueue
accessTokenQueue,
identityDAL
});
const identityProjectService = identityProjectServiceFactory({

View File

@@ -17,77 +17,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
const doc = await (tx || db.replicaNode())(TableName.IdentityAccessToken)
.where(filter)
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`)
.leftJoin(
TableName.IdentityUaClientSecret,
`${TableName.IdentityAccessToken}.identityUAClientSecretId`,
`${TableName.IdentityUaClientSecret}.id`
)
.leftJoin(
TableName.IdentityUniversalAuth,
`${TableName.IdentityUaClientSecret}.identityUAId`,
`${TableName.IdentityUniversalAuth}.id`
)
.leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`)
.leftJoin(
TableName.IdentityAliCloudAuth,
`${TableName.Identity}.id`,
`${TableName.IdentityAliCloudAuth}.identityId`
)
.leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`)
.leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`)
.leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`)
.leftJoin(
TableName.IdentityKubernetesAuth,
`${TableName.Identity}.id`,
`${TableName.IdentityKubernetesAuth}.identityId`
)
.leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`)
.leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`)
.leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`)
.leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`)
.leftJoin(
TableName.IdentityTlsCertAuth,
`${TableName.Identity}.id`,
`${TableName.IdentityTlsCertAuth}.identityId`
)
.select(selectAllTableCols(TableName.IdentityAccessToken))
.select(
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"),
db
.ref("accessTokenTrustedIps")
.withSchema(TableName.IdentityAliCloudAuth)
.as("accessTokenTrustedIpsAliCloud"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOciAuth).as("accessTokenTrustedIpsOci"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityLdapAuth).as("accessTokenTrustedIpsLdap"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTlsCertAuth).as("accessTokenTrustedIpsTlsCert"),
db.ref("name").withSchema(TableName.Identity)
)
.select(db.ref("name").withSchema(TableName.Identity))
.first();
if (!doc) return;
return {
...doc,
trustedIpsUniversalAuth: doc.accessTokenTrustedIpsUa,
trustedIpsGcpAuth: doc.accessTokenTrustedIpsGcp,
trustedIpsAliCloudAuth: doc.accessTokenTrustedIpsAliCloud,
trustedIpsAwsAuth: doc.accessTokenTrustedIpsAws,
trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure,
trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s,
trustedIpsOciAuth: doc.accessTokenTrustedIpsOci,
trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc,
trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken,
trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt,
trustedIpsAccessLdapAuth: doc.accessTokenTrustedIpsLdap,
trustedIpsAccessTlsCertAuth: doc.accessTokenTrustedIpsTlsCert
};
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "IdAccessTokenFindOne" });
}

View File

@@ -7,12 +7,14 @@ import { checkIPAgainstBlocklist, TIp } from "@app/lib/ip";
import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue";
import { AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal";
import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types";
type TIdentityAccessTokenServiceFactoryDep = {
identityAccessTokenDAL: TIdentityAccessTokenDALFactory;
identityDAL: Pick<TIdentityDALFactory, "getTrustedIpsByAuthMethod">;
identityOrgMembershipDAL: TIdentityOrgDALFactory;
accessTokenQueue: Pick<
TAccessTokenQueueServiceFactory,
@@ -25,7 +27,8 @@ export type TIdentityAccessTokenServiceFactory = ReturnType<typeof identityAcces
export const identityAccessTokenServiceFactory = ({
identityAccessTokenDAL,
identityOrgMembershipDAL,
accessTokenQueue
accessTokenQueue,
identityDAL
}: TIdentityAccessTokenServiceFactoryDep) => {
const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => {
const {
@@ -190,24 +193,11 @@ export const identityAccessTokenServiceFactory = ({
message: "Failed to authorize revoked access token, access token is revoked"
});
const trustedIpsMap: Record<IdentityAuthMethod, unknown> = {
[IdentityAuthMethod.UNIVERSAL_AUTH]: identityAccessToken.trustedIpsUniversalAuth,
[IdentityAuthMethod.GCP_AUTH]: identityAccessToken.trustedIpsGcpAuth,
[IdentityAuthMethod.ALICLOUD_AUTH]: identityAccessToken.trustedIpsAliCloudAuth,
[IdentityAuthMethod.AWS_AUTH]: identityAccessToken.trustedIpsAwsAuth,
[IdentityAuthMethod.OCI_AUTH]: identityAccessToken.trustedIpsOciAuth,
[IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth,
[IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth,
[IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth,
[IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth,
[IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth,
[IdentityAuthMethod.LDAP_AUTH]: identityAccessToken.trustedIpsAccessLdapAuth,
[IdentityAuthMethod.TLS_CERT_AUTH]: identityAccessToken.trustedIpsAccessTlsCertAuth
};
const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod];
if (ipAddress) {
const trustedIps = await identityDAL.getTrustedIpsByAuthMethod(
identityAccessToken.identityId,
identityAccessToken.authMethod as IdentityAuthMethod
);
if (ipAddress && trustedIps) {
checkIPAgainstBlocklist({
ipAddress,
trustedIps: trustedIps as TIp[]

View File

@@ -1,5 +1,5 @@
import { TDbClient } from "@app/db";
import { TableName, TIdentities } from "@app/db/schemas";
import { IdentityAuthMethod, TableName, TIdentities } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
@@ -8,6 +8,28 @@ export type TIdentityDALFactory = ReturnType<typeof identityDALFactory>;
export const identityDALFactory = (db: TDbClient) => {
const identityOrm = ormify(db, TableName.Identity);
const getTrustedIpsByAuthMethod = async (identityId: string, authMethod: IdentityAuthMethod) => {
const authMethodToTableName = {
[IdentityAuthMethod.TOKEN_AUTH]: TableName.IdentityTokenAuth,
[IdentityAuthMethod.UNIVERSAL_AUTH]: TableName.IdentityUniversalAuth,
[IdentityAuthMethod.KUBERNETES_AUTH]: TableName.IdentityKubernetesAuth,
[IdentityAuthMethod.GCP_AUTH]: TableName.IdentityGcpAuth,
[IdentityAuthMethod.ALICLOUD_AUTH]: TableName.IdentityAliCloudAuth,
[IdentityAuthMethod.AWS_AUTH]: TableName.IdentityAwsAuth,
[IdentityAuthMethod.AZURE_AUTH]: TableName.IdentityAzureAuth,
[IdentityAuthMethod.TLS_CERT_AUTH]: TableName.IdentityTlsCertAuth,
[IdentityAuthMethod.OCI_AUTH]: TableName.IdentityOciAuth,
[IdentityAuthMethod.OIDC_AUTH]: TableName.IdentityOidcAuth,
[IdentityAuthMethod.JWT_AUTH]: TableName.IdentityJwtAuth,
[IdentityAuthMethod.LDAP_AUTH]: TableName.IdentityLdapAuth
} as const;
const tableName = authMethodToTableName[authMethod];
if (!tableName) return;
const data = await db(tableName).where({ identityId }).first();
if (!data) return;
return data.accessTokenTrustedIps;
};
const getIdentitiesByFilter = async ({
limit,
offset,
@@ -38,5 +60,5 @@ export const identityDALFactory = (db: TDbClient) => {
}
};
return { ...identityOrm, getIdentitiesByFilter };
return { ...identityOrm, getTrustedIpsByAuthMethod, getIdentitiesByFilter };
};

View File

@@ -392,7 +392,12 @@ export const identityOrgDALFactory = (db: TDbClient) => {
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`)
.where(`${TableName.IdentityOrgMembership}.orgId`, orgId)
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.orderBy(`${TableName.Identity}.${orderBy}`, orderDirection)
.orderBy(
orderBy === OrgIdentityOrderBy.Role
? `${TableName.IdentityOrgMembership}.${orderBy}`
: `${TableName.Identity}.${orderBy}`,
orderDirection
)
.select(`${TableName.IdentityOrgMembership}.id`)
.select<{ id: string; total_count: string }>(
db.raw(
@@ -523,6 +528,23 @@ export const identityOrgDALFactory = (db: TDbClient) => {
if (orderBy === OrgIdentityOrderBy.Name) {
void query.orderBy("identityName", orderDirection);
} else if (orderBy === OrgIdentityOrderBy.Role) {
void query.orderByRaw(
`
CASE
WHEN ??.role = ?
THEN ??.slug
ELSE ??.role
END ?
`,
[
TableName.IdentityOrgMembership,
"custom",
TableName.OrgRoles,
TableName.IdentityOrgMembership,
db.raw(orderDirection)
]
);
}
const docs = await query;

View File

@@ -46,8 +46,8 @@ export type TListOrgIdentitiesByOrgIdDTO = {
} & TOrgPermission;
export enum OrgIdentityOrderBy {
Name = "name"
// Role = "role"
Name = "name",
Role = "role"
}
export type TSearchOrgIdentitiesByOrgIdDAL = {

View File

@@ -11,9 +11,9 @@ Fairly frequently, you might run into situations when you need to spend company
As a perk of working at Infisical, we cover some of your meal expenses.
HQ team members: meals and unlimited snacks are provided on-site at no cost.
**HQ team members**: meals and unlimited snacks are provided **on-site** at no cost.
Remote team members: a food stipend is allocated based on location.
**Remote team members**: a food stipend is allocated based on location.
# Trivial expenses
@@ -27,21 +27,28 @@ This means expenses that are:
Please spend money in a way that you think is in the best interest of the company.
</Note>
## Saving receipts
Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay.
# Travel
You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually.
If you need to travel on Infisicals behalf for in-person onboarding, meeting customers, and offsites, again please spend money in the best interests of the company.
## Training
We do not pre-approve your travel expenses, and trust team members to make the right decisions here. Some guidance:
- Please find a flight ticket that is reasonably priced. We all travel economy by default we cannot afford for folks to fly premium or business class. Feel free to upgrade using your personal money/airmiles if youd like to.
- Feel free to pay for the Uber/subway/bus to and from the airport with your Brex card.
- For business travel, Infisical will cover reasonable expenses for breakfast, lunch, and dinner.
- When traveling internationally, Infisical does not cover roaming charges for your phone. You can expense a reasonable eSIM, which usually is no more than $20.
<Note>
Note that this only applies to business travel. It is not applicable for personal travel or day-to-day commuting.
</Note>
For engineers, youre welcome to take an approved Udemy course. Please reach out to Maidul. For the GTM team, you may buy a book a month if its relevant to your work.
# Equipment
Infisical is a remote first company so we understand the importance of having a comfortable work setup. To support this, we provide allowances for essential office equipment.
### Desk & Chair
### 1. Desk & Chair
Most people already have a comfortable desk and chair, but if you need an upgrade, we offer the following allowances.
While we're not yet able to provide the latest and greatest, we strive to be reasonable given the stage of our company.
@@ -50,10 +57,10 @@ While we're not yet able to provide the latest and greatest, we strive to be rea
**Chair**: $150 USD
### Laptop
### 2. Laptop
Each team member will receive a company-issued Macbook Pro before they start their first day.
### Notes
### 3. Notes
1. All equipment purchased using company allowances remains the property of Infisical.
2. Keep all receipts for equipment purchases and submit them for reimbursement.
@@ -65,6 +72,28 @@ This is because we don't yet have a formal HR department to handle such logistic
For any equipment related questions, please reach out to Maidul.
## Brex
# Brex
We use Brex as our primary credit card provider. Don't have a company card yet? Reach out to Maidul.
### Budgets
You will generally have multiple budgets assigned to you. "General Company Expenses" primarily covers quick SaaS purchases (not food). Remote team members should have a "Lunch Stipend" budget that applies to food.
If your position involves a lot of travel, you may also have a "Travel" budget that applies to expenses related to business travel (e.g., you can not use it for transportation or food during personal travel).
### Saving receipts
Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay.
You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually.
### Need a one-off budget increase?
You can do this directly within Brex - just request the amount and duration for the relevant budget in the app, and your hiring manager will automatically be notified for approval.
# Training
For engineers, youre welcome to take an approved Udemy course. Please reach out to Maidul. For the GTM team, you may buy a book a month if its relevant to your work.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,4 +1,6 @@
import { ReactNode } from "react";
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
type Props = {
@@ -7,6 +9,7 @@ type Props = {
className?: string;
labelClassName?: string;
truncate?: boolean;
icon?: IconDefinition;
};
export const GenericFieldLabel = ({
@@ -14,11 +17,15 @@ export const GenericFieldLabel = ({
children,
className,
labelClassName,
truncate
truncate,
icon
}: Props) => {
return (
<div className={twMerge("min-w-0", className)}>
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
<div className="flex items-center gap-1.5">
{icon && <FontAwesomeIcon icon={icon} className="text-mineshaft-400" size="sm" />}
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
</div>
{children ? (
<p className={twMerge("text-sm text-mineshaft-100", truncate && "truncate")}>{children}</p>
) : (

View File

@@ -64,7 +64,7 @@ export const Pagination = ({
<FontAwesomeIcon className="text-xs" icon={faCaretDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-fit">
<DropdownMenuContent sideOffset={2} className="min-w-fit">
{perPageList.map((perPageOption) => (
<DropdownMenuItem
key={`pagination-per-page-options-${perPageOption}`}

View File

@@ -81,7 +81,8 @@ export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => {
search
});
return data;
}
},
placeholderData: (previousData) => previousData
});
};

View File

@@ -154,6 +154,6 @@ export type TOrgIdentitiesList = {
};
export enum OrgIdentityOrderBy {
Name = "name"
// Role = "role"
Name = "name",
Role = "role"
}

View File

@@ -56,12 +56,12 @@ export const OrgGroupsSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
<OrgPermissionCan I={OrgPermissionGroupActions.Create} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddGroupModal()}

View File

@@ -2,14 +2,17 @@ import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faCopy,
faEdit,
faEllipsisV,
faMagnifyingGlass,
faSearch,
faTrash,
faUserGroup,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
@@ -261,7 +264,8 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
className="h-8 w-48 bg-mineshaft-700"
position="popper"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
@@ -282,13 +286,19 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>
<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 asChild>
<IconButton
ariaLabel="Options"
className="w-6"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCopy} />}
onClick={(e) => {
e.stopPropagation();
createNotification({
@@ -306,10 +316,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("group", {
@@ -320,7 +327,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
customRole
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Edit Group
</DropdownMenuItem>
@@ -332,10 +339,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faUserGroup} />}
onClick={() =>
navigate({
to: "/organization/groups/$groupId",
@@ -344,7 +348,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
}
})
}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Manage Members
</DropdownMenuItem>
@@ -356,11 +360,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
@@ -368,7 +368,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
name
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>

View File

@@ -1,4 +1,4 @@
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -71,20 +71,22 @@ export const IdentitySection = withPermission(
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/identities/overview"
className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white"
>
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<OrgPermissionCan
@@ -93,7 +95,7 @@ export const IdentitySection = withPermission(
>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {

View File

@@ -1,12 +1,15 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useCallback, useState } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faCheckCircle,
faChevronRight,
faEdit,
faEllipsisV,
faFilter,
faMagnifyingGlass,
faServer
faServer,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -15,19 +18,18 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
EmptyState,
FormControl,
IconButton,
Input,
Pagination,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
@@ -38,7 +40,6 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
@@ -63,6 +64,10 @@ type Props = {
) => void;
};
type Filter = {
roles: string[];
};
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
@@ -90,7 +95,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
setUserTablePreference("identityTable", PreferenceKey.PerPage, newPerPage);
};
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
const [filter, setFilter] = useState<Filter>({
roles: []
});
const organizationId = currentOrg?.id || "";
@@ -103,7 +110,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
orderBy,
search: {
name: debouncedSearch ? { $contains: debouncedSearch } : undefined,
role: filteredRoles?.length ? { $in: filteredRoles } : undefined
role: filter.roles?.length ? { $in: filter.roles } : undefined
}
});
@@ -113,7 +120,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
offset,
setPage
});
const filterForm = useForm<{ roles: string }>();
const { data: roles } = useGetOrgRoles(organizationId);
@@ -153,79 +159,80 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}
};
const handleRoleToggle = useCallback(
(roleSlug: string) =>
setFilter((state) => {
const currentRoles = state.roles || [];
if (currentRoles.includes(roleSlug)) {
return { ...state, roles: currentRoles.filter((role) => role !== roleSlug) };
}
return { ...state, roles: [...currentRoles, roleSlug] };
}),
[]
);
const isTableFiltered = Boolean(filter.roles.length);
return (
<div>
<div className="mb-4 flex items-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Identities"
variant="plain"
size="sm"
className={twMerge(
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-0">
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
>
Roles
</DropdownSubMenuTrigger>
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Roles to Filter Identities
</DropdownMenuLabel>
{roles?.map(({ id, slug, name }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
handleRoleToggle(slug);
}}
key={id}
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
</DropdownMenuContent>
</DropdownMenu>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div>
<Popover>
<PopoverTrigger>
<IconButton
ariaLabel="filter"
variant="outline_bg"
className={filteredRoles?.length ? "border-primary" : ""}
>
<Tooltip content="Advance Filter">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl">
<div className="mb-4 border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Advance Filter
</div>
<form
onSubmit={filterForm.handleSubmit((el) => {
setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []);
})}
>
<Controller
control={filterForm.control}
name="roles"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Roles"
helperText="Eg: admin,viewer"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
type="submit"
size="xs"
colorSchema="primary"
variant="outline_bg"
className="mt-4"
>
Apply Filter
</Button>
{Boolean(filteredRoles.length) && (
<Button
size="xs"
variant="link"
className="ml-4 mt-4"
onClick={() => {
filterForm.reset({ roles: "" });
setFilteredRoles([]);
}}
>
Clear
</Button>
)}
</div>
</form>
</PopoverContent>
</Popover>
</div>
</div>
<TableContainer>
<Table>
@@ -251,8 +258,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
</IconButton>
</div>
</Th>
<Th>Role</Th>
{/* <Th>
<Th>
<div className="flex items-center">
Role
<IconButton
@@ -271,7 +277,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
/>
</IconButton>
</div>
</Th> */}
</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
@@ -303,7 +309,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
className="h-8 w-48 bg-mineshaft-700"
position="popper"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
@@ -324,21 +331,24 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="flex justify-center hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
className="w-6"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-3 p-1">
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={(e) => {
e.stopPropagation();
navigate({
@@ -348,7 +358,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
@@ -360,11 +370,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteIdentity", {
@@ -372,7 +377,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
name
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Identity
</DropdownMenuItem>
@@ -398,7 +404,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
{!isPending && data && data?.identities.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0 || filteredRoles?.length > 0
debouncedSearch.trim().length > 0 || filter.roles?.length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}

View File

@@ -115,12 +115,12 @@ export const OrgMembersSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddMemberModal()}

View File

@@ -4,11 +4,14 @@ import {
faArrowUp,
faCheckCircle,
faChevronRight,
faEllipsis,
faEdit,
faEllipsisV,
faFilter,
faMagnifyingGlass,
faSearch,
faUsers
faUsers,
faUserSlash,
faUserXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -79,7 +82,8 @@ type Props = {
enum OrgMembersOrderBy {
Name = "firstName",
Email = "email"
Email = "email",
Role = "role"
}
type Filter = {
@@ -99,8 +103,10 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
const { data: serverDetails } = useFetchServerStatus();
const { data: members = [], isPending: isMembersLoading } = useGetOrgUsers(orgId);
const { mutateAsync: resendOrgMemberInvitation } = useResendOrgMemberInvitation();
const { mutateAsync: resendOrgMemberInvitation, isPending: isResendInvitePending } =
useResendOrgMemberInvitation();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const [resendInviteId, setResendInviteId] = useState<string | null>(null);
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?.id) return;
@@ -136,6 +142,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
};
const onResendInvite = async (membershipId: string) => {
setResendInviteId(membershipId);
try {
const signupToken = await resendOrgMemberInvitation({
membershipId
@@ -156,6 +163,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
text: "Failed to resend org invitation",
type: "error"
});
} finally {
setResendInviteId(null);
}
};
@@ -229,6 +238,16 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case OrgMembersOrderBy.Role:
valueOne =
memberOne.role === "custom"
? findRoleFromId(memberOne.roleId)!.slug
: memberOne.role;
valueTwo =
memberTwo.role === "custom"
? findRoleFromId(memberTwo.roleId)!.slug
: memberTwo.role;
break;
case OrgMembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
@@ -284,7 +303,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
@@ -378,7 +397,26 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th className="w-1/3">
<div className="flex items-center">
Role
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Role ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Role)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Role
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
@@ -398,7 +436,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
isActive
}) => {
const name =
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-";
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : null;
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
@@ -415,7 +453,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
}
>
<Td className={isActive ? "" : "text-mineshaft-400"}>
{name}
{name ?? <span className="text-mineshaft-400">Not Set</span>}
{u.superAdmin && (
<Badge variant="primary" className="ml-2">
Server Admin
@@ -429,79 +467,77 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<>
{!isActive && (
<Button
isDisabled
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => {}}
>
Suspended
</Button>
)}
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="h-8 w-48 bg-mineshaft-700"
position="popper"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
</OrgPermissionCan>
</Td>
<Td>
<div className="flex items-center justify-end gap-6">
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
className="w-48"
isDisabled={!isAllowed || isResendInvitePending}
className="h-8 border-mineshaft-600 bg-mineshaft-700 font-normal"
colorSchema="primary"
variant="outline_bg"
isLoading={
isResendInvitePending && resendInviteId === orgMembershipId
}
onClick={(e) => {
onResendInvite(orgMembershipId);
e.stopPropagation();
}}
>
Resend invite
Resend Invite
</Button>
)}
</>
)}
</OrgPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
</OrgPermissionCan>
)}
<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 asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className={twMerge("w-6", userId === u?.id && "opacity-50")}
variant="plain"
isDisabled={userId === u?.id}
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
@@ -511,7 +547,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
}
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit User
</DropdownMenuItem>
@@ -523,15 +560,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
icon={<FontAwesomeIcon icon={faUserSlash} />}
onClick={async (e) => {
e.stopPropagation();
@@ -560,7 +589,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
username
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
@@ -572,11 +601,6 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
@@ -593,7 +617,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
username
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faUserXmark} />}
>
Remove User
</DropdownMenuItem>
@@ -601,7 +626,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Td>
</Tr>
);

View File

@@ -6,10 +6,10 @@ export const OrgRoleTabSection = () => {
return (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgRoleTable />
</motion.div>

View File

@@ -1,4 +1,17 @@
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faCopy,
faEdit,
faEllipsisV,
faEye,
faIdBadge,
faMagnifyingGlass,
faPlus,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@@ -14,6 +27,10 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -30,13 +47,25 @@ import {
useOrganization,
useSubscription
} from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { isCustomOrgRole, isCustomProjectRole } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useDeleteOrgRole, useGetOrgRoles, useUpdateOrg } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TOrgRole } from "@app/hooks/api/roles/types";
import { DuplicateOrgRoleModal } from "@app/pages/organization/RoleByIDPage/components/DuplicateOrgRoleModal";
import { RoleModal } from "@app/pages/organization/RoleByIDPage/components/RoleModal";
enum RolesOrderBy {
Name = "name",
Slug = "slug",
Type = "type"
}
export const OrgRoleTable = () => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
@@ -93,14 +122,89 @@ export const OrgRoleTable = () => {
}
};
const {
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy,
search,
setSearch,
page,
perPage,
setPerPage,
setPage,
offset
} = usePagination<RolesOrderBy>(RolesOrderBy.Type, {
initPerPage: getUserTablePreference("orgRolesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("orgRolesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredRoles = useMemo(
() =>
roles
?.filter((role) => {
const { slug, name } = role;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) || slug.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [roleOne, roleTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case RolesOrderBy.Slug:
return roleOne.slug.toLowerCase().localeCompare(roleTwo.slug.toLowerCase());
case RolesOrderBy.Type: {
const roleOneValue = isCustomOrgRole(roleOne.slug) ? -1 : 1;
const roleTwoValue = isCustomOrgRole(roleTwo.slug) ? -1 : 1;
return roleTwoValue - roleOneValue;
}
case RolesOrderBy.Name:
default:
return roleOne.name.toLowerCase().localeCompare(roleTwo.name.toLowerCase());
}
}) ?? [],
[roles, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredRoles.length,
offset,
setPage
});
const handleSort = (column: RolesOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: RolesOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: RolesOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Organization Roles</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Role}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
@@ -113,18 +217,63 @@ export const OrgRoleTable = () => {
)}
</OrgPermissionCan>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search roles..."
className="flex-1"
containerClassName="mb-4"
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Slug
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Slug)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Slug)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Slug)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Type
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Type)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Type)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Type)} />
</IconButton>
</div>
</Th>
<Th aria-label="actions" className="w-5" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={3} innerKey="org-roles" />}
{roles?.map((role) => {
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{filteredRoles?.slice(offset, perPage * page).map((role) => {
const { id, name, slug } = role;
const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug);
const isDefaultOrgRole = isCustomOrgRole(slug)
@@ -162,23 +311,30 @@ export const OrgRoleTable = () => {
<Td className="max-w-md overflow-hidden text-ellipsis whitespace-nowrap">
{slug}
</Td>
<Td>
<Badge className="w-min whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
{isCustomProjectRole(slug) ? "Custom" : "Default"}
</Badge>
</Td>
<Td>
<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 asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
@@ -188,7 +344,8 @@ export const OrgRoleTable = () => {
}
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={isNonMutatable ? faEye : faEdit} />}
>
{`${isNonMutatable ? "View" : "Edit"} Role`}
</DropdownMenuItem>
@@ -200,14 +357,12 @@ export const OrgRoleTable = () => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("duplicateRole", role);
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faCopy} />}
>
Duplicate Role
</DropdownMenuItem>
@@ -220,14 +375,12 @@ export const OrgRoleTable = () => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
disabled={!isAllowed}
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handleSetRoleAsDefault(slug);
}}
icon={<FontAwesomeIcon icon={faIdBadge} />}
>
Set as Default Role
</DropdownMenuItem>
@@ -250,16 +403,12 @@ export const OrgRoleTable = () => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed && !isDefaultOrgRole
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", role);
}}
disabled={!isAllowed || isDefaultOrgRole}
icon={<FontAwesomeIcon icon={faTrash} />}
isDisabled={!isAllowed || isDefaultOrgRole}
>
Delete Role
</DropdownMenuItem>
@@ -276,6 +425,25 @@ export const OrgRoleTable = () => {
})}
</TBody>
</Table>
{Boolean(filteredRoles?.length) && (
<Pagination
count={filteredRoles!.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!filteredRoles?.length && !isRolesLoading && (
<EmptyState
title={
roles?.length
? "No roles match search..."
: "This organization does not have any roles"
}
icon={roles?.length ? faSearch : undefined}
/>
)}
</TableContainer>
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal

View File

@@ -197,7 +197,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
@@ -298,7 +298,8 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
{!isMembersLoading &&
filteredUsers.slice(offset, perPage * page).map((projectMember) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
const name =
u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : null;
const email = u?.email || inviteEmail;
return (
@@ -328,7 +329,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
})
}
>
<Td>{name}</Td>
<Td>{name ?? <span className="text-mineshaft-400">Not Set</span>}</Td>
<Td>{email}</Td>
<Td>
<div className="flex items-center space-x-2">

View File

@@ -236,7 +236,7 @@ export const ProjectRoleList = () => {
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={3} innerKey="project-roles" />}
{isRolesLoading && <TableSkeleton columns={4} innerKey="project-roles" />}
{filteredRoles?.slice(offset, perPage * page).map((role) => {
const { id, name, slug } = role;
const isNonMutatable = Object.values(ProjectMembershipRole).includes(

View File

@@ -12,7 +12,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, Checkbox, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
import { Button, Checkbox, IconButton, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
@@ -241,16 +241,19 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
/>
</Tooltip>
{!isDisabled && (
<Button
leftIcon={<FontAwesomeIcon icon={faTrash} />}
variant="outline_bg"
size="xs"
className="ml-auto mr-3"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
Remove Rule
</Button>
<Tooltip content="Remove Rule">
<IconButton
ariaLabel="Remove rule"
colorSchema="danger"
variant="plain"
size="xs"
className="ml-auto mr-3 rounded"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
{!isDisabled && (
<Tooltip position="left" content="Drag to reorder permission">
@@ -271,16 +274,19 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
<div className="flex w-full justify-between">
<div className="mb-2">Actions</div>
{!isDisabled && !isConditionalSubjects(subject) && (
<Button
leftIcon={<FontAwesomeIcon icon={faTrash} />}
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
Remove Rule
</Button>
<Tooltip content="Remove Rule">
<IconButton
ariaLabel="Remove rule"
colorSchema="danger"
variant="plain"
size="xs"
className="ml-auto rounded"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</div>
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">

View File

@@ -247,9 +247,7 @@ export const AccessApprovalRequest = ({
};
else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`,
label: "Pending Additional Reviews",
type: "primary",
icon: faClipboardCheck
};

View File

@@ -1,10 +1,9 @@
import { useCallback, useMemo, useState } from "react";
import { ReactNode, useCallback, useMemo, useState } from "react";
import {
faCheckCircle,
faCircle,
faTriangleExclamation,
faUsers,
faXmarkCircle
faBan,
faCheck,
faHourglass,
faTriangleExclamation
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
@@ -15,12 +14,10 @@ import {
Button,
Checkbox,
FormControl,
GenericFieldLabel,
Input,
Modal,
ModalContent,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -38,10 +35,22 @@ import { groupBy } from "@app/lib/fn/array";
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
return (
<Badge variant="success" className="flex h-4 items-center justify-center">
<FontAwesomeIcon icon={faCheck} size="xs" />
</Badge>
);
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
return (
<Badge variant="danger" className="flex h-4 items-center justify-center">
<FontAwesomeIcon icon={faBan} size="xs" />
</Badge>
);
return (
<Badge variant="primary" className="flex h-4 items-center justify-center">
<FontAwesomeIcon icon={faHourglass} size="xs" />
</Badge>
);
};
export const ReviewAccessRequestModal = ({
@@ -267,139 +276,160 @@ export const ReviewAccessRequestModal = ({
</div>
<div className="">
<div className="mb-2 mt-4 text-mineshaft-200">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Environment</div>
<div>{accessDetails.env || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Secret Path</div>
<div>{accessDetails.secretPath || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Access Type</div>
<div>{getAccessLabel()}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Permission</div>
<div>{requestedAccess}</div>
</div>
<div className="col-span-2">
<div className="mb-1 text-xs font-semibold uppercase">Note</div>
<div>{request.note || "-"}</div>
</div>
<div className="flex flex-wrap gap-8">
<GenericFieldLabel label="Environment">{accessDetails.env}</GenericFieldLabel>
<GenericFieldLabel truncate label="Secret Path">
{accessDetails.secretPath}
</GenericFieldLabel>
<GenericFieldLabel label="Access Type">{getAccessLabel()}</GenericFieldLabel>
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
{request.note && (
<GenericFieldLabel className="col-span-full" label="Note">
{request.note}
</GenericFieldLabel>
)}
</div>
</div>
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
<div className="thin-scrollbar max-h-64 overflow-y-auto rounded p-2">
{approverSequence?.approvers?.map((approver, index) => (
<div
key={`approval-list-${index + 1}`}
className={twMerge(
"relative mb-2 flex items-center rounded border border-mineshaft-500 bg-mineshaft-700 p-4",
approverSequence?.currentSequence !== approver.sequence &&
!hasApproved &&
"text-mineshaft-400"
)}
>
<div>
<div
className={twMerge(
"mr-8 flex h-8 w-8 items-center justify-center text-3xl font-medium",
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
>
{index + 1}
</div>
{index !== (approverSequence?.approvers?.length || 0) - 1 && (
<div
className={twMerge(
"absolute bottom-0 left-8 h-5 border-r-2 border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)}
{index !== 0 && (
<div
className={twMerge(
"absolute left-8 top-0 h-5 border-r-2 border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)}
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(",") || "-"}
</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>
{approver?.group
?.map(
(el) =>
approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name
)
.join(",") || "-"}
</div>
</div>
<div className="flex items-center">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{approver.approvals || "-"}</div>
</div>
<div className="ml-16">
<Popover>
<PopoverTrigger>
<FontAwesomeIcon icon={faUsers} />
</PopoverTrigger>
<PopoverContent hideCloseBtn className="pt-3">
<div>
<div className="mb-1 text-sm text-bunker-300">Reviewers</div>
<div className="thin-scrollbar flex max-h-64 flex-col gap-1 overflow-y-auto rounded">
{approver.reviewers.map((el, idx) => (
<div
key={`reviewer-${idx + 1}`}
className="flex items-center gap-2 bg-mineshaft-700 p-1 text-sm"
>
<div className="flex-grow">{el.username}</div>
<Tooltip
content={`Status: ${el?.status || ApprovalStatus.PENDING}`}
>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
</Tooltip>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
))}
<div className="mt-4 flex items-center justify-between border-b-2 border-mineshaft-500 py-2">
<span>Approvers</span>
{approverSequence.isMyReviewInThisSequence &&
request.status === ApprovalStatus.PENDING && (
<Badge variant="primary" className="h-min">
Awaiting Your Review
</Badge>
)}
</div>
<div className="thin-scrollbar max-h-[40vh] overflow-y-auto rounded py-2">
{approverSequence?.approvers &&
approverSequence.approvers.map((approver, index) => {
const isInactive =
approverSequence?.currentSequence <
(approver.sequence ?? approverSequence.approvers!.length);
const isPending = approverSequence?.currentSequence === approver.sequence;
let StepComponent: ReactNode;
let BadgeComponent: ReactNode = null;
if (approver.hasRejected) {
StepComponent = (
<Badge
variant="danger"
className="flex h-6 min-w-6 items-center justify-center"
>
<FontAwesomeIcon icon={faBan} />
</Badge>
);
BadgeComponent = <Badge variant="danger">Rejected</Badge>;
} else if (approver.hasApproved) {
StepComponent = (
<Badge
variant="success"
className="flex h-6 min-w-6 items-center justify-center"
>
<FontAwesomeIcon icon={faCheck} />
</Badge>
);
BadgeComponent = <Badge variant="success">Approved</Badge>;
} else if (isPending) {
StepComponent = (
<Badge
variant="primary"
className="flex h-6 min-w-6 items-center justify-center"
>
<FontAwesomeIcon icon={faHourglass} />
</Badge>
);
BadgeComponent = <Badge variant="primary">Pending</Badge>;
} else {
StepComponent = (
<Badge
className={
isInactive
? "py-auto my-auto flex h-6 min-w-6 items-center justify-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-center text-bunker-200"
: ""
}
>
<span>{index + 1}</span>
</Badge>
);
}
return (
<div
key={`approval-list-${index + 1}`}
className={twMerge("flex", isInactive && "opacity-50")}
>
{approverSequence.approvers!.length > 1 && (
<div className="flex w-12 flex-col items-center gap-2 pr-4">
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index !== 0 && "border-r"
)}
/>
{StepComponent}
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index < approverSequence.approvers!.length - 1 && "border-r"
)}
/>
</div>
)}
<div className="grid flex-1 grid-cols-5 border-b border-mineshaft-600 p-4">
<GenericFieldLabel className="col-span-2" label="Users">
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(", ")}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-2" label="Groups">
{approver?.group
?.map(
(el) =>
approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name
)
.join(", ")}
</GenericFieldLabel>
<GenericFieldLabel label="Approvals Required">
<div className="flex items-center">
<span className="mr-2">{approver.approvals}</span>
{BadgeComponent && (
<Tooltip
className="max-w-lg"
content={
<div>
<div className="mb-1 text-sm text-bunker-300">Reviewers</div>
<div className="thin-scrollbar flex max-h-64 flex-col divide-y divide-mineshaft-500 overflow-y-auto rounded">
{approver.reviewers.map((el, idx) => (
<div
key={`reviewer-${idx + 1}`}
className="flex items-center gap-2 px-2 py-2 text-sm"
>
<div className="flex-1">{el.username}</div>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
</div>
))}
</div>
</div>
}
>
<div>{BadgeComponent}</div>
</Tooltip>
)}
</div>
</GenericFieldLabel>
</div>
</div>
);
})}
</div>
{approverSequence.isMyReviewInThisSequence &&
request.status === ApprovalStatus.PENDING && (
<div className="mb-4 rounded-r border-l-2 border-l-primary-400 bg-mineshaft-300/5 px-4 py-2.5 text-sm">
Awaiting review from you.
</div>
)}
{shouldBlockRequestActions ? (
<div
className={twMerge(
"mb-4 rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm",
"mt-2 rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm",
isReviewedByMe && "border-l-green-400",
!approverSequence.isMyReviewInThisSequence && "border-l-primary-400",
hasRejected && "border-l-red-500"
@@ -409,41 +439,6 @@ export const ReviewAccessRequestModal = ({
</div>
) : (
<>
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
Boolean(isLoading) ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
<Button
isLoading={isLoading === "rejected"}
isDisabled={
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm"
>
Reject Request
</Button>
</div>
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
@@ -453,11 +448,7 @@ export const ReviewAccessRequestModal = ({
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
bypassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
className={twMerge("mr-2", bypassApproval ? "border-red/30 bg-red/10" : "")}
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy
@@ -481,6 +472,42 @@ export const ReviewAccessRequestModal = ({
)}
</div>
)}
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
Boolean(isLoading) ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
variant="outline_bg"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
<Button
isLoading={isLoading === "rejected"}
isDisabled={
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm"
>
Reject Request
</Button>
</div>
</>
)}
</div>

View File

@@ -1,5 +1,12 @@
import { useMemo } from "react";
import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import {
faClipboardCheck,
faEdit,
faEllipsisV,
faTrash,
faUser,
faUserGroup
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@@ -179,26 +186,40 @@ export const ApprovalPolicyRow = ({
}`}
>
<div className="p-4">
<div className="mb-4 border-b-2 border-mineshaft-500 pb-2">Approvers</div>
<div className="border-b-2 border-mineshaft-500 pb-2">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
>
<div className="my-auto mr-8 flex h-8 w-8 items-center justify-center rounded border border-mineshaft-400 bg-bunker-500/50 text-white">
<div>{index + 1}</div>
</div>
{index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
<div key={`approval-list-${index + 1}`} className="flex">
{labels.length > 1 && (
<div className="flex w-12 flex-col items-center gap-2 pr-4">
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index !== 0 && "border-r"
)}
/>
{labels.length > 1 && (
<Badge className="my-auto flex h-5 w-min min-w-5 items-center justify-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-center text-bunker-200">
<span>{index + 1}</span>
</Badge>
)}
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index < labels.length - 1 && "border-r"
)}
/>
</div>
)}
{index !== 0 && (
<div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
)}
<div className="grid flex-grow grid-cols-3">
<GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
<GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
<GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
<div className="grid flex-1 grid-cols-5 border-b border-mineshaft-600 p-4">
<GenericFieldLabel className="col-span-2" icon={faUser} label="Users">
{el.userLabels}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-2" icon={faUserGroup} label="Groups">
{el.groupLabels}
</GenericFieldLabel>
<GenericFieldLabel icon={faClipboardCheck} label="Approvals Required">
{el.approvals}
</GenericFieldLabel>
</div>
</div>
))}