1
0
mirror of https://github.com/Infisical/infisical.git synced 2025-03-17 00:01:55 +00:00

Compare commits

...

17 Commits

Author SHA1 Message Date
b75289f074 Merge branch 'main' into snyk-upgrade-ba8f3acf185100a451cbbadcbe68f789 2024-03-09 11:05:36 -05:00
de86705e64 Merge pull request from rhythmbhiwani/feature-rename-secret-accross-envs
Feature: Rename secret from overview page, accross all environments
2024-03-09 11:02:22 -05:00
6ca56143d9 Merge pull request from rhythmbhiwani/docs-typo-fixed-cli
Fixed typo in `secrets get` docs
2024-03-09 17:32:45 +05:30
ef0e652557 Fixed typo in secrets get docs 2024-03-09 15:23:25 +05:30
48062d9680 Merge pull request from akhilmhdh/fix/create-folder-cli
feat(server): added back delete by folder name in api
2024-03-08 17:46:14 -05:00
d11fda3be5 Merge pull request from Infisical/railway-integration
Update Railway integration get services query, make services optional
2024-03-08 14:25:14 -08:00
0df5f845fb Update docker-swarm-with-agent.mdx 2024-03-08 17:07:11 -05:00
ca59488b62 Update Railway integration get services query, make services optional 2024-03-08 11:46:51 -08:00
3a05ae4b27 Merge pull request from Infisical/docker-swarm-docs
docs: docker swarm with infisical agent
2024-03-08 14:42:56 -05:00
dd009182e8 docs: docker swarm with infisical agent 2024-03-08 14:42:02 -05:00
2e3b10ccfc feat(server): added back delete by folder name in api 2024-03-08 17:45:14 +05:30
d8860e1ce3 Disabled submit button when renaming all keys if key name is empty 2024-03-03 02:49:35 +05:30
3fa529dcb0 Added error message if name is empty 2024-03-02 09:30:03 +05:30
b6f3cf512e spacing made consistent 2024-03-02 06:57:36 +05:30
4dbee7df06 Added notification on success and failure renaming secret 2024-03-02 06:45:52 +05:30
323c412f5e Added Option to Rename Secrets from overview page in all environments 2024-03-02 06:41:32 +05:30
c2fe6eb90c fix: upgrade posthog-js from 1.105.4 to 1.105.6
Snyk has created this PR to upgrade posthog-js from 1.105.4 to 1.105.6.

See this package in npm:
https://www.npmjs.com/package/posthog-js

See this project in Snyk:
https://app.snyk.io/org/maidul98/project/53d4ecb6-6cc1-4918-aa73-bf9cae4ffd13?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-03-01 09:12:34 +00:00
14 changed files with 497 additions and 74 deletions
backend/src
docs
frontend
package-lock.jsonpackage.json
src
pages/integrations/railway
views/SecretOverviewPage/components/SecretOverviewTableRow

@ -120,7 +120,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
});
server.route({
url: "/:folderId",
url: "/:folderIdOrName",
method: "DELETE",
schema: {
description: "Delete a folder",
@ -131,7 +131,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
}
],
params: z.object({
folderId: z.string()
folderIdOrName: z.string()
}),
body: z.object({
workspaceId: z.string().trim(),
@ -155,7 +155,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
actorOrgId: req.permission.orgId,
...req.body,
projectId: req.body.workspaceId,
id: req.params.folderId,
idOrName: req.params.folderIdOrName,
path
});
await server.services.auditLog.createAuditLog({

@ -649,33 +649,21 @@ export const integrationAuthServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const botKey = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey);
if (appId) {
if (appId && appId !== "") {
const query = `
query project($id: String!) {
project(id: $id) {
createdAt
deletedAt
id
description
expiredAt
isPublic
isTempProject
isUpdatable
name
prDeploys
teamId
updatedAt
upstreamUrl
services {
edges {
node {
id
name
}
}
}
query project($id: String!) {
project(id: $id) {
services {
edges {
node {
id
name
}
}
}
}
}
}
`;
const variables = {
@ -711,6 +699,7 @@ export const integrationAuthServiceFactory = ({
);
return edges.map(({ node: { name, id: serviceId } }) => ({ name, serviceId }));
}
return [];
};

@ -1204,21 +1204,21 @@ const syncSecretsRailway = async ({
}
`;
const input = {
projectId: integration.appId,
environmentId: integration.targetEnvironmentId,
...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}),
replace: true,
variables: getSecretKeyValuePair(secrets)
const variables = {
input: {
projectId: integration.appId,
environmentId: integration.targetEnvironmentId,
...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}),
replace: true,
variables: getSecretKeyValuePair(secrets)
}
};
await request.post(
IntegrationUrls.RAILWAY_API_URL,
{
query,
variables: {
input
}
variables
},
{
headers: {

@ -1,6 +1,6 @@
import { ForbiddenError, subject } from "@casl/ability";
import path from "path";
import { v4 as uuidv4 } from "uuid";
import { v4 as uuidv4, validate as uuidValidate } from "uuid";
import { TSecretFoldersInsert } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@ -164,7 +164,7 @@ export const secretFolderServiceFactory = ({
actorOrgId,
environment,
path: secretPath,
id
idOrName
}: TDeleteFolderDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(
@ -179,7 +179,10 @@ export const secretFolderServiceFactory = ({
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, tx);
if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" });
const [doc] = await folderDAL.delete({ envId: env.id, id, parentId: parentFolder.id }, tx);
const [doc] = await folderDAL.delete(
{ envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id },
tx
);
if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" });
return doc;
});

@ -16,7 +16,7 @@ export type TUpdateFolderDTO = {
export type TDeleteFolderDTO = {
environment: string;
path: string;
id: string;
idOrName: string;
} & TProjectPermission;
export type TGetFolderDTO = {

@ -12,12 +12,13 @@ infisical secrets
This command enables you to perform CRUD (create, read, update, delete) operations on secrets within your Infisical project. With it, you can view, create, update, and delete secrets in your environment.
### Sub-commands
<Accordion title="infisical secrets" defaultOpen="true">
Use this command to print out all of the secrets in your project
```bash
$ infisical secrets
```
```bash
$ infisical secrets
```
### Environment variables
@ -95,7 +96,7 @@ $ infisical secrets get DOMAIN
Default value: `false`
Example: `infisical secrets get DOMAIN --value`
Example: `infisical secrets get DOMAIN --raw-value`
<Tip>
When running in CI/CD environments or in a script, set `INFISICAL_DISABLE_UPDATE_CHECK` env to `true`. This will help hide any CLI update messages and only show the secret value.

Binary file not shown.

After

(image error) Size: 73 KiB

@ -0,0 +1,164 @@
---
title: 'Docker Swarm'
description: "How to manage secrets in Docker Swarm services"
---
In this guide, we'll demonstrate how to use Infisical for managing secrets within Docker Swarm.
Specifically, we'll set up a sidecar container using the [Infisical Agent](/infisical-agent/overview), which authenticates with Infisical to retrieve secrets and access tokens.
These secrets are then stored in a shared volume accessible by other services in your Docker Swarm.
## Prerequisites
- Infisical account
- Docker version 20.10.24 or newer
- Basic knowledge of Docker Swarm
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed on your system
- Familiarity with the [Infisical Agent](/infisical-agent/overview)
## Objective
Our goal is to deploy an Nginx instance in your Docker Swarm cluster, configured to display Infisical secrets on its landing page. This will provide hands-on experience in fetching and utilizing secrets from Infisical within Docker Swarm. The principles demonstrated here are also applicable to Docker Compose deployments.
<Steps>
<Step title="Cloning the Guide Assets Repository">
Start by cloning the [Infisical guide assets repository](https://github.com/Infisical/infisical-guides.git) from Github. This repository includes necessary assets for this and other Infisical guides. Focus on the `docker-swarm-with-agent` sub-directory, which we'll use as our working directory.
</Step>
<Step title="Setting Up Authentication with Infisical">
To allow the agent to fetch your Infisical secrets, choose an authentication method for the agent. For this guide, we will use [Universal Auth](/documentation/platform/identities/universal-auth) for authentication. Follow the instructions [here](/documentation/platform/identities/universal-auth) to generate a client ID and client secret.
</Step>
<Step title="Entering Universal Auth Credentials">
Copy the client ID and client secret obtained in the previous step into the `client-id` and `client-secret` text files, respectively.
</Step>
<Step title="Configuring the Infisical Agent">
The Infisical Agent will authenticate using Universal Auth and retrieve secrets for rendering as specified in the template(s).
Adjust the `polling-interval` to control the frequency of secret updates.
In the example template, the secrets are rendered as an HTML page, which will be set as Nginx's home page to demonstrate successful secret retrieval and utilization.
<Tip>
Remember to add your project id, environment slug and path of corresponding Infisical project to the secret template.
</Tip>
<Tabs>
<Tab title="Agent Configuration">
```yaml infisical-agent-config
infisical:
address: "https://app.infisical.com"
auth:
type: "universal-auth"
config:
client-id: "/run/secrets/infisical-universal-auth-client-id"
client-secret: "/run/secrets/infisical-universal-auth-client-secret"
remove_client_secret_on_read: false
sinks:
- type: "file"
config:
path: "/infisical-secrets/access-token"
templates:
- source-path: /run/secrets/nginx-home-page-template
destination-path: /infisical-secrets/index.html
config:
polling-interval: 60s
```
<Info>
Some paths contain `/run/secrets/` because the contents of those files reside in a [Docker secret](https://docs.docker.com/engine/swarm/secrets/#how-docker-manages-secrets).
</Info>
</Tab>
<Tab title="Secret Template for Agent">
```html nginx-home-page-template
<!DOCTYPE html>
<html lang="en">
<body>
<h1>This file is rendered by Infisical agent template engine</h1>
<p>Here are the secrets that have been fetched from Infisical and stored in your volume mount</p>
<ol>
{{- with secret "7df67a5f-d26a-4988-a375-7153c08149da" "dev" "/" }}
{{- range . }}
<li>{{ .Key }}={{ .Value }}</li>
{{- end }}
{{- end }}
</ol>
</body>
</html>
```
</Tab>
</Tabs>
</Step>
<Step title="Creating the Docker Compose File">
Define the `infisical-agent` and `nginx` services in your Docker Compose file. `infisical-agent` will handle secret retrieval and storage. These secrets are stored in a volume, accessible by other services like Nginx.
```yaml docker-compose.yaml
version: "3.1"
services:
infisical-agent:
container_name: infisical-agnet
image: infisical/cli:0.18.0
command: agent --config=/run/secrets/infisical-agent-config
volumes:
- infisical-agent:/infisical-secrets
secrets:
- infisical-universal-auth-client-id
- infisical-universal-auth-client-secret
- infisical-agent-config
- nginx-home-page-template
networks:
- infisical_network
nginx:
image: nginx:latest
ports:
- "80:80"
volumes:
- infisical-agent:/usr/share/nginx/html
networks:
- infisical_network
volumes:
infisical-agent:
secrets:
infisical-universal-auth-client-id:
file: ./client-id
infisical-universal-auth-client-secret:
file: ./client-secret
infisical-agent-config:
file: ./infisical-agent-config
nginx-home-page-template:
file: ./nginx-home-page-template
networks:
infisical_network:
```
</Step>
<Step title="Initializing Docker Swarm">
```
docker swarm init
```
</Step>
<Step title="Deploying the Docker Stack">
```
docker stack deploy -c docker-compose.yaml agent-demo
```
</Step>
<Step title="Verifying Secret Consumption">
To confirm that secrets are properly rendered and accessible, navigate to `http://localhost`. You should see the Infisical secrets displayed on the Nginx landing page.
![Nginx displaying Infisical secrets](/images/docker-swarm-secrets-complete.png)
</Step>
<Step title="Clean up">
```
docker stack rm agent-demo
```
</Step>
</Steps>
## Considerations
- Secret Updates: Applications that access secrets directly from the volume mount will receive updates in real-time, in accordance with the `polling-interval` set in agent config.
- In-Memory Secrets: If your application loads secrets into memory, the new secrets will be available to the application on the next deployment.

@ -228,12 +228,27 @@
{
"group": "Agent",
"pages": [
"infisical-agent/overview"
"infisical-agent/overview",
{
"group": "Use cases",
"pages": [
"infisical-agent/guides/docker-swarm-with-agent",
"integrations/platforms/ecs-with-agent"
]
}
]
},
{
"group": "Infrastructure Integrations",
"pages": [
{
"group": "Container orchestrators",
"pages": [
"integrations/platforms/kubernetes",
"infisical-agent/guides/docker-swarm-with-agent",
"integrations/platforms/ecs-with-agent"
]
},
{
"group": "Docker",
"pages": [
@ -243,10 +258,8 @@
"integrations/platforms/docker-compose"
]
},
"integrations/platforms/kubernetes",
"integrations/frameworks/terraform",
"integrations/platforms/ansible",
"integrations/platforms/ecs-with-agent"
"integrations/platforms/ansible"
]
},
{

@ -1,5 +1,5 @@
{
"name": "npm-proj-1709146141702-0.772936286416932EMIzNi",
"name": "frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -68,7 +68,7 @@
"next": "^12.3.4",
"nprogress": "^0.2.0",
"picomatch": "^2.3.1",
"posthog-js": "^1.105.4",
"posthog-js": "^1.105.6",
"query-string": "^7.1.3",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1",
@ -19065,9 +19065,9 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/posthog-js": {
"version": "1.105.4",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.105.4.tgz",
"integrity": "sha512-hazxQYi4nxSqktu0Hh1xCV+sJCpN8mp5E5Ei/cfEa2nsb13xQbzn81Lf3VIDA0xMU1mXxNRStntlY267eQVC/w==",
"version": "1.105.6",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.105.6.tgz",
"integrity": "sha512-5ITXsh29XIuNohHLy21nawGnfFZDpyt+yfnWge9sJl5yv0nNuoUmLiDgw1tJafoqGrfd5CUasKyzSI21HxsSeQ==",
"dependencies": {
"fflate": "^0.4.8",
"preact": "^10.19.3"

@ -76,7 +76,7 @@
"next": "^12.3.4",
"nprogress": "^0.2.0",
"picomatch": "^2.3.1",
"posthog-js": "^1.105.4",
"posthog-js": "^1.105.6",
"query-string": "^7.1.3",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1",

@ -74,16 +74,6 @@ export default function RailwayCreateIntegrationPage() {
}
}, [targetEnvironments]);
useEffect(() => {
if (targetServices) {
if (targetServices.length > 0) {
setTargetServiceId(targetServices[0].serviceId);
} else {
setTargetServiceId("none");
}
}
}, [targetServices]);
const handleButtonClick = async () => {
try {
setIsLoading(true);
@ -124,11 +114,14 @@ export default function RailwayCreateIntegrationPage() {
}
};
const filteredTargetServices = targetServices ? [ { name: "", serviceId: "none" }, ...targetServices ] : [ { name: "", serviceId: "none" } ];
return workspace &&
selectedSourceEnvironment &&
integrationAuthApps &&
targetEnvironments &&
targetServices ? (
targetServices &&
filteredTargetServices ? (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Railway Integration</CardTitle>
@ -208,20 +201,14 @@ export default function RailwayCreateIntegrationPage() {
className="w-full border border-mineshaft-500"
isDisabled={targetServices.length === 0}
>
{targetServices.length > 0 ? (
targetServices.map((targetService) => (
{filteredTargetServices.map((targetService) => (
<SelectItem
value={targetService.serviceId as string}
key={`target-service-${targetService.serviceId as string}`}
>
{targetService.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-service-none">
No services found
</SelectItem>
)}
))}
</Select>
</FormControl>
<Button

@ -15,6 +15,7 @@ import { useToggle } from "@app/hooks";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { SecretEditRow } from "./SecretEditRow";
import SecretRenameRow from "./SecretRenameRow";
type Props = {
secretKey: string;
@ -105,6 +106,13 @@ export const SecretOverviewTableRow = ({
width: `calc(${expandableColWidth}px - 1rem)`
}}
>
<SecretRenameRow
secretKey={secretKey}
environments={environments}
secretPath={secretPath}
getSecretByKey={getSecretByKey}
/>
<TableContainer>
<table className="secret-table">
<thead>

@ -0,0 +1,258 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCheck, faClose, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { useGetUserWsKey, useUpdateSecretV3 } from "@app/hooks/api";
import { DecryptedSecret } from "@app/hooks/api/types";
import { SecretActionType } from "@app/views/SecretMainPage/components/SecretListView/SecretListView.utils";
type Props = {
secretKey: string;
secretPath: string;
environments: { name: string; slug: string }[];
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
};
export const formSchema = z.object({
key: z.string().trim().min(1, { message: "Secret key is required" })
});
type TFormSchema = z.infer<typeof formSchema>;
function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) {
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const { createNotification } = useNotificationContext();
const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey));
const isReadOnly = environments.some((env) => {
const environment = env.slug;
const isSecretInEnvReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
if (isSecretInEnvReadOnly) {
return true;
}
return false;
});
const isOverriden = secrets.some(
(secret) =>
secret?.overrideAction === SecretActionType.Created ||
secret?.overrideAction === SecretActionType.Modified
);
const workspaceId = currentWorkspace?.id || "";
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
const [isSecNameCopied, setIsSecNameCopied] = useToggle(false);
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
const {
handleSubmit,
control,
reset,
trigger,
getValues,
formState: { isDirty, isSubmitting, errors }
} = useForm<TFormSchema>({
defaultValues: { key: secretKey },
values: { key: secretKey },
resolver: zodResolver(formSchema)
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isSecNameCopied) {
timer = setTimeout(() => setIsSecNameCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isSecNameCopied]);
const handleFormSubmit = async (data: TFormSchema) => {
if (!data.key) {
createNotification({
type: "error",
text: "Secret name cannot be empty"
});
return;
}
const promises = secrets
.filter((secret) => !!secret)
.map((secret) => {
if (!secret) return null;
return updateSecretV3({
environment: secret?.env,
workspaceId,
secretPath,
secretName: secret.key,
secretId: secret.id,
secretValue: secret.value || "",
type: "shared",
latestFileKey: decryptFileKey!,
tags: secret.tags.map((tag) => tag.id),
secretComment: secret.comment,
secretReminderRepeatDays: secret.reminderRepeatDays,
secretReminderNote: secret.reminderNote,
skipMultilineEncoding: secret.skipMultilineEncoding,
newSecretName: data.key
});
});
await Promise.all(promises)
.then(() => {
createNotification({
type: "success",
text: "Successfully renamed the secret"
});
})
.catch(() => {
createNotification({
type: "error",
text: "Error renaming the secret"
});
});
};
const copyTokenToClipboard = () => {
const [key] = getValues(["key"]);
navigator.clipboard.writeText(key as string);
setIsSecNameCopied.on();
};
return (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="secret-table relative mb-2 flex w-full flex-row items-center justify-between overflow-hidden rounded-lg border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter"
>
<div className="flex h-11 flex-1 flex-shrink-0 items-center">
<span className="flex h-full min-w-[11rem] items-center justify-start border-r-2 border-mineshaft-600 px-4">
Key
</span>
<Controller
name="key"
control={control}
render={({ field, fieldState: { error } }) => (
<Input
autoComplete="off"
isReadOnly={isReadOnly}
autoCapitalization={currentWorkspace?.autoCapitalization}
variant="plain"
isDisabled={isOverriden}
placeholder={error?.message}
onKeyUp={() => trigger("key")}
isError={Boolean(error)}
{...field}
className="w-full px-2 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
/>
)}
/>
</div>
{isReadOnly || isOverriden ? (
<span className="mr-5 rounded-md bg-mineshaft-500 px-2">Read Only</span>
) : (
<div className="group flex w-20 items-center justify-center border-l border-mineshaft-500 py-1">
<AnimatePresence exitBeforeEnter>
{!isDirty ? (
<motion.div
key="options"
className="flex flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
>
<Tooltip content="Copy secret">
<IconButton
ariaLabel="copy-value"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isSecNameCopied ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</motion.div>
) : (
<motion.div
key="options-save"
className="flex flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
>
<Tooltip content={errors.key ? errors.key.message : "Save"}>
<IconButton
ariaLabel="more"
variant="plain"
type="submit"
size="md"
className={twMerge(
"p-0 text-primary opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
isDisabled={isSubmitting || Boolean(errors.key)}
>
{isSubmitting ? (
<Spinner className="m-0 h-4 w-4 p-0" />
) : (
<FontAwesomeIcon
icon={faCheck}
size="lg"
className={twMerge("text-primary", errors.key && "text-mineshaft-400")}
/>
)}
</IconButton>
</Tooltip>
<Tooltip content="Cancel">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className={twMerge(
"p-0 opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
onClick={() => reset()}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</form>
);
}
export default SecretRenameRow;