mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #3190 from Infisical/revert-3189-revert-3128-daniel/view-secret-value-permission
feat(api/secrets): view secret value permission 2
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { faChevronRight, faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
faChevronRight,
|
||||
faExclamationTriangle,
|
||||
faEye,
|
||||
faEyeSlash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { AxiosError } from "axios";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { FormControl, FormLabel, SecretInput, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useGetSecretReferenceTree } from "@app/hooks/api";
|
||||
import { TSecretReferenceTraceNode } from "@app/hooks/api/types";
|
||||
import { ApiErrorTypes, TApiErrors, TSecretReferenceTraceNode } from "@app/hooks/api/types";
|
||||
|
||||
import style from "./SecretReferenceDetails.module.css";
|
||||
|
||||
@@ -84,7 +92,7 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
|
||||
const { data, isPending } = useGetSecretReferenceTree({
|
||||
const { data, isPending, isError, error } = useGetSecretReferenceTree({
|
||||
secretPath,
|
||||
environmentSlug: environment,
|
||||
projectId,
|
||||
@@ -94,6 +102,26 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
|
||||
const tree = data?.tree;
|
||||
const secretValue = data?.value;
|
||||
|
||||
useEffect(() => {
|
||||
if (error instanceof AxiosError) {
|
||||
const err = error?.response?.data as TApiErrors;
|
||||
|
||||
if (err?.error === ApiErrorTypes.CustomForbiddenError) {
|
||||
createNotification({
|
||||
title: "You don't have permission to view reference tree",
|
||||
text: "You don't have permission to view one or more of the referenced secrets.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
createNotification({
|
||||
title: "Error fetching secret reference tree",
|
||||
text: "Please try again later.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
@@ -114,11 +142,16 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
|
||||
</FormControl>
|
||||
<FormLabel className="mb-2" label="Reference Tree" />
|
||||
<div className="thin-scrollbar relative max-h-96 overflow-auto rounded-md border border-mineshaft-600 bg-bunker-700 py-6 text-sm text-mineshaft-200">
|
||||
{tree && (
|
||||
{isError ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2 text-red-500" />
|
||||
<p className="text-red-500">Error fetching secret reference tree</p>
|
||||
</div>
|
||||
) : tree ? (
|
||||
<ul className={style.tree}>
|
||||
<SecretReferenceNode node={tree} isRoot secretKey={secretKey} />
|
||||
</ul>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-mineshaft-400">
|
||||
Click a secret key to view its sub-references.
|
||||
|
||||
22
frontend/src/components/v2/Blur/Blur.tsx
Normal file
22
frontend/src/components/v2/Blur/Blur.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Tooltip } from "../Tooltip/Tooltip";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
tooltipText?: string;
|
||||
}
|
||||
|
||||
export const Blur = ({ className, tooltipText }: IProps) => {
|
||||
return (
|
||||
<Tooltip content={tooltipText} isDisabled={!tooltipText}>
|
||||
<div
|
||||
className={twMerge("flex w-80 flex-grow items-center py-1 pl-4 pr-2", className)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<span className="blur">********</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
1
frontend/src/components/v2/Blur/index.tsx
Normal file
1
frontend/src/components/v2/Blur/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { Blur } from "./Blur";
|
||||
@@ -120,6 +120,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
|
||||
const isPopupOpen = Boolean(suggestionSource.isOpen) && isFocused;
|
||||
const { data: secrets } = useGetProjectSecrets({
|
||||
viewSecretValue: false,
|
||||
environment: suggestionSource.environment || "",
|
||||
secretPath: suggestionSource.secretPath || "",
|
||||
workspaceId,
|
||||
|
||||
@@ -7,6 +7,15 @@ export enum ProjectPermissionActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretActions {
|
||||
DescribeAndReadValue = "read",
|
||||
DescribeSecret = "describeSecret",
|
||||
ReadValue = "readValue",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionDynamicSecretActions {
|
||||
ReadRootCredential = "read-root-credential",
|
||||
CreateRootCredential = "create-root-credential",
|
||||
@@ -138,7 +147,7 @@ export type SecretImportSubjectFields = {
|
||||
|
||||
export type ProjectPermissionSet =
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSecretActions,
|
||||
(
|
||||
| ProjectPermissionSub.Secrets
|
||||
| (ForcedSubject<ProjectPermissionSub.Secrets> & SecretSubjectFields)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
useGetAccessibleSecrets,
|
||||
useGetProjectSecretsDetails,
|
||||
useGetProjectSecretsOverview,
|
||||
useGetProjectSecretsQuickSearch
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DashboardSecretsOrderBy,
|
||||
TDashboardProjectSecretsQuickSearch,
|
||||
TDashboardProjectSecretsQuickSearchResponse,
|
||||
TGetAccessibleSecretsDTO,
|
||||
TGetDashboardProjectSecretsByKeys,
|
||||
TGetDashboardProjectSecretsDetailsDTO,
|
||||
TGetDashboardProjectSecretsOverviewDTO,
|
||||
@@ -20,6 +21,8 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import { groupBy, unique } from "@app/lib/fn/array";
|
||||
|
||||
import { SecretV3Raw } from "../types";
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: () => ["dashboard"] as const,
|
||||
getDashboardSecrets: ({
|
||||
@@ -58,6 +61,17 @@ export const dashboardKeys = {
|
||||
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
|
||||
"quick-search",
|
||||
params
|
||||
] as const,
|
||||
getAccessibleSecrets: ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
}: TGetAccessibleSecretsDTO) =>
|
||||
[
|
||||
...dashboardKeys.all(),
|
||||
"accessible-secrets",
|
||||
{ projectId, secretPath, environment, filterByAction }
|
||||
] as const
|
||||
};
|
||||
|
||||
@@ -207,6 +221,7 @@ export const useGetProjectSecretsDetails = (
|
||||
search = "",
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
viewSecretValue,
|
||||
includeImports,
|
||||
includeDynamicSecrets,
|
||||
tags
|
||||
@@ -231,6 +246,7 @@ export const useGetProjectSecretsDetails = (
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
viewSecretValue,
|
||||
offset,
|
||||
projectId,
|
||||
environment,
|
||||
@@ -247,6 +263,7 @@ export const useGetProjectSecretsDetails = (
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
viewSecretValue,
|
||||
offset,
|
||||
projectId,
|
||||
environment,
|
||||
@@ -292,6 +309,22 @@ export const fetchProjectSecretsQuickSearch = async ({
|
||||
return data;
|
||||
};
|
||||
|
||||
const fetchAccessibleSecrets = async ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>(
|
||||
"/api/v1/dashboard/accessible-secrets",
|
||||
{
|
||||
params: { projectId, secretPath, environment, filterByAction }
|
||||
}
|
||||
);
|
||||
|
||||
return data.secrets;
|
||||
};
|
||||
|
||||
export const useGetProjectSecretsQuickSearch = (
|
||||
{
|
||||
projectId,
|
||||
@@ -354,3 +387,32 @@ export const useGetProjectSecretsQuickSearch = (
|
||||
placeholderData: (previousData) => previousData
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetAccessibleSecrets = ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction,
|
||||
options
|
||||
}: TGetAccessibleSecretsDTO & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
SecretV3Raw[],
|
||||
unknown,
|
||||
SecretV3Raw[],
|
||||
ReturnType<typeof dashboardKeys.getAccessibleSecrets>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
}) => {
|
||||
return useQuery({
|
||||
...options,
|
||||
queryKey: dashboardKeys.getAccessibleSecrets({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
}),
|
||||
queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction })
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
@@ -69,6 +70,7 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
|
||||
TGetDashboardProjectSecretsOverviewDTO,
|
||||
"environments"
|
||||
> & {
|
||||
viewSecretValue: boolean;
|
||||
environment: string;
|
||||
includeImports?: boolean;
|
||||
tags: Record<string, boolean>;
|
||||
@@ -100,3 +102,12 @@ export type TGetDashboardProjectSecretsByKeys = {
|
||||
environment: string;
|
||||
keys: string[];
|
||||
};
|
||||
|
||||
export type TGetAccessibleSecretsDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
filterByAction:
|
||||
| ProjectPermissionSecretActions.DescribeSecret
|
||||
| ProjectPermissionSecretActions.ReadValue;
|
||||
};
|
||||
|
||||
@@ -10,18 +10,14 @@ import {
|
||||
formatedConditionsOperatorNames,
|
||||
PermissionConditionOperators
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
import { ApiErrorTypes, TApiErrors } from "./types";
|
||||
|
||||
// this is saved in react-query cache
|
||||
export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ["infisical__signup-temp-token"];
|
||||
export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"];
|
||||
export const AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"];
|
||||
|
||||
const camelCaseToSpaces = (input: string) => {
|
||||
return input.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export const decryptSecrets = (
|
||||
id: encSecret.id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
secretValueHidden: encSecret.secretValueHidden,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
|
||||
@@ -137,6 +137,7 @@ export const useGetImportedSecretsSingleEnv = ({
|
||||
env: encSecret.environment,
|
||||
key: encSecret.secretKey,
|
||||
value: encSecret.secretValue,
|
||||
secretValueHidden: encSecret.secretValueHidden,
|
||||
tags: encSecret.tags,
|
||||
comment: encSecret.secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
@@ -176,6 +177,7 @@ export const useGetImportedSecretsAllEnvs = ({
|
||||
env: encSecret.environment,
|
||||
key: encSecret.secretKey,
|
||||
value: encSecret.secretValue,
|
||||
secretValueHidden: encSecret.secretValueHidden,
|
||||
tags: encSecret.tags,
|
||||
comment: encSecret.secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
|
||||
@@ -75,6 +75,7 @@ export const useGetSnapshotSecrets = ({ snapshotId }: TSnapshotDataProps) =>
|
||||
id: secretVersion.secretId,
|
||||
env: data.environment.slug,
|
||||
key: secretVersion.secretKey,
|
||||
secretValueHidden: secretVersion.secretValueHidden,
|
||||
value: secretVersion.secretValue || "",
|
||||
tags: secretVersion.tags,
|
||||
comment: secretVersion.secretComment,
|
||||
|
||||
@@ -26,8 +26,13 @@ import {
|
||||
|
||||
export const secretKeys = {
|
||||
// this is also used in secretSnapshot part
|
||||
getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) =>
|
||||
[{ workspaceId, environment, secretPath }, "secrets"] as const,
|
||||
getProjectSecret: ({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue
|
||||
}: TGetProjectSecretsKey) =>
|
||||
[{ workspaceId, environment, secretPath, viewSecretValue }, "secrets"] as const,
|
||||
getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const,
|
||||
getSecretAccessList: ({
|
||||
workspaceId,
|
||||
@@ -44,13 +49,15 @@ export const fetchProjectSecrets = async ({
|
||||
environment,
|
||||
secretPath,
|
||||
includeImports,
|
||||
expandSecretReferences
|
||||
expandSecretReferences,
|
||||
viewSecretValue
|
||||
}: TGetProjectSecretsKey) => {
|
||||
const { data } = await apiRequest.get<SecretV3RawResponse>("/api/v3/secrets/raw", {
|
||||
params: {
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
viewSecretValue,
|
||||
expandSecretReferences,
|
||||
include_imports: includeImports
|
||||
}
|
||||
@@ -68,6 +75,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
|
||||
env: el.environment,
|
||||
key: el.secretKey,
|
||||
value: el.secretValue,
|
||||
secretValueHidden: el.secretValueHidden,
|
||||
tags: el.tags || [],
|
||||
comment: el.secretComment || "",
|
||||
reminderRepeatDays: el.secretReminderRepeatDays,
|
||||
@@ -107,6 +115,7 @@ export const useGetProjectSecrets = ({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue,
|
||||
options
|
||||
}: TGetProjectSecretsDTO & {
|
||||
options?: Omit<
|
||||
@@ -123,8 +132,13 @@ export const useGetProjectSecrets = ({
|
||||
...options,
|
||||
// wait for all values to be available
|
||||
enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true),
|
||||
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }),
|
||||
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
|
||||
queryKey: secretKeys.getProjectSecret({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue
|
||||
}),
|
||||
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath, viewSecretValue }),
|
||||
select: useCallback(
|
||||
(data: Awaited<ReturnType<typeof fetchProjectSecrets>>) => mergePersonalSecrets(data.secrets),
|
||||
[]
|
||||
|
||||
@@ -19,6 +19,7 @@ export type EncryptedSecret = {
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
secretValueHidden: boolean;
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -37,6 +38,7 @@ export type SecretV3RawSanitized = {
|
||||
version: number;
|
||||
key: string;
|
||||
value?: string;
|
||||
secretValueHidden: boolean;
|
||||
comment?: string;
|
||||
reminderRepeatDays?: number | null;
|
||||
reminderNote?: string | null;
|
||||
@@ -61,6 +63,7 @@ export type SecretV3Raw = {
|
||||
environment: string;
|
||||
version: number;
|
||||
type: string;
|
||||
secretValueHidden: boolean;
|
||||
secretKey: string;
|
||||
secretPath: string;
|
||||
secretValue?: string;
|
||||
@@ -95,6 +98,7 @@ export type SecretVersions = {
|
||||
envId: string;
|
||||
secretKey: string;
|
||||
secretValue?: string;
|
||||
secretValueHidden: boolean;
|
||||
secretComment?: string;
|
||||
tags: WsTag[];
|
||||
__v: number;
|
||||
@@ -115,6 +119,7 @@ export type TGetProjectSecretsKey = {
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
includeImports?: boolean;
|
||||
viewSecretValue?: boolean;
|
||||
expandSecretReferences?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ export enum ApiErrorTypes {
|
||||
PermissionBoundaryError = "PermissionBoundaryError",
|
||||
BadRequestError = "BadRequest",
|
||||
UnauthorizedError = "UnauthorizedError",
|
||||
ForbiddenError = "PermissionDenied"
|
||||
ForbiddenError = "PermissionDenied",
|
||||
CustomForbiddenError = "ForbiddenError"
|
||||
}
|
||||
|
||||
export type TApiErrors =
|
||||
@@ -70,6 +71,12 @@ export type TApiErrors =
|
||||
details: PureAbility["rules"];
|
||||
statusCode: 403;
|
||||
}
|
||||
| {
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.CustomForbiddenError;
|
||||
message: string;
|
||||
statusCode: 403;
|
||||
}
|
||||
| {
|
||||
reqId: string;
|
||||
statusCode: 400;
|
||||
|
||||
36
frontend/src/lib/fn/permission.ts
Normal file
36
frontend/src/lib/fn/permission.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MongoAbility, subject } from "@casl/ability";
|
||||
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
import {
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSub,
|
||||
SecretSubjectFields
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
export function hasSecretReadValueOrDescribePermission(
|
||||
permission: MongoAbility<ProjectPermissionSet>,
|
||||
action: Extract<
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue
|
||||
>,
|
||||
subjectFields?: SecretSubjectFields
|
||||
) {
|
||||
let canNewPermission = false;
|
||||
let canOldPermission = false;
|
||||
|
||||
if (subjectFields) {
|
||||
canNewPermission = permission.can(action, subject(ProjectPermissionSub.Secrets, subjectFields));
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, subjectFields)
|
||||
);
|
||||
} else {
|
||||
canNewPermission = permission.can(action, ProjectPermissionSub.Secrets);
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
}
|
||||
|
||||
return canNewPermission || canOldPermission;
|
||||
}
|
||||
@@ -7,3 +7,7 @@ export const formatReservedPaths = (secretPath: string) => {
|
||||
}
|
||||
return secretPath;
|
||||
};
|
||||
|
||||
export const camelCaseToSpaces = (input: string) => {
|
||||
return input.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ const schema = z.object({
|
||||
permissions: z
|
||||
.object({
|
||||
read: z.boolean(),
|
||||
readValue: z.boolean(),
|
||||
write: z.boolean()
|
||||
})
|
||||
.required()
|
||||
@@ -296,6 +297,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
name="permissions"
|
||||
defaultValue={{
|
||||
read: true,
|
||||
readValue: false,
|
||||
write: false
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
|
||||
@@ -35,12 +35,13 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
title,
|
||||
isDisabled
|
||||
}: Props<T>) => {
|
||||
const { control } = useFormContext<TFormSchema>();
|
||||
const { control, watch } = useFormContext<TFormSchema>();
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${subject}`
|
||||
});
|
||||
const [isOpen, setIsOpen] = useToggle();
|
||||
// const [hideFullReadAccess, setHideFullReadAccess] = useState(false);
|
||||
|
||||
if (!items.fields.length) return <div />;
|
||||
|
||||
@@ -71,116 +72,138 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
|
||||
{items.fields.map((el, rootIndex) => (
|
||||
<div key={el.id} className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md">
|
||||
{isConditionalSubjects(subject) && (
|
||||
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
|
||||
<div className="w-1/4">Permission</div>
|
||||
<div className="mr-4 w-1/4">
|
||||
<Controller
|
||||
defaultValue={false as any}
|
||||
name={`permissions.${subject}.${rootIndex}.inverted`}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(val === "true")}
|
||||
containerClassName="w-full"
|
||||
className="w-full"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<SelectItem value="false">Allow</SelectItem>
|
||||
<SelectItem value="true">Forbid</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Whether to allow or forbid the selected actions when the following
|
||||
conditions (if any) are met.
|
||||
</p>
|
||||
<p className="mt-2">Forbid rules must come after allow rules.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-gray-300">
|
||||
<div className="w-1/4">Actions</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{actions.map(({ label, value }) => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return (
|
||||
{items.fields.map((el, rootIndex) => {
|
||||
let isFullReadAccessEnabled = false;
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
isFullReadAccessEnabled = watch(`permissions.${subject}.${rootIndex}.read` as any);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
{isConditionalSubjects(subject) && (
|
||||
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
|
||||
<div className="w-1/4">Permission</div>
|
||||
<div className="mr-4 w-1/4">
|
||||
<Controller
|
||||
key={`${el.id}-${label}`}
|
||||
name={`permissions.${subject}.${rootIndex}.${value}` as any}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
defaultValue={false as any}
|
||||
name={`permissions.${subject}.${rootIndex}.inverted`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isDisabled={isDisabled}
|
||||
isChecked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(val === "true")}
|
||||
containerClassName="w-full"
|
||||
className="w-full"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<SelectItem value="false">Allow</SelectItem>
|
||||
<SelectItem value="true">Forbid</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Whether to allow or forbid the selected actions when the following
|
||||
conditions (if any) are met.
|
||||
</p>
|
||||
<p className="mt-2">Forbid rules must come after allow rules.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-gray-300">
|
||||
<div className="w-1/4">Actions</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{actions.map(({ label, value }, index) => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
|
||||
if (
|
||||
subject === ProjectPermissionSub.Secrets &&
|
||||
value === "read" &&
|
||||
!isFullReadAccessEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={`${el.id}-${index + 1}`}
|
||||
name={`permissions.${subject}.${rootIndex}.${value}` as any}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isDisabled={isDisabled}
|
||||
isChecked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{children &&
|
||||
cloneElement(children, {
|
||||
position: rootIndex
|
||||
})}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex justify-start space-x-4",
|
||||
isConditionalSubjects(subject) && "justify-end"
|
||||
)}
|
||||
>
|
||||
{!isDisabled && isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="mt-2 hover:border-red"
|
||||
onClick={() => items.remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove policy
|
||||
</Button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
{children &&
|
||||
cloneElement(children, {
|
||||
position: rootIndex
|
||||
})}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex justify-start space-x-4",
|
||||
isConditionalSubjects(subject) && "justify-end"
|
||||
)}
|
||||
>
|
||||
{!isDisabled && isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="mt-2 hover:border-red"
|
||||
onClick={() => items.remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove policy
|
||||
</Button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ReactNode } from "react";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretSyncActions,
|
||||
TPermissionCondition,
|
||||
TPermissionConditionOperators
|
||||
@@ -22,6 +27,15 @@ const GeneralPolicyActionSchema = z.object({
|
||||
create: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretActions.DescribeAndReadValue]: z.boolean().optional(), // existing read, gives both describe and read value
|
||||
[ProjectPermissionSecretActions.DescribeSecret]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.ReadValue]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.Delete]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.Create]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const CmekPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
@@ -114,7 +128,7 @@ export const projectRoleFormSchema = z.object({
|
||||
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
|
||||
permissions: z
|
||||
.object({
|
||||
[ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({
|
||||
[ProjectPermissionSub.Secrets]: SecretPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
@@ -283,6 +297,33 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
const canDescribeAndReadValue = action.includes(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue
|
||||
);
|
||||
const canDescribe = action.includes(ProjectPermissionSecretActions.DescribeSecret);
|
||||
const canReadValue = action.includes(ProjectPermissionSecretActions.ReadValue);
|
||||
|
||||
const canEdit = action.includes(ProjectPermissionSecretActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionSecretActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionSecretActions.Create);
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
formVal[subject]!.push({
|
||||
describeSecret: canDescribe,
|
||||
read: canDescribeAndReadValue,
|
||||
readValue: canReadValue,
|
||||
create: canCreate,
|
||||
edit: canEdit,
|
||||
delete: canDelete,
|
||||
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
|
||||
inverted
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// for other subjects
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||
@@ -470,7 +511,7 @@ export type TProjectPermissionObject = {
|
||||
[K in ProjectPermissionSub]: {
|
||||
title: string;
|
||||
actions: {
|
||||
label: string;
|
||||
label: string | ReactNode;
|
||||
value: keyof Omit<
|
||||
NonNullable<NonNullable<TFormSchema["permissions"]>[K]>[number],
|
||||
"conditions" | "inverted"
|
||||
@@ -483,10 +524,35 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
[ProjectPermissionSub.Secrets]: {
|
||||
title: "Secrets",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="opacity-60">
|
||||
Read <span className="text-xs opacity-80">(legacy)</span>
|
||||
</p>
|
||||
<Tooltip
|
||||
className="overflow-hidden whitespace-normal"
|
||||
content={
|
||||
<div>
|
||||
This is a legacy action and will be removed in the future.
|
||||
<br />
|
||||
<br /> You should instead use the{" "}
|
||||
<strong className="font-semibold">Describe Secret</strong> and{" "}
|
||||
<strong className="font-semibold">Read Value</strong> actions.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} className="mt-1 text-yellow-500" size="sm" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
value: ProjectPermissionSecretActions.DescribeAndReadValue
|
||||
},
|
||||
{ label: "Describe Secret", value: ProjectPermissionSecretActions.DescribeSecret },
|
||||
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
||||
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
||||
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
||||
{ label: "Create", value: ProjectPermissionSecretActions.Create }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretFolders]: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
const typeSchema = z
|
||||
.object({
|
||||
@@ -275,7 +276,7 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
isMulti
|
||||
options={environments.filter((environment) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSecretActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: environment.slug,
|
||||
secretPath,
|
||||
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
ModalTrigger,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
|
||||
type Props = {
|
||||
defaultValue?: string | null;
|
||||
@@ -39,6 +42,7 @@ type Props = {
|
||||
isVisible?: boolean;
|
||||
isImportedSecret: boolean;
|
||||
environment: string;
|
||||
secretValueHidden: boolean;
|
||||
secretPath: string;
|
||||
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
|
||||
onSecretUpdate: (
|
||||
@@ -58,6 +62,7 @@ export const SecretEditRow = ({
|
||||
isImportedSecret,
|
||||
onSecretUpdate,
|
||||
secretName,
|
||||
secretValueHidden,
|
||||
onSecretCreate,
|
||||
onSecretDelete,
|
||||
environment,
|
||||
@@ -76,6 +81,9 @@ export const SecretEditRow = ({
|
||||
value: defaultValue || null
|
||||
}
|
||||
});
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const [isDeleting, setIsDeleting] = useToggle();
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
|
||||
@@ -117,6 +125,11 @@ export const SecretEditRow = ({
|
||||
reset({ value });
|
||||
};
|
||||
|
||||
const canReadSecretValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue
|
||||
);
|
||||
|
||||
const handleDeleteSecret = useCallback(async () => {
|
||||
setIsDeleting.on();
|
||||
setIsModalOpen(false);
|
||||
@@ -140,24 +153,29 @@ export const SecretEditRow = ({
|
||||
/>
|
||||
|
||||
<div className="flex-grow border-r border-r-mineshaft-600 pl-1 pr-2">
|
||||
<Controller
|
||||
disabled={isImportedSecret && !defaultValue}
|
||||
control={control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
isReadOnly={isImportedSecret}
|
||||
value={field.value as string}
|
||||
key="secret-input"
|
||||
isVisible={isVisible}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
isImport={isImportedSecret}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{secretValueHidden ? (
|
||||
<Blur tooltipText="You do not have permission to read the value of this secret." />
|
||||
) : (
|
||||
<Controller
|
||||
disabled={isImportedSecret && !defaultValue}
|
||||
control={control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
isReadOnly={isImportedSecret}
|
||||
value={field.value as string}
|
||||
key="secret-input"
|
||||
isVisible={isVisible}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
isImport={isImportedSecret}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-24 justify-center space-x-3 pl-2 transition-all",
|
||||
@@ -211,6 +229,7 @@ export const SecretEditRow = ({
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Copy Secret">
|
||||
<IconButton
|
||||
isDisabled={secretValueHidden}
|
||||
ariaLabel="copy-value"
|
||||
onClick={handleCopySecretToClipboard}
|
||||
variant="plain"
|
||||
@@ -220,48 +239,43 @@ export const SecretEditRow = ({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Read}
|
||||
a={ProjectPermissionSub.Secrets}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Modal>
|
||||
<ModalTrigger asChild>
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip
|
||||
content={
|
||||
hasSecretReference(defaultValue || "")
|
||||
? "Secret Reference Tree"
|
||||
: "Secret does not contain references"
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
ariaLabel="reference-tree"
|
||||
className="h-full"
|
||||
isDisabled={!hasSecretReference(defaultValue || "") || !isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faProjectDiagram} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
title="Secret Reference Details"
|
||||
subTitle="Visual breakdown of secrets referenced by this secret."
|
||||
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Modal>
|
||||
<ModalTrigger asChild>
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip
|
||||
content={
|
||||
hasSecretReference(defaultValue || "")
|
||||
? "Secret Reference Tree"
|
||||
: "Secret does not contain references"
|
||||
}
|
||||
>
|
||||
<SecretReferenceTree
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
secretKey={secretName}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
ariaLabel="reference-tree"
|
||||
className="h-full"
|
||||
isDisabled={!hasSecretReference(defaultValue || "") || !canReadSecretValue}
|
||||
>
|
||||
<FontAwesomeIcon icon={faProjectDiagram} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
title="Secret Reference Details"
|
||||
subTitle="Visual breakdown of secrets referenced by this secret."
|
||||
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open
|
||||
>
|
||||
<SecretReferenceTree
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
secretKey={secretName}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { faLock } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
|
||||
type Props = {
|
||||
environments: { name: string; slug: string }[];
|
||||
@@ -25,7 +26,7 @@ export const SecretNoAccessOverviewTableRow = ({ environments = [], count }: Pro
|
||||
<div className="text-bunker-300">
|
||||
<FontAwesomeIcon className="block" icon={faLock} />
|
||||
</div>
|
||||
<div className="blur-sm">NO ACCESS</div>
|
||||
<Blur />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -221,10 +221,13 @@ export const SecretOverviewTableRow = ({
|
||||
secretPath={secretPath}
|
||||
isVisible={isSecretVisible}
|
||||
secretName={secretKey}
|
||||
secretValueHidden={secret?.secretValueHidden || false}
|
||||
defaultValue={
|
||||
secret?.valueOverride ||
|
||||
secret?.value ||
|
||||
importedSecret?.secret?.value
|
||||
secret?.secretValueHidden
|
||||
? ""
|
||||
: secret?.valueOverride ||
|
||||
secret?.value ||
|
||||
importedSecret?.secret?.value
|
||||
}
|
||||
secretId={secret?.id}
|
||||
isOverride={Boolean(secret?.valueOverride)}
|
||||
|
||||
@@ -10,15 +10,12 @@ import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useUpdateSecretV3 } from "@app/hooks/api";
|
||||
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
|
||||
enum SecretActionType {
|
||||
Created = "created",
|
||||
@@ -55,8 +52,11 @@ function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }
|
||||
secretTags: (secretDetails?.tags || []).map((i) => i.slug)
|
||||
});
|
||||
const isSecretInEnvReadOnly =
|
||||
permission.can(ProjectPermissionActions.Read, secretPermissionSubject) &&
|
||||
permission.cannot(ProjectPermissionActions.Edit, secretPermissionSubject);
|
||||
hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
secretPermissionSubject
|
||||
) && permission.cannot(ProjectPermissionSecretActions.Edit, secretPermissionSubject);
|
||||
if (isSecretInEnvReadOnly) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -110,21 +110,31 @@ export const QuickSearchSecretItem = ({
|
||||
</Badge>
|
||||
)}
|
||||
{isSingleEnv ? (
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="Copy secret value"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const el = envSlugMap.get(groupSecret.env)?.name;
|
||||
if (el) {
|
||||
handleCopy(groupSecret.value!, el);
|
||||
}
|
||||
}}
|
||||
<Tooltip
|
||||
isDisabled={!groupSecret?.secretValueHidden}
|
||||
content={
|
||||
groupSecret?.secretValueHidden
|
||||
? "You do not have permission to view this secret value"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="md"
|
||||
isDisabled={groupSecret?.secretValueHidden}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="Copy secret value"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const el = envSlugMap.get(groupSecret.env)?.name;
|
||||
if (el) {
|
||||
handleCopy(groupSecret.value!, el);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -158,14 +168,24 @@ export const QuickSearchSecretItem = ({
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="View secret value"
|
||||
<Tooltip
|
||||
isDisabled={!groupSecret?.secretValueHidden}
|
||||
content={
|
||||
groupSecret?.secretValueHidden
|
||||
? "You do not have permission to view this secret value"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="md"
|
||||
isDisabled={groupSecret?.secretValueHidden}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="View secret value"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Hover to Reveal...</DropdownMenuLabel>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||
import {
|
||||
@@ -58,7 +59,7 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
// user should have the ability to delete secrets/folders in at least one of the envs
|
||||
const shouldShowDelete = userAvailableEnvs.some((env) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSecretActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: env.slug,
|
||||
secretPath,
|
||||
@@ -110,7 +111,7 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
(accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => {
|
||||
const entry = secretRecord[env.slug];
|
||||
const canDeleteSecret = permission.can(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSecretActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: env.slug,
|
||||
secretPath,
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
Spinner,
|
||||
Switch
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useMoveSecrets } from "@app/hooks/api";
|
||||
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
|
||||
@@ -95,7 +96,7 @@ const Content = ({
|
||||
env.slug,
|
||||
{
|
||||
missingPermissions: permission.cannot(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSecretActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: env.slug,
|
||||
secretPath: sourceSecretPath,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useGetImportedSecretsSingleEnv,
|
||||
@@ -37,6 +38,7 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
|
||||
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
|
||||
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
|
||||
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
|
||||
@@ -102,14 +104,27 @@ const Page = () => {
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
const canReadSecret = permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
|
||||
const canReadSecret = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const canReadSecretValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
}
|
||||
);
|
||||
|
||||
const canReadSecretImports = permission.can(
|
||||
@@ -176,6 +191,7 @@ const Page = () => {
|
||||
orderDirection,
|
||||
includeImports: canReadSecretImports && filter.include.import,
|
||||
includeFolders: filter.include.folder,
|
||||
viewSecretValue: canReadSecretValue,
|
||||
includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic,
|
||||
includeSecrets: canReadSecret && filter.include.secret,
|
||||
tags: filter.tags
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AxiosError } from "axios";
|
||||
import FileSaver from "file-saver";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -54,7 +55,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, WsTag } from "@app/hooks/api/types";
|
||||
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
|
||||
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
||||
|
||||
import {
|
||||
@@ -152,51 +153,71 @@ export const ActionBar = ({
|
||||
};
|
||||
|
||||
const handleSecretDownload = async () => {
|
||||
const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({
|
||||
workspaceId,
|
||||
expandSecretReferences: true,
|
||||
includeImports: true,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
const secretsPicked = new Set<string>();
|
||||
const secretsToDownload: { key: string; value?: string; comment?: string }[] = [];
|
||||
localSecrets.forEach((el) => {
|
||||
secretsPicked.add(el.secretKey);
|
||||
secretsToDownload.push({
|
||||
key: el.secretKey,
|
||||
value: el.secretValue,
|
||||
comment: el.secretComment
|
||||
try {
|
||||
const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({
|
||||
workspaceId,
|
||||
expandSecretReferences: true,
|
||||
includeImports: true,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
const secretsPicked = new Set<string>();
|
||||
const secretsToDownload: { key: string; value?: string; comment?: string }[] = [];
|
||||
localSecrets.forEach((el) => {
|
||||
secretsPicked.add(el.secretKey);
|
||||
secretsToDownload.push({
|
||||
key: el.secretKey,
|
||||
value: el.secretValue,
|
||||
comment: el.secretComment
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) {
|
||||
const secret = localImportedSecrets[i].secrets[j];
|
||||
if (!secretsPicked.has(secret.secretKey)) {
|
||||
secretsToDownload.push({
|
||||
key: secret.secretKey,
|
||||
value: secret.secretValue,
|
||||
comment: secret.secretComment
|
||||
});
|
||||
for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) {
|
||||
const secret = localImportedSecrets[i].secrets[j];
|
||||
if (!secretsPicked.has(secret.secretKey)) {
|
||||
secretsToDownload.push({
|
||||
key: secret.secretKey,
|
||||
value: secret.secretValue,
|
||||
comment: secret.secretComment
|
||||
});
|
||||
}
|
||||
secretsPicked.add(secret.secretKey);
|
||||
}
|
||||
secretsPicked.add(secret.secretKey);
|
||||
}
|
||||
|
||||
const file = secretsToDownload
|
||||
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
|
||||
.reduce(
|
||||
(prev, { key, comment, value }, index) =>
|
||||
prev +
|
||||
(comment
|
||||
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n`
|
||||
: `${key}=${value}\n`),
|
||||
""
|
||||
);
|
||||
|
||||
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
|
||||
FileSaver.saveAs(blob, `${environment}.env`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
const error = err?.response?.data as TApiErrors;
|
||||
|
||||
if (error?.error === ApiErrorTypes.ForbiddenError && error.message.includes("readValue")) {
|
||||
createNotification({
|
||||
title: "You don't have permission to download secrets",
|
||||
text: "You don't have permission to view one or more of the secrets in the current folder. Please contact your administrator.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
createNotification({
|
||||
title: "Failed to download secrets",
|
||||
text: "Please try again later.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
const file = secretsToDownload
|
||||
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
|
||||
.reduce(
|
||||
(prev, { key, comment, value }, index) =>
|
||||
prev +
|
||||
(comment
|
||||
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n`
|
||||
: `${key}=${value}\n`),
|
||||
""
|
||||
);
|
||||
|
||||
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
|
||||
FileSaver.saveAs(blob, `${environment}.env`);
|
||||
};
|
||||
|
||||
const handleSecretBulkDelete = async () => {
|
||||
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||
import { useGetAccessibleSecrets } from "@app/hooks/api/dashboard";
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
@@ -32,7 +33,7 @@ const formSchema = z.object({
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: z
|
||||
.object({ key: z.string(), value: z.string().optional() })
|
||||
.object({ secretKey: z.string(), secretValue: z.string().optional() })
|
||||
.array()
|
||||
.min(1, "Select one or more secrets to copy")
|
||||
});
|
||||
@@ -78,34 +79,38 @@ export const CopySecretsFromBoard = ({
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: secrets, isPending: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
environment: selectedEnvSlug.slug,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
options: {
|
||||
enabled:
|
||||
Boolean(workspaceId) &&
|
||||
Boolean(selectedEnvSlug) &&
|
||||
Boolean(debouncedEnvCopySecretPath) &&
|
||||
isOpen
|
||||
}
|
||||
});
|
||||
const { data: accessibleSecrets, isPending: isAccessibleSecretsLoading } =
|
||||
useGetAccessibleSecrets({
|
||||
projectId: workspaceId,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
environment: selectedEnvSlug.slug,
|
||||
filterByAction: shouldIncludeValues
|
||||
? ProjectPermissionSecretActions.ReadValue
|
||||
: ProjectPermissionSecretActions.DescribeSecret,
|
||||
options: {
|
||||
enabled:
|
||||
Boolean(workspaceId) &&
|
||||
Boolean(selectedEnvSlug) &&
|
||||
Boolean(debouncedEnvCopySecretPath) &&
|
||||
isOpen
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", []);
|
||||
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
|
||||
|
||||
const handleSecSelectAll = () => {
|
||||
if (secrets) {
|
||||
setValue("secrets", secrets, { shouldDirty: true });
|
||||
if (accessibleSecrets) {
|
||||
setValue("secrets", accessibleSecrets, { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||
data.secrets.forEach(({ key, value }) => {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && value) || "",
|
||||
data.secrets.forEach(({ secretKey, secretValue }) => {
|
||||
secretsToBePulled[secretKey] = {
|
||||
value: (shouldIncludeValues && secretValue) || "",
|
||||
comments: [""]
|
||||
};
|
||||
});
|
||||
@@ -202,19 +207,19 @@ export const CopySecretsFromBoard = ({
|
||||
<FilterableSelect
|
||||
placeholder={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
isSecretsLoading
|
||||
isAccessibleSecretsLoading
|
||||
? "Loading secrets..."
|
||||
: secrets?.length
|
||||
: accessibleSecrets?.length
|
||||
? "Select secrets..."
|
||||
: "No secrets found..."
|
||||
}
|
||||
isLoading={isSecretsLoading}
|
||||
options={secrets}
|
||||
isLoading={isAccessibleSecretsLoading}
|
||||
options={accessibleSecrets}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti
|
||||
getOptionValue={(option) => option.key}
|
||||
getOptionLabel={(option) => option.key}
|
||||
getOptionValue={(option) => option.secretKey}
|
||||
getOptionLabel={(option) => option.secretKey}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -235,7 +240,10 @@ export const CopySecretsFromBoard = ({
|
||||
<Switch
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
|
||||
onCheckedChange={(isChecked) => {
|
||||
setValue("secrets", []);
|
||||
setShouldIncludeValues(isChecked as boolean);
|
||||
}}
|
||||
>
|
||||
Include secret values
|
||||
</Switch>
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
faShare,
|
||||
faTag,
|
||||
faTrash,
|
||||
faTriangleExclamation,
|
||||
faUser
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -48,15 +50,18 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useGetSecretVersion } from "@app/hooks/api";
|
||||
import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
|
||||
import { CreateReminderForm } from "./CreateReminderForm";
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
@@ -128,7 +133,7 @@ export const SecretDetailSidebar = ({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cannotEditSecret = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
@@ -136,16 +141,31 @@ export const SecretDetailSidebar = ({
|
||||
secretTags: selectTagSlugs
|
||||
})
|
||||
);
|
||||
|
||||
const cannotReadSecretValue = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: secretKey,
|
||||
secretTags: selectTagSlugs
|
||||
}
|
||||
);
|
||||
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: secretKey,
|
||||
secretTags: selectTagSlugs
|
||||
})
|
||||
) && cannotEditSecret;
|
||||
}
|
||||
) &&
|
||||
cannotEditSecret &&
|
||||
cannotReadSecretValue;
|
||||
|
||||
const overrideAction = watch("overrideAction");
|
||||
const isOverridden =
|
||||
@@ -325,38 +345,63 @@ export const SecretDetailSidebar = ({
|
||||
key="secret-value"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl label="Value">
|
||||
<InfisicalSecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
key="secret-value"
|
||||
isDisabled={isOverridden || !isAllowed}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
|
||||
{...field}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
helperText={
|
||||
cannotReadSecretValue ? (
|
||||
<div className="flex space-x-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faTriangleExclamation}
|
||||
className="mt-0.5 text-yellow-400"
|
||||
/>
|
||||
<span>
|
||||
The value of this secret is hidden because you do not have the
|
||||
read secret value permission.
|
||||
</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
label="Value"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
isReadOnly={isReadOnly || !isAllowed}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
key="secret-value"
|
||||
isDisabled={isOverridden}
|
||||
containerClassName="text-bunker-300 w-full hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
|
||||
{...field}
|
||||
autoFocus={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content="You don't have permission to view the secret value."
|
||||
isDisabled={!secret?.secretValueHidden}
|
||||
>
|
||||
<Button
|
||||
isDisabled={secret?.secretValueHidden}
|
||||
className="px-2 py-[0.43rem] font-normal"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faShare} />}
|
||||
onClick={() => {
|
||||
const value = secret?.valueOverride ?? secret?.value;
|
||||
if (value) {
|
||||
handleSecretShare(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="ml-1 mt-1.5 flex items-center">
|
||||
<Button
|
||||
className="w-full px-2 py-[0.43rem] font-normal"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faShare} />}
|
||||
onClick={() => {
|
||||
const value = secret?.valueOverride ?? secret?.value;
|
||||
if (value) {
|
||||
handleSecretShare(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 rounded border border-mineshaft-600 bg-mineshaft-900 p-4 px-0 pb-0">
|
||||
<div className="mb-4 px-4">
|
||||
@@ -683,77 +728,62 @@ export const SecretDetailSidebar = ({
|
||||
<div className="mb-4flex-grow dark cursor-default text-sm text-bunker-300">
|
||||
<div className="mb-2 pl-1">Version History</div>
|
||||
<div className="thin-scrollbar flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 dark:[color-scheme:dark]">
|
||||
{secretVersion?.map(({ createdAt, secretValue, version, id, actor }) => (
|
||||
<div className="flex flex-row">
|
||||
<div key={id} className="flex w-full flex-col space-y-1">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10">
|
||||
<div className="w-fit rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 text-sm text-mineshaft-300">
|
||||
v{version}
|
||||
{secretVersion?.map(
|
||||
({ createdAt, secretValue, secretValueHidden, version, id, actor }) => (
|
||||
<div className="flex flex-row" key={id}>
|
||||
<div className="flex w-full flex-col space-y-1">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10">
|
||||
<div className="w-fit rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 text-sm text-mineshaft-300">
|
||||
v{version}
|
||||
</div>
|
||||
</div>
|
||||
<div>{format(new Date(createdAt), "Pp")}</div>
|
||||
</div>
|
||||
<div>{format(new Date(createdAt), "Pp")}</div>
|
||||
</div>
|
||||
<div className="flex w-full cursor-default">
|
||||
<div className="relative w-10">
|
||||
<div className="absolute bottom-0 left-3 top-0 mt-0.5 border-l border-mineshaft-400/60" />
|
||||
</div>
|
||||
<div className="flex w-full cursor-default flex-col">
|
||||
{actor && (
|
||||
<div className="flex flex-row">
|
||||
<div className="flex w-fit flex-row text-sm">
|
||||
Modified by:
|
||||
<Tooltip content={getModifiedByName(actor.actorType, actor.name)}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
onClick={() =>
|
||||
onModifyHistoryClick(
|
||||
actor.actorId,
|
||||
actor.actorType,
|
||||
actor.membershipId
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
<div className="flex w-full cursor-default">
|
||||
<div className="relative w-10">
|
||||
<div className="absolute bottom-0 left-3 top-0 mt-0.5 border-l border-mineshaft-400/60" />
|
||||
</div>
|
||||
<div className="flex w-full cursor-default flex-col">
|
||||
{actor && (
|
||||
<div className="flex flex-row">
|
||||
<div className="flex w-fit flex-row text-sm">
|
||||
Modified by:
|
||||
<Tooltip
|
||||
content={getModifiedByName(actor.actorType, actor.name)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={getModifiedByIcon(actor.actorType)}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
onClick={() =>
|
||||
onModifyHistoryClick(
|
||||
actor.actorId,
|
||||
actor.actorType,
|
||||
actor.membershipId
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={getModifiedByIcon(actor.actorType)}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row">
|
||||
<div className="h-min w-fit rounded-sm bg-primary-500/10 px-1 text-primary-300/70">
|
||||
Value:
|
||||
</div>
|
||||
<div className="group break-all pl-1 font-mono">
|
||||
<div className="relative hidden cursor-pointer transition-all duration-200 group-[.show-value]:inline">
|
||||
<button
|
||||
type="button"
|
||||
className="select-none text-left"
|
||||
onClick={(e) => {
|
||||
navigator.clipboard.writeText(secretValue || "");
|
||||
const target = e.currentTarget;
|
||||
target.style.borderBottom = "1px dashed";
|
||||
target.style.paddingBottom = "-1px";
|
||||
)}
|
||||
<div className="flex flex-row">
|
||||
<div className="h-min w-fit rounded-sm bg-primary-500/10 px-1 text-primary-300/70">
|
||||
Value:
|
||||
</div>
|
||||
<div className="group break-all pl-1 font-mono">
|
||||
<div className="relative hidden cursor-pointer transition-all duration-200 group-[.show-value]:inline">
|
||||
<button
|
||||
type="button"
|
||||
className="select-none text-left"
|
||||
onClick={(e) => {
|
||||
if (secretValueHidden) return;
|
||||
|
||||
// Create and insert popup
|
||||
const popup = document.createElement("div");
|
||||
popup.className =
|
||||
"w-16 flex justify-center absolute top-6 left-0 text-xs text-primary-100 bg-mineshaft-800 px-1 py-0.5 rounded-md border border-primary-500/50";
|
||||
popup.textContent = "Copied!";
|
||||
target.parentElement?.appendChild(popup);
|
||||
|
||||
// Remove popup and border after delay
|
||||
setTimeout(() => {
|
||||
popup.remove();
|
||||
target.style.borderBottom = "none";
|
||||
}, 3000);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
navigator.clipboard.writeText(secretValue || "");
|
||||
const target = e.currentTarget;
|
||||
target.style.borderBottom = "1px dashed";
|
||||
@@ -771,75 +801,104 @@ export const SecretDetailSidebar = ({
|
||||
popup.remove();
|
||||
target.style.borderBottom = "none";
|
||||
}, 3000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{secretValue}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.currentTarget
|
||||
.closest(".group")
|
||||
?.classList.remove("show-value");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (secretValueHidden) return;
|
||||
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
navigator.clipboard.writeText(secretValue || "");
|
||||
const target = e.currentTarget;
|
||||
target.style.borderBottom = "1px dashed";
|
||||
target.style.paddingBottom = "-1px";
|
||||
|
||||
// Create and insert popup
|
||||
const popup = document.createElement("div");
|
||||
popup.className =
|
||||
"w-16 flex justify-center absolute top-6 left-0 text-xs text-primary-100 bg-mineshaft-800 px-1 py-0.5 rounded-md border border-primary-500/50";
|
||||
popup.textContent = "Copied!";
|
||||
target.parentElement?.appendChild(popup);
|
||||
|
||||
// Remove popup and border after delay
|
||||
setTimeout(() => {
|
||||
popup.remove();
|
||||
target.style.borderBottom = "none";
|
||||
}, 3000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
secretValueHidden && "text-xs text-bunker-300 opacity-40"
|
||||
)}
|
||||
>
|
||||
{secretValueHidden ? "Hidden" : secretValue}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.currentTarget
|
||||
.closest(".group")
|
||||
?.classList.remove("show-value");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEyeSlash} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="group-[.show-value]:hidden">
|
||||
{secretValue?.replace(/./g, "*")}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.currentTarget
|
||||
.closest(".group")
|
||||
?.classList.add("show-value");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
e.currentTarget
|
||||
.closest(".group")
|
||||
?.classList.remove("show-value");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEyeSlash} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="group-[.show-value]:hidden">
|
||||
{secretValueHidden ? "******" : secretValue?.replace(/./g, "*")}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.currentTarget
|
||||
.closest(".group")
|
||||
?.classList.add("show-value");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} />
|
||||
</button>
|
||||
</span>
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.currentTarget
|
||||
.closest(".group")
|
||||
?.classList.add("show-value");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center justify-center ${version === secretVersion.length ? "hidden" : ""}`}
|
||||
>
|
||||
<Tooltip content="Restore Secret Value">
|
||||
<IconButton
|
||||
ariaLabel="Restore"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-md"
|
||||
onClick={() => setValue("value", secretValue)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateRight} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center justify-center ${version === secretVersion.length ? "hidden" : ""}`}
|
||||
>
|
||||
<Tooltip content="Restore Secret Value">
|
||||
<IconButton
|
||||
ariaLabel="Restore"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-md"
|
||||
onClick={() => setValue("value", secretValue)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateRight} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dark mb-4 flex-grow text-sm text-bunker-300">
|
||||
@@ -878,12 +937,9 @@ export const SecretDetailSidebar = ({
|
||||
<div className="rounded-md bg-bunker-500">
|
||||
<Tooltip
|
||||
content={user.allowedActions
|
||||
.map(
|
||||
(action) =>
|
||||
action.charAt(0).toUpperCase() + action.slice(1).toLowerCase()
|
||||
)
|
||||
.map((action) => camelCaseToSpaces(action))
|
||||
.join(", ")}
|
||||
className="z-[100]"
|
||||
className="z-[100] capitalize"
|
||||
>
|
||||
<Link
|
||||
to={
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
/* eslint-disable simple-import-sort/imports */
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
@@ -44,6 +45,9 @@ import {
|
||||
SecretReferenceTree
|
||||
} from "@app/components/secrets/SecretReferenceDetails";
|
||||
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import {
|
||||
FontAwesomeSpriteName,
|
||||
formSchema,
|
||||
@@ -99,8 +103,14 @@ export const SecretItem = memo(
|
||||
trigger,
|
||||
formState: { isDirty, isSubmitting, errors }
|
||||
} = useForm<TFormSchema>({
|
||||
defaultValues: secret,
|
||||
values: secret,
|
||||
defaultValues: {
|
||||
...secret,
|
||||
value: secret.secretValueHidden ? "" : secret.value
|
||||
},
|
||||
values: {
|
||||
...secret,
|
||||
value: secret.secretValueHidden ? "" : secret.value
|
||||
},
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
@@ -122,17 +132,18 @@ export const SecretItem = memo(
|
||||
});
|
||||
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})
|
||||
}
|
||||
) &&
|
||||
permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
@@ -141,6 +152,8 @@ export const SecretItem = memo(
|
||||
})
|
||||
);
|
||||
|
||||
const { secretValueHidden } = secret;
|
||||
|
||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
@@ -272,6 +285,8 @@ export const SecretItem = memo(
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : secretValueHidden ? (
|
||||
<Blur tooltipText="You do not have permission to read the value of this secret." />
|
||||
) : (
|
||||
<Controller
|
||||
name="value"
|
||||
@@ -285,6 +300,7 @@ export const SecretItem = memo(
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
{...field}
|
||||
defaultValue={secretValueHidden ? "" : undefined}
|
||||
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
|
||||
/>
|
||||
)}
|
||||
@@ -293,6 +309,7 @@ export const SecretItem = memo(
|
||||
<div key="actions" className="flex h-8 flex-shrink-0 self-start transition-all">
|
||||
<Tooltip content="Copy secret">
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden}
|
||||
ariaLabel="copy-value"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
@@ -500,6 +517,7 @@ export const SecretItem = memo(
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden}
|
||||
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6"
|
||||
variant="plain"
|
||||
size="md"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FontAwesomeSymbol, Input, Tooltip } from "@app/components/v2";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
|
||||
import { FontAwesomeSpriteName } from "./SecretListView.utils";
|
||||
|
||||
@@ -34,13 +35,7 @@ export const SecretNoAccessListView = ({ count }: Props) => {
|
||||
className="w-full px-0 blur-sm placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex w-80 flex-grow items-center border-x border-mineshaft-600 py-1 pl-4 pr-2"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<span className="blur">********</span>
|
||||
</div>
|
||||
<Blur />
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
|
||||
@@ -120,11 +121,25 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
|
||||
<Td className="border-r border-mineshaft-600">Value</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">
|
||||
<SecretInput value={preSecret?.value} />
|
||||
{preSecret?.secretValueHidden ? (
|
||||
<Blur
|
||||
className="w-min"
|
||||
tooltipText="You do not have permission to read the value of this secret."
|
||||
/>
|
||||
) : (
|
||||
<SecretInput value={preSecret?.value} />
|
||||
)}
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<SecretInput value={postSecret?.value} />
|
||||
{postSecret?.secretValueHidden ? (
|
||||
<Blur
|
||||
className="w-min"
|
||||
tooltipText="You do not have permission to read the value of this secret."
|
||||
/>
|
||||
) : (
|
||||
<SecretInput value={postSecret?.value} />
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
{Boolean(preSecret?.idOverride || postSecret?.idOverride) && (
|
||||
|
||||
Reference in New Issue
Block a user