Compare commits

...

26 Commits

Author SHA1 Message Date
212748f140 misc: added cleanup of global/instance-level resources 2024-10-21 21:19:55 +08:00
b61582a60e Merge remote-tracking branch 'origin/main' into misc/export-org-data-feature 2024-10-21 19:04:02 +08:00
c5aa1b8664 Merge pull request #2626 from Infisical/vmatsiiako-patch-docsimage-1
Update group-mappings.mdx
2024-10-20 21:18:48 -04:00
90dbb417ac Update group-mappings.mdx 2024-10-20 18:17:20 -07:00
946651496f Merge pull request #2623 from Infisical/daniel/rate-limit-error
fix: better rate limit errors
2024-10-19 07:25:46 +04:00
5a8ac850b5 fix: variable naming 2024-10-19 06:41:29 +04:00
77a88f1575 feat: better rate limit errors 2024-10-19 06:35:49 +04:00
be00d13a46 Merge pull request #2621 from scott-ray-wilson/improve-overview-table-overflow
Improvement: Cap Expanded Secret View Width when Overview Table Overflows
2024-10-18 19:14:42 -04:00
84814a0012 improvement: improve handling of expanded secret when table overflows 2024-10-18 16:06:25 -07:00
de03692469 Merge pull request #2600 from scott-ray-wilson/select-all-secrets-page
Feat: Select All Rows for Secrets Tables
2024-10-18 18:30:35 -04:00
fb2d3e4eb7 Merge pull request #2618 from scott-ray-wilson/scim-group-mapping-docs
Docs: SCIM Group Mapping and SCIM/Organization Doc Improvements
2024-10-18 18:06:22 -04:00
29150e809d Merge pull request #2617 from Infisical/misc/allow-secret-scanning-whitelist
misc: added secret scanning whitelist configuration
2024-10-18 18:03:54 -04:00
e18a606b23 improvements: adjust UI for alignment and remove checkbox separator 2024-10-18 14:47:31 -07:00
67708411cd update tooltip for k8 2024-10-18 17:43:37 -04:00
3e4bd28916 Merge pull request #2619 from scott-ray-wilson/fix-default-tag-color
Fix: Set Default Value for Color in Tags Modal
2024-10-18 14:12:34 -07:00
a2e16370fa fix: set default value for color in tags modal 2024-10-18 14:06:38 -07:00
903fac1005 misc: added infisical cli to docker and fixed redirect 2024-10-19 03:18:13 +08:00
ff045214d6 improve readability 2024-10-18 11:59:23 -07:00
57dcf5ab28 docs: scim group mapping and scim/org improvements 2024-10-18 11:57:36 -07:00
959a5ec55b misc: added secret scanning whitelist conig 2024-10-19 01:59:45 +08:00
b22a93a175 Merge pull request #2604 from akhilmhdh/feat/org-kms-ui
feat: added organization kms in org role permission section
2024-10-18 21:59:56 +05:30
d7d88f3356 Merge pull request #2613 from Infisical/vmatsiiako-patch-scim-docs
Update azure.mdx
2024-10-17 21:50:00 -07:00
dbaef9d227 Update azure.mdx 2024-10-17 21:42:45 -07:00
d94b4b2a3c feat: select all on page for secrets tables and fix multipage select behavior for actions 2024-10-17 10:17:23 -07:00
=
9d90c35629 feat: added organization kms in org role permission section 2024-10-17 20:26:22 +05:30
9f6d837a9b feat: add migration script to migrate org 2024-10-07 17:28:32 +05:30
36 changed files with 587 additions and 276 deletions

View File

@ -95,6 +95,10 @@ RUN mkdir frontend-build
# Production stage
FROM base AS production
RUN apk add --upgrade --no-cache ca-certificates
RUN apk add --no-cache bash curl && curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \
&& apk add infisical=0.31.1 && apk add --no-cache git
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 non-root-user

View File

@ -58,6 +58,7 @@
"migration:latest": "npm run auditlog-migration:latest && knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest",
"migration:status": "npm run auditlog-migration:status && knex --knexfile ./src/db/knexfile.ts --client pg migrate:status",
"migration:rollback": "npm run auditlog-migration:rollback && knex --knexfile ./src/db/knexfile.ts migrate:rollback",
"migrate:org": "tsx ./scripts/migrate-organization.ts",
"seed:new": "tsx ./scripts/create-seed-file.ts",
"seed": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run",
"db:reset": "npm run migration:rollback -- --all && npm run migration:latest"

View File

@ -0,0 +1,84 @@
/* eslint-disable */
import promptSync from "prompt-sync";
import { execSync } from "child_process";
import path from "path";
import { existsSync } from "fs";
const prompt = promptSync({
sigint: true
});
const exportDb = () => {
const exportHost = prompt("Enter your Postgres Host to migrate from: ");
const exportPort = prompt("Enter your Postgres Port to migrate from [Default = 5432]: ") ?? "5432";
const exportUser = prompt("Enter your Postgres User to migrate from: [Default = infisical]: ") ?? "infisical";
const exportPassword = prompt("Enter your Postgres Password to migrate from: ");
const exportDatabase = prompt("Enter your Postgres Database to migrate from [Default = infisical]: ") ?? "infisical";
// we do not include the audit_log and secret_sharing entries
execSync(
`PGDATABASE="${exportDatabase}" PGPASSWORD="${exportPassword}" PGHOST="${exportHost}" PGPORT=${exportPort} PGUSER=${exportUser} pg_dump infisical --exclude-table-data="secret_sharing" --exclude-table-data="audit_log*" > ${path.join(
__dirname,
"../src/db/dump.sql"
)}`,
{ stdio: "inherit" }
);
};
const importDbForOrg = () => {
const importHost = prompt("Enter your Postgres Host to migrate to: ");
const importPort = prompt("Enter your Postgres Port to migrate to [Default = 5432]: ") ?? "5432";
const importUser = prompt("Enter your Postgres User to migrate to: [Default = infisical]: ") ?? "infisical";
const importPassword = prompt("Enter your Postgres Password to migrate to: ");
const importDatabase = prompt("Enter your Postgres Database to migrate to [Default = infisical]: ") ?? "infisical";
const orgId = prompt("Enter the organization ID to migrate: ");
if (!existsSync(path.join(__dirname, "../src/db/dump.sql"))) {
console.log("File not found, please export the database first.");
return;
}
execSync(
`PGDATABASE="${importDatabase}" PGPASSWORD="${importPassword}" PGHOST="${importHost}" PGPORT=${importPort} PGUSER=${importUser} psql -f ${path.join(
__dirname,
"../src/db/dump.sql"
)}`
);
execSync(
`PGDATABASE="${importDatabase}" PGPASSWORD="${importPassword}" PGHOST="${importHost}" PGPORT=${importPort} PGUSER=${importUser} psql -c "DELETE FROM public.organizations WHERE id != '${orgId}'"`
);
// delete global/instance-level resources not relevant to the organization to migrate
// users
execSync(
`PGDATABASE="${importDatabase}" PGPASSWORD="${importPassword}" PGHOST="${importHost}" PGPORT=${importPort} PGUSER=${importUser} psql -c 'DELETE FROM users WHERE users.id NOT IN (SELECT org_memberships."userId" FROM org_memberships)'`
);
// identities
execSync(
`PGDATABASE="${importDatabase}" PGPASSWORD="${importPassword}" PGHOST="${importHost}" PGPORT=${importPort} PGUSER=${importUser} psql -c 'DELETE FROM identities WHERE id NOT IN (SELECT "identityId" FROM identity_org_memberships)'`
);
// reset slack configuration in superAdmin
execSync(
`PGDATABASE="${importDatabase}" PGPASSWORD="${importPassword}" PGHOST="${importHost}" PGPORT=${importPort} PGUSER=${importUser} psql -c 'UPDATE super_admin SET "encryptedSlackClientId" = null, "encryptedSlackClientSecret" = null'`
);
console.log("Organization migrated successfully.");
};
const main = () => {
const action = prompt(
"Enter the action to perform\n 1. Export from existing instance.\n 2. Import org to instance.\n \n Action: "
);
if (action === "1") {
exportDb();
} else if (action === "2") {
importDbForOrg();
} else {
console.log("Invalid action");
}
};
main();

View File

@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.SamlConfig, "orgId")) {
await knex.schema.alterTable(TableName.SamlConfig, (t) => {
t.dropForeign("orgId");
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.SamlConfig, "orgId")) {
await knex.schema.alterTable(TableName.SamlConfig, (t) => {
t.dropForeign("orgId");
t.foreign("orgId").references("id").inTable(TableName.Organization);
});
}
}

View File

@ -2,6 +2,8 @@ import { z } from "zod";
import { GitAppOrgSchema, SecretScanningGitRisksSchema } from "@app/db/schemas";
import { SecretScanningRiskStatus } from "@app/ee/services/secret-scanning/secret-scanning-types";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@ -23,6 +25,13 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) =
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const appCfg = getConfig();
if (!appCfg.SECRET_SCANNING_ORG_WHITELIST?.includes(req.auth.orgId)) {
throw new BadRequestError({
message: "Secret scanning is temporarily unavailable."
});
}
const session = await server.services.secretScanning.createInstallationSession({
actor: req.permission.type,
actorId: req.permission.id,
@ -30,6 +39,7 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) =
actorOrgId: req.permission.orgId,
orgId: req.body.organizationId
});
return session;
}
});

View File

@ -1,6 +1,6 @@
import { ProbotOctokit } from "probot";
import { OrgMembershipRole } from "@app/db/schemas";
import { OrgMembershipRole, TableName } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
@ -61,7 +61,7 @@ export const secretScanningQueueFactory = ({
const getOrgAdminEmails = async (organizationId: string) => {
// get emails of admins
const adminsOfWork = await orgMemberDAL.findMembership({
orgId: organizationId,
[`${TableName.Organization}.id` as string]: organizationId,
role: OrgMembershipRole.Admin
});
return adminsOfWork.filter((userObject) => userObject.email).map((userObject) => userObject.email as string);

View File

@ -90,7 +90,7 @@ export const secretScanningServiceFactory = ({
const {
data: { repositories }
} = await octokit.apps.listReposAccessibleToInstallation();
if (!appCfg.DISABLE_SECRET_SCANNING) {
if (appCfg.SECRET_SCANNING_ORG_WHITELIST?.includes(actorOrgId)) {
await Promise.all(
repositories.map(({ id, full_name }) =>
secretScanningQueue.startFullRepoScan({
@ -164,7 +164,7 @@ export const secretScanningServiceFactory = ({
});
if (!installationLink) return;
if (!appCfg.DISABLE_SECRET_SCANNING) {
if (appCfg.SECRET_SCANNING_ORG_WHITELIST?.includes(installationLink.orgId)) {
await secretScanningQueue.startPushEventScan({
commits,
pusher: { name: pusher.name, email: pusher.email },

View File

@ -142,6 +142,7 @@ const envSchema = z
SECRET_SCANNING_WEBHOOK_SECRET: zpStr(z.string().optional()),
SECRET_SCANNING_GIT_APP_ID: zpStr(z.string().optional()),
SECRET_SCANNING_PRIVATE_KEY: zpStr(z.string().optional()),
SECRET_SCANNING_ORG_WHITELIST: zpStr(z.string().optional()),
// LICENSE
LICENSE_SERVER_URL: zpStr(z.string().optional().default("https://portal.infisical.com")),
LICENSE_SERVER_KEY: zpStr(z.string().optional()),
@ -177,7 +178,8 @@ const envSchema = z
Boolean(data.SECRET_SCANNING_GIT_APP_ID) &&
Boolean(data.SECRET_SCANNING_PRIVATE_KEY) &&
Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET),
samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG
samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG,
SECRET_SCANNING_ORG_WHITELIST: data.SECRET_SCANNING_ORG_WHITELIST?.split(",")
}));
let envCfg: Readonly<z.infer<typeof envSchema>>;

View File

@ -71,6 +71,13 @@ export class BadRequestError extends Error {
}
}
export class RateLimitError extends Error {
constructor({ message }: { message?: string }) {
super(message || "Rate limit exceeded");
this.name = "RateLimitExceeded";
}
}
export class NotFoundError extends Error {
name: string;

View File

@ -2,6 +2,7 @@ import type { RateLimitOptions, RateLimitPluginOptions } from "@fastify/rate-lim
import { Redis } from "ioredis";
import { getConfig } from "@app/lib/config/env";
import { RateLimitError } from "@app/lib/errors";
export const globalRateLimiterCfg = (): RateLimitPluginOptions => {
const appCfg = getConfig();
@ -10,6 +11,11 @@ export const globalRateLimiterCfg = (): RateLimitPluginOptions => {
: null;
return {
errorResponseBuilder: (_, context) => {
throw new RateLimitError({
message: `Rate limit exceeded. Please try again in ${context.after}`
});
},
timeWindow: 60 * 1000,
max: 600,
redis,

View File

@ -10,6 +10,7 @@ import {
GatewayTimeoutError,
InternalServerError,
NotFoundError,
RateLimitError,
ScimRequestError,
UnauthorizedError
} from "@app/lib/errors";
@ -27,7 +28,8 @@ enum HttpStatusCodes {
Forbidden = 403,
// eslint-disable-next-line @typescript-eslint/no-shadow
InternalServerError = 500,
GatewayTimeout = 504
GatewayTimeout = 504,
TooManyRequests = 429
}
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
@ -69,6 +71,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
message: error.message,
error: error.name
});
} else if (error instanceof RateLimitError) {
void res.status(HttpStatusCodes.TooManyRequests).send({
statusCode: HttpStatusCodes.TooManyRequests,
message: error.message,
error: error.name
});
} else if (error instanceof ScimRequestError) {
void res.status(error.status).send({
schemas: error.schemas,

View File

@ -225,9 +225,7 @@ export const registerRoutes = async (
}: { auditLogDb?: Knex; db: Knex; smtp: TSmtpService; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory }
) => {
const appCfg = getConfig();
if (!appCfg.DISABLE_SECRET_SCANNING) {
await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" });
}
await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" });
// db layers
const userDAL = userDALFactory(db);

View File

@ -16,8 +16,10 @@ as well as create a new project.
The **Settings** page lets you manage information about your organization including:
- Name: The name of your organization.
- Incident contacts: Emails that should be alerted if anything abnormal is detected within the organization.
- **Name**: The name of your organization.
- **Slug**: The slug of your organization.
- **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified.
- **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization.
![organization settings general](../../images/platform/organization/organization-settings-general.png)

View File

@ -28,6 +28,13 @@ Prerequisites:
![SCIM copy token](/images/platform/scim/scim-copy-token.png)
</Step>
<Step title="Add Users and Groups in Azure">
In Azure, navigate to Enterprise Application > Users and Groups. Add any users and/or groups to your application that you would like
to be provisioned over to Infisical.
![SCIM Azure Users and Groups](/images/platform/scim/azure/scim-azure-add-users-and-groups.png)
</Step>
<Step title="Configure SCIM in Azure">
In Azure, head to your Enterprise Application > Provisioning > Overview and press **Get started**.
@ -39,7 +46,7 @@ Prerequisites:
- Tenant URL: Input **SCIM URL** from Step 1.
- Secret Token: Input the **New SCIM Token** from Step 1.
Afterwards, press the **Test Connection** button to check that SCIM is configured properly.
Afterwards, click **Enable SCIM** and press the **Test Connection** button to check that SCIM is configured properly.
![SCIM Azure](/images/platform/scim/azure/scim-azure-config.png)
@ -71,4 +78,4 @@ Prerequisites:
For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
</Accordion>
</AccordionGroup>
</AccordionGroup>

View File

@ -0,0 +1,26 @@
---
title: "SCIM Group Mappings"
description: "Learn how to enhance your SCIM implementation using group mappings"
---
<Info>
SCIM provisioning, and by extension group mapping, is a paid feature.
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
then you should contact sales@infisical.com to purchase an enterprise license to use it.
</Info>
## SCIM Group to Organization Role Mapping
By default, when users are provisioned via SCIM, they will be assigned the default organization role configured in [Organization General Settings](/documentation/platform/organization#settings).
For more precise control over membership roles, you can set up SCIM Group to Organization Role Mappings. This enables you to assign specific roles based on the group from which a user is provisioned.
![SCIM Group Mapping](/images/platform/scim/scim-group-mapping.png)
To configure a mapping, simply enter the SCIM group's name and select the role you would like users to be assigned from this group. Be sure
to tap **Update Mappings** once complete.
<Note>
SCIM Group Mappings only apply when users are first provisioned. Previously provisioned users will not be affected, allowing you to customize user roles after they are added.
</Note>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -249,7 +249,8 @@
"documentation/platform/scim/overview",
"documentation/platform/scim/okta",
"documentation/platform/scim/azure",
"documentation/platform/scim/jumpcloud"
"documentation/platform/scim/jumpcloud",
"documentation/platform/scim/group-mappings"
]
}
]

View File

@ -115,7 +115,10 @@ export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => {
formState: { isSubmitting },
handleSubmit
} = useForm<FormData>({
resolver: zodResolver(createTagSchema)
resolver: zodResolver(createTagSchema),
defaultValues: {
color: secretTagsColors[0].hex
}
});
const { currentWorkspace } = useWorkspace();

View File

@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { faCheck, faMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { twMerge } from "tailwind-merge";
@ -15,6 +15,7 @@ export type CheckboxProps = Omit<
isRequired?: boolean;
checkIndicatorBg?: string | undefined;
isError?: boolean;
isIndeterminate?: boolean;
};
export const Checkbox = ({
@ -26,6 +27,7 @@ export const Checkbox = ({
isRequired,
checkIndicatorBg,
isError,
isIndeterminate,
...props
}: CheckboxProps): JSX.Element => {
return (
@ -45,7 +47,11 @@ export const Checkbox = ({
id={id}
>
<CheckboxPrimitive.Indicator className={`${checkIndicatorBg || "text-bunker-800"}`}>
<FontAwesomeIcon icon={faCheck} size="sm" />
{isIndeterminate ? (
<FontAwesomeIcon icon={faMinus} size="sm" />
) : (
<FontAwesomeIcon icon={faCheck} size="sm" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<label

View File

@ -1,4 +1,4 @@
import { HTMLAttributes, ReactNode, TdHTMLAttributes } from "react";
import { DetailedHTMLProps, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import { Skeleton } from "../Skeleton";
@ -7,12 +7,13 @@ export type TableContainerProps = {
children: ReactNode;
isRounded?: boolean;
className?: string;
};
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export const TableContainer = ({
children,
className,
isRounded = true
isRounded = true,
...props
}: TableContainerProps): JSX.Element => (
<div
className={twMerge(
@ -20,6 +21,7 @@ export const TableContainer = ({
isRounded && "rounded-lg",
className
)}
{...props}
>
{children}
</div>

View File

@ -230,6 +230,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
(!orgs?.map((org) => org.id)?.includes(router.query.id as string) &&
!router.asPath.includes("project") &&
!router.asPath.includes("personal") &&
!router.asPath.includes("secret-scanning") &&
!router.asPath.includes("integration")))
) {
router.push(`/org/${currentOrg?.id}/overview`);

View File

@ -72,7 +72,8 @@ const SecretScanning = withPermission(
</div>
{config.isSecretScanningDisabled && (
<NoticeBanner title="Secret scanning is in maintenance" className="mb-4">
We are working on improving the performance of secret scanning due to increased usage.
We are working on improving the performance of secret scanning due to increased
usage.
</NoticeBanner>
)}
<div className="relative mb-6 flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6">
@ -116,7 +117,7 @@ const SecretScanning = withPermission(
colorSchema="primary"
onClick={generateNewIntegrationSession}
className="h-min py-2"
isDisabled={!isAllowed || config.isSecretScanningDisabled}
isDisabled={!isAllowed}
>
Integrate with GitHub
</Button>

View File

@ -270,7 +270,7 @@ export const IdentityKubernetesAuthForm = ({
label="Allowed Namespaces"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any namespaces."
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
>
<Input {...field} placeholder="namespaceA, namespaceB" type="text" />
</FormControl>

View File

@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */
import { z } from "zod";
import { OrgPermissionSubjects } from "@app/context";
import { TPermission } from "@app/hooks/api/roles/types";
const generalPermissionSchema = z
@ -46,7 +47,8 @@ export const formSchema = z.object({
ldap: generalPermissionSchema,
billing: generalPermissionSchema,
identity: generalPermissionSchema,
"organization-admin-console": adminConsolePermissionSchmea
"organization-admin-console": adminConsolePermissionSchmea,
[OrgPermissionSubjects.Kms]: generalPermissionSchema
})
.optional()
});

View File

@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api";
import {
formRolePermission2API,
@ -64,6 +64,10 @@ const SIMPLE_PERMISSION_OPTIONS = [
{
title: "SCIM",
formName: "scim"
},
{
title: "External KMS",
formName: OrgPermissionSubjects.Kms
}
] as const;

View File

@ -2,29 +2,33 @@ import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
import { useRouter } from "next/router";
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
// This is first step and the whole secret crud will be moved to this global page scope state
// this will allow more stuff like undo grouping stuffs etc
type SelectedSecretState = {
selectedSecret: Record<string, boolean>;
selectedSecret: Record<string, SecretV3RawSanitized>;
action: {
toggle: (id: string) => void;
toggle: (secret: SecretV3RawSanitized) => void;
reset: () => void;
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
};
};
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
selectedSecret: {},
action: {
toggle: (id) =>
toggle: (secret) =>
set((state) => {
const isChecked = Boolean(state.selectedSecret?.[id]);
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
const newChecks = { ...state.selectedSecret };
// remove selection if its present else add it
if (isChecked) delete newChecks[id];
else newChecks[id] = true;
if (isChecked) delete newChecks[secret.id];
else newChecks[secret.id] = secret;
return { selectedSecret: newChecks };
}),
reset: () => set({ selectedSecret: {} })
reset: () => set({ selectedSecret: {} }),
set: (secrets) => set({ selectedSecret: secrets })
}
});

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
@ -9,7 +9,7 @@ import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner } from "@app/components/permissions";
import { ContentLoader, Pagination } from "@app/components/v2";
import { Checkbox, ContentLoader, Pagination, Tooltip } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
@ -39,7 +39,11 @@ import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone";
import { SecretListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView";
import { StoreProvider } from "./SecretMainPage.store";
import {
StoreProvider,
useSelectedSecretActions,
useSelectedSecrets
} from "./SecretMainPage.store";
import { Filter, RowType } from "./SecretMainPage.types";
const LOADER_TEXT = [
@ -48,7 +52,7 @@ const LOADER_TEXT = [
"Getting secret import links..."
];
export const SecretMainPage = () => {
const SecretMainPageContent = () => {
const { t } = useTranslation();
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const router = useRouter();
@ -283,6 +287,33 @@ export const SecretMainPage = () => {
}
}, [secretPath]);
const selectedSecrets = useSelectedSecrets();
const selectedSecretActions = useSelectedSecretActions();
const allRowsSelectedOnPage = useMemo(() => {
if (secrets?.every((secret) => selectedSecrets[secret.id]))
return { isChecked: true, isIndeterminate: false };
if (secrets?.some((secret) => selectedSecrets[secret.id]))
return { isChecked: true, isIndeterminate: true };
return { isChecked: false, isIndeterminate: false };
}, [selectedSecrets, secrets]);
const toggleSelectAllRows = () => {
const newChecks = { ...selectedSecrets };
secrets?.forEach((secret) => {
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[secret.id];
} else {
newChecks[secret.id] = secret;
}
});
selectedSecretActions.set(newChecks);
};
if (isDetailsLoading) {
return <ContentLoader text={LOADER_TEXT} />;
}
@ -300,169 +331,192 @@ export const SecretMainPage = () => {
};
return (
<StoreProvider>
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
<div className="relative right-6 -top-2 mb-2 ml-6">
<NavHeader
pageName={t("dashboard.title")}
currentEnv={environment}
userAvailableEnvs={currentWorkspace?.environments}
isFolderMode
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
<div className="relative right-6 -top-2 mb-2 ml-6">
<NavHeader
pageName={t("dashboard.title")}
currentEnv={environment}
userAvailableEnvs={currentWorkspace?.environments}
isFolderMode
secretPath={secretPath}
isProjectRelated
onEnvChange={handleEnvChange}
isProtectedBranch={isProtectedBranch}
protectionPolicyName={boardPolicy?.name}
/>
</div>
{!isRollbackMode ? (
<>
<ActionBar
environment={environment}
workspaceId={workspaceId}
projectSlug={projectSlug}
secretPath={secretPath}
isProjectRelated
onEnvChange={handleEnvChange}
isProtectedBranch={isProtectedBranch}
protectionPolicyName={boardPolicy?.name}
isVisible={isVisible}
filter={filter}
tags={tags}
onVisibilityToggle={handleToggleVisibility}
onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0}
isSnapshotCountLoading={isSnapshotCountLoading}
onToggleRowType={handleToggleRowType}
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
/>
</div>
{!isRollbackMode ? (
<>
<ActionBar
secrets={secrets}
environment={environment}
workspaceId={workspaceId}
projectSlug={projectSlug}
secretPath={secretPath}
isVisible={isVisible}
filter={filter}
tags={tags}
onVisibilityToggle={handleToggleVisibility}
onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0}
isSnapshotCountLoading={isSnapshotCountLoading}
onToggleRowType={handleToggleRowType}
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
/>
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard">
{isNotEmpty && (
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard">
{isNotEmpty && (
<div
className={twMerge(
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
)}
>
<div
className={twMerge(
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
)}
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
role="button"
tabIndex={0}
onClick={handleSortToggle}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleSortToggle();
}}
>
<div style={{ width: "2.8rem" }} className="flex-shrink-0 px-4 py-3" />
<div
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
role="button"
tabIndex={0}
onClick={handleSortToggle}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleSortToggle();
}}
<Tooltip
className="max-w-[20rem] whitespace-nowrap"
content={
totalCount > 0
? `${
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
} all secrets on page`
: ""
}
>
Key
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
className="ml-2"
/>
</div>
<div className="flex-grow px-4 py-2">Value</div>
<div className="mr-6 ml-1">
<Checkbox
isDisabled={totalCount === 0}
id="checkbox-select-all-rows"
onClick={(e) => e.stopPropagation()}
isChecked={allRowsSelectedOnPage.isChecked}
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
onCheckedChange={toggleSelectAllRows}
/>
</div>
</Tooltip>
Key
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
className="ml-2"
/>
</div>
)}
{canReadSecret && imports?.length && (
<SecretImportListView
searchTerm={debouncedSearchFilter}
secretImports={imports}
isFetching={isDetailsFetching}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
importedSecrets={importedSecrets}
/>
)}
{folders?.length && (
<FolderListView
folders={folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
onNavigateToFolder={handleResetFilter}
/>
)}
{canReadSecret && dynamicSecrets?.length && (
<DynamicSecretListView
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={dynamicSecrets}
/>
)}
{canReadSecret && secrets?.length && (
<SecretListView
secrets={secrets}
tags={tags}
isVisible={isVisible}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isProtectedBranch={isProtectedBranch}
/>
)}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
</div>
<div className="flex-grow px-4 py-2">Value</div>
</div>
)}
{canReadSecret && imports?.length && (
<SecretImportListView
searchTerm={debouncedSearchFilter}
secretImports={imports}
isFetching={isDetailsFetching}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
importedSecrets={importedSecrets}
/>
)}
{folders?.length && (
<FolderListView
folders={folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
onNavigateToFolder={handleResetFilter}
/>
)}
{canReadSecret && dynamicSecrets?.length && (
<DynamicSecretListView
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={dynamicSecrets}
/>
)}
{canReadSecret && secrets?.length && (
<SecretListView
secrets={secrets}
tags={tags}
isVisible={isVisible}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isProtectedBranch={isProtectedBranch}
/>
)}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
</div>
{!isDetailsLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
<CreateSecretForm
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
autoCapitalize={currentWorkspace?.autoCapitalization}
isProtectedBranch={isProtectedBranch}
</div>
{!isDetailsLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
<SecretDropzone
secrets={secrets}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isNotEmpty}
environments={currentWorkspace?.environments}
isProtectedBranch={isProtectedBranch}
/>
<PitDrawer
secretSnaphots={snapshotList}
snapshotId={snapshotId}
isDrawerOpen={popUp.snapshots.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
hasNextPage={hasNextSnapshotListPage}
fetchNextPage={fetchNextSnapshotList}
onSelectSnapshot={handleSelectSnapshot}
isFetchingNextPage={isFetchingNextSnapshotList}
/>
</>
) : (
<SnapshotView
snapshotId={snapshotId || ""}
)}
<CreateSecretForm
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
secrets={secrets}
folders={folders}
snapshotCount={snapshotCount}
onGoBack={handleResetSnapshot}
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
autoCapitalize={currentWorkspace?.autoCapitalization}
isProtectedBranch={isProtectedBranch}
/>
)}
</div>
</StoreProvider>
<SecretDropzone
secrets={secrets}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isNotEmpty}
environments={currentWorkspace?.environments}
isProtectedBranch={isProtectedBranch}
/>
<PitDrawer
secretSnaphots={snapshotList}
snapshotId={snapshotId}
isDrawerOpen={popUp.snapshots.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
hasNextPage={hasNextSnapshotListPage}
fetchNextPage={fetchNextSnapshotList}
onSelectSnapshot={handleSelectSnapshot}
isFetchingNextPage={isFetchingNextSnapshotList}
/>
</>
) : (
<SnapshotView
snapshotId={snapshotId || ""}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
secrets={secrets}
folders={folders}
snapshotCount={snapshotCount}
onGoBack={handleResetSnapshot}
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
/>
)}
</div>
);
};
export const SecretMainPage = () => (
<StoreProvider>
<SecretMainPageContent />
</StoreProvider>
);

View File

@ -54,7 +54,7 @@ import {
import { usePopUp } from "@app/hooks";
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
import { SecretType, WsTag } from "@app/hooks/api/types";
import {
PopUpNames,
@ -69,8 +69,7 @@ import { FolderForm } from "./FolderForm";
import { MoveSecretsModal } from "./MoveSecretsModal";
type Props = {
secrets?: SecretV3RawSanitized[];
// swtich the secrets type as it gets decrypted after api call
// switch the secrets type as it gets decrypted after api call
environment: string;
// @depreciated will be moving all these details to zustand
workspaceId: string;
@ -89,7 +88,6 @@ type Props = {
};
export const ActionBar = ({
secrets = [],
environment,
workspaceId,
projectSlug,
@ -202,7 +200,7 @@ export const ActionBar = ({
};
const handleSecretBulkDelete = async () => {
const bulkDeletedSecrets = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
const bulkDeletedSecrets = Object.values(selectedSecrets);
try {
await deleteBatchSecretV3({
secretPath,
@ -235,7 +233,7 @@ export const ActionBar = ({
shouldOverwrite: boolean;
}) => {
try {
const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
const secretsToMove = Object.values(selectedSecrets);
const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
projectSlug,
shouldOverwrite,
@ -553,7 +551,7 @@ export const ActionBar = ({
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
</IconButton>
</Tooltip>
<div className="ml-4 flex-grow px-2 text-sm">
<div className="ml-2 flex-grow px-2 text-sm">
{Object.keys(selectedSecrets).length} Selected
</div>
<ProjectPermissionCan

View File

@ -158,17 +158,17 @@ export const SecretImportItem = ({
}
}}
>
<div className="flex w-12 items-center px-4 py-2 text-green-700">
<div className="flex w-11 items-center py-2 pl-5 text-green-700">
<FontAwesomeIcon icon={faFileImport} />
</div>
<div className="flex flex-grow items-center px-4 py-2">
<div className="flex flex-grow items-center py-2 pl-4 pr-2">
<EnvFolderIcon
env={importEnv.slug || ""}
secretPath={secretImport?.importPath || ""}
// isReplication={isReplication}
/>
</div>
<div className="flex items-center space-x-4 px-4 py-2">
<div className="flex items-center space-x-4 py-2 pr-4">
{lastReplicated && (
<Tooltip
position="left"

View File

@ -56,7 +56,7 @@ type Props = {
onDetailViewSecret: (sec: SecretV3RawSanitized) => void;
isVisible?: boolean;
isSelected?: boolean;
onToggleSecretSelect: (id: string) => void;
onToggleSecretSelect: (secret: SecretV3RawSanitized) => void;
tags: WsTag[];
onCreateTag: () => void;
environment: string;
@ -218,7 +218,7 @@ export const SecretItem = memo(
<Checkbox
id={`checkbox-${secret.id}`}
isChecked={isSelected}
onCheckedChange={() => onToggleSecretSelect(secret.id)}
onCheckedChange={() => onToggleSecretSelect(secret)}
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
/>
<FontAwesomeSymbol

View File

@ -304,7 +304,7 @@ export const SecretListView = ({
environment={environment}
secretPath={secretPath}
tags={wsTags}
isSelected={selectedSecrets?.[secret.id]}
isSelected={Boolean(selectedSecrets?.[secret.id])}
onToggleSecretSelect={toggleSelectedSecret}
isVisible={isVisible}
secret={secret}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
@ -25,6 +25,7 @@ import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@ -67,7 +68,7 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
import { SecretType, TSecretFolder } from "@app/hooks/api/types";
import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
@ -106,18 +107,10 @@ export const SecretOverviewPage = () => {
const { t } = useTranslation();
const router = useRouter();
// this is to set expandable table width
// coz when overflow the table goes to the right
const parentTableRef = useRef<HTMLTableElement>(null);
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [debouncedScrollOffset] = useDebounce(scrollOffset);
const { permission } = useProjectPermission();
useEffect(() => {
if (parentTableRef.current) {
setExpandableTableWidth(parentTableRef.current.clientWidth);
}
}, [parentTableRef.current]);
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3;
const { currentOrg } = useOrganization();
@ -133,8 +126,9 @@ export const SecretOverviewPage = () => {
>(new Map());
const [selectedEntries, setSelectedEntries] = useState<{
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
// selectedEntries[name/key][envSlug][resource]
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
}>({
[EntryType.FOLDER]: {},
[EntryType.SECRET]: {}
@ -152,23 +146,6 @@ export const SecretOverviewPage = () => {
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
const toggleSelectedEntry = useCallback(
(type: EntryType, key: string) => {
const isChecked = Boolean(selectedEntries[type]?.[key]);
const newChecks = { ...selectedEntries };
// remove selection if its present else add it
if (isChecked) {
delete newChecks[type][key];
} else {
newChecks[type][key] = true;
}
setSelectedEntries(newChecks);
},
[selectedEntries]
);
const resetSelectedEntries = useCallback(() => {
setSelectedEntries({
[EntryType.FOLDER]: {},
@ -177,19 +154,13 @@ export const SecretOverviewPage = () => {
}, []);
useEffect(() => {
const handleParentTableWidthResize = () => {
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
};
const onRouteChangeStart = () => {
resetSelectedEntries();
};
router.events.on("routeChangeStart", onRouteChangeStart);
window.addEventListener("resize", handleParentTableWidthResize);
return () => {
window.removeEventListener("resize", handleParentTableWidthResize);
router.events.off("routeChangeStart", onRouteChangeStart);
};
}, []);
@ -551,6 +522,83 @@ export const SecretOverviewPage = () => {
[]
);
const allRowsSelectedOnPage = useMemo(() => {
if (
(!secrets?.length ||
secrets?.every((secret) => selectedEntries[EntryType.SECRET][secret.key])) &&
(!folders?.length ||
folders?.every((folder) => selectedEntries[EntryType.FOLDER][folder.name]))
)
return { isChecked: true, isIndeterminate: false };
if (
secrets?.some((secret) => selectedEntries[EntryType.SECRET][secret.key]) ||
folders?.some((folder) => selectedEntries[EntryType.FOLDER][folder.name])
)
return { isChecked: true, isIndeterminate: true };
return { isChecked: false, isIndeterminate: false };
}, [selectedEntries, secrets, folders]);
const toggleSelectedEntry = useCallback(
(type: EntryType, key: string) => {
const isChecked = Boolean(selectedEntries[type]?.[key]);
const newChecks = { ...selectedEntries };
// remove selection if its present else add it
if (isChecked) {
delete newChecks[type][key];
} else {
newChecks[type][key] = {};
userAvailableEnvs.forEach((env) => {
const resource =
type === EntryType.SECRET
? getSecretByKey(env.slug, key)
: getFolderByNameAndEnv(key, env.slug);
if (resource) newChecks[type][key][env.slug] = resource;
});
}
setSelectedEntries(newChecks);
},
[selectedEntries, getFolderByNameAndEnv, getSecretByKey]
);
const toggleSelectAllRows = () => {
const newChecks = { ...selectedEntries };
userAvailableEnvs.forEach((env) => {
secrets?.forEach((secret) => {
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[EntryType.SECRET][secret.key];
} else {
if (!newChecks[EntryType.SECRET][secret.key])
newChecks[EntryType.SECRET][secret.key] = {};
const resource = getSecretByKey(env.slug, secret.key);
if (resource) newChecks[EntryType.SECRET][secret.key][env.slug] = resource;
}
});
folders?.forEach((folder) => {
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[EntryType.FOLDER][folder.name];
} else {
if (!newChecks[EntryType.FOLDER][folder.name])
newChecks[EntryType.FOLDER][folder.name] = {};
const resource = getFolderByNameAndEnv(folder.name, env.slug);
if (resource) newChecks[EntryType.FOLDER][folder.name][env.slug] = resource;
}
});
});
setSelectedEntries(newChecks);
};
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
@ -799,18 +847,39 @@ export const SecretOverviewPage = () => {
</div>
<SelectionPanel
secretPath={secretPath}
getSecretByKey={getSecretByKey}
getFolderByNameAndEnv={getFolderByNameAndEnv}
selectedEntries={selectedEntries}
resetSelectedEntries={resetSelectedEntries}
/>
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
<TableContainer className="rounded-b-none">
<div className="thin-scrollbar mt-4">
<TableContainer
onScroll={(e) => setScrollOffset(e.currentTarget.scrollLeft)}
className="thin-scrollbar"
>
<Table>
<THead>
<Tr className="sticky top-0 z-20 border-0">
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-3.5 pb-3">
<div className="flex items-center border-b border-r border-mineshaft-600 pr-5 pl-3 pt-3.5 pb-3">
<Tooltip
className="max-w-[20rem] whitespace-nowrap capitalize"
content={
totalCount > 0
? `${
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
} all folders and secrets on page`
: ""
}
>
<div className="mr-4 ml-2">
<Checkbox
isDisabled={totalCount === 0}
id="checkbox-select-all-rows"
isChecked={allRowsSelectedOnPage.isChecked}
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
onCheckedChange={toggleSelectAllRows}
/>
</div>
</Tooltip>
Name
<IconButton
variant="plain"
@ -938,7 +1007,7 @@ export const SecretOverviewPage = () => {
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
isSelected={selectedEntries.folder[folderName]}
isSelected={Boolean(selectedEntries.folder[folderName])}
onToggleFolderSelect={() =>
toggleSelectedEntry(EntryType.FOLDER, folderName)
}
@ -960,7 +1029,7 @@ export const SecretOverviewPage = () => {
))}
{secKeys.map((key, index) => (
<SecretOverviewTableRow
isSelected={selectedEntries.secret[key]}
isSelected={Boolean(selectedEntries.secret[key])}
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
@ -972,7 +1041,7 @@ export const SecretOverviewPage = () => {
environments={visibleEnvs}
secretKey={key}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
scrollOffset={debouncedScrollOffset}
/>
))}
</>

View File

@ -23,7 +23,6 @@ type Props = {
secretKey: string;
secretPath: string;
environments: { name: string; slug: string }[];
expandableColWidth: number;
isSelected: boolean;
onToggleSecretSelect: (key: string) => void;
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
@ -41,6 +40,7 @@ type Props = {
env: string,
secretName: string
) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined;
scrollOffset: number;
};
export const SecretOverviewTableRow = ({
@ -53,9 +53,7 @@ export const SecretOverviewTableRow = ({
onSecretDelete,
isImportedSecretPresentInEnv,
getImportedSecretByKey,
// temporary until below todo is resolved
// eslint-disable-next-line @typescript-eslint/no-unused-vars
expandableColWidth,
scrollOffset,
onToggleSecretSelect,
isSelected
}: Props) => {
@ -152,11 +150,11 @@ export const SecretOverviewTableRow = ({
}`}
>
<div
className="ml-2 w-[99%] p-2"
// TODO: scott expandableColWidth sometimes 0 due to parent ref not mounting, opting for relative width until resolved
// style={{
// width: `calc(${expandableColWidth} - 1rem)`
// }}
className="ml-2 p-2"
style={{
marginLeft: scrollOffset,
width: "calc(100vw - 290px)" // 290px accounts for sidebar and margin
}}
>
<SecretRenameRow
secretKey={secretKey}

View File

@ -27,31 +27,23 @@ export enum EntryType {
type Props = {
secretPath: string;
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined;
resetSelectedEntries: () => void;
selectedEntries: {
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
};
};
export const SelectionPanel = ({
getFolderByNameAndEnv,
getSecretByKey,
secretPath,
resetSelectedEntries,
selectedEntries
}: Props) => {
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
const { permission } = useProjectPermission();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"bulkDeleteEntries"
] as const);
const selectedFolderCount = Object.keys(selectedEntries.folder).length
const selectedKeysCount = Object.keys(selectedEntries.secret).length
const selectedCount = selectedFolderCount + selectedKeysCount
const selectedFolderCount = Object.keys(selectedEntries.folder).length;
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
const selectedCount = selectedFolderCount + selectedKeysCount;
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
@ -77,7 +69,7 @@ export const SelectionPanel = ({
return "Do you want to delete the selected secrets across environments?";
}
return "Do you want to delete the selected folders across environments?";
}
};
const handleBulkDelete = async () => {
let processedEntries = 0;
@ -94,8 +86,8 @@ export const SelectionPanel = ({
}
await Promise.all(
Object.keys(selectedEntries.folder).map(async (folderName) => {
const folder = getFolderByNameAndEnv(folderName, env.slug);
Object.values(selectedEntries.folder).map(async (folderRecord) => {
const folder = folderRecord[env.slug];
if (folder) {
processedEntries += 1;
await deleteFolder({
@ -108,9 +100,9 @@ export const SelectionPanel = ({
})
);
const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
const entry = getSecretByKey(env.slug, secretName);
const secretsToDelete = Object.values(selectedEntries.secret).reduce(
(accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => {
const entry = secretRecord[env.slug];
if (entry) {
return [
...accum,
@ -173,7 +165,7 @@ export const SelectionPanel = ({
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
</IconButton>
</Tooltip>
<div className="ml-4 flex-grow px-2 text-sm">{selectedCount} Selected</div>
<div className="ml-1 flex-grow px-2 text-sm">{selectedCount} Selected</div>
{shouldShowDelete && (
<Button
variant="outline_bg"