mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge branch 'main' of github.com:atimapreandrew/infisical
This commit is contained in:
@@ -1,72 +0,0 @@
|
||||
import { Request, Response } from "express";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import { APIKeyData } from "../../models";
|
||||
import { getSaltRounds } from "../../config";
|
||||
|
||||
/**
|
||||
* Return API key data for user with id [req.user_id]
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const getAPIKeyData = async (req: Request, res: Response) => {
|
||||
const apiKeyData = await APIKeyData.find({
|
||||
user: req.user._id,
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
apiKeyData,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create new API key data for user with id [req.user._id]
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const createAPIKeyData = async (req: Request, res: Response) => {
|
||||
const { name, expiresIn } = req.body;
|
||||
|
||||
const secret = crypto.randomBytes(16).toString("hex");
|
||||
const secretHash = await bcrypt.hash(secret, await getSaltRounds());
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
|
||||
|
||||
let apiKeyData = await new APIKeyData({
|
||||
name,
|
||||
lastUsed: new Date(),
|
||||
expiresAt,
|
||||
user: req.user._id,
|
||||
secretHash,
|
||||
}).save();
|
||||
|
||||
// return api key data without sensitive data
|
||||
// FIX: fix this any
|
||||
apiKeyData = (await APIKeyData.findById(apiKeyData._id)) as any;
|
||||
|
||||
if (!apiKeyData) throw new Error("Failed to find API key data");
|
||||
|
||||
const apiKey = `ak.${apiKeyData._id.toString()}.${secret}`;
|
||||
|
||||
return res.status(200).send({
|
||||
apiKey,
|
||||
apiKeyData,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete API key data with id [apiKeyDataId].
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const deleteAPIKeyData = async (req: Request, res: Response) => {
|
||||
const { apiKeyDataId } = req.params;
|
||||
const apiKeyData = await APIKeyData.findByIdAndDelete(apiKeyDataId);
|
||||
|
||||
return res.status(200).send({
|
||||
apiKeyData,
|
||||
});
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import * as usersController from "./usersController";
|
||||
import * as organizationsController from "./organizationsController";
|
||||
import * as workspaceController from "./workspaceController";
|
||||
import * as serviceTokenDataController from "./serviceTokenDataController";
|
||||
import * as apiKeyDataController from "./apiKeyDataController";
|
||||
import * as secretController from "./secretController";
|
||||
import * as secretsController from "./secretsController";
|
||||
import * as serviceAccountsController from "./serviceAccountsController";
|
||||
@@ -18,7 +17,6 @@ export {
|
||||
organizationsController,
|
||||
workspaceController,
|
||||
serviceTokenDataController,
|
||||
apiKeyDataController,
|
||||
secretController,
|
||||
secretsController,
|
||||
serviceAccountsController,
|
||||
|
||||
@@ -15,7 +15,7 @@ export const createWorkspaceTag = async (req: Request, res: Response) => {
|
||||
user: new Types.ObjectId(req.user._id),
|
||||
};
|
||||
|
||||
const createdTag = await new Tag(tagToCreate);
|
||||
const createdTag = await new Tag(tagToCreate).save();
|
||||
|
||||
res.json(createdTag);
|
||||
};
|
||||
@@ -48,7 +48,11 @@ export const deleteWorkspaceTag = async (req: Request, res: Response) => {
|
||||
|
||||
export const getWorkspaceTags = async (req: Request, res: Response) => {
|
||||
const { workspaceId } = req.params;
|
||||
const workspaceTags = await Tag.find({ workspace: workspaceId });
|
||||
|
||||
const workspaceTags = await Tag.find({
|
||||
workspace: new Types.ObjectId(workspaceId)
|
||||
});
|
||||
|
||||
return res.json({
|
||||
workspaceTags
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Request, Response } from "express";
|
||||
import { Types } from "mongoose";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import {
|
||||
MembershipOrg,
|
||||
User,
|
||||
APIKeyData,
|
||||
TokenVersion
|
||||
} from "../../models";
|
||||
import { getSaltRounds } from "../../config";
|
||||
|
||||
/**
|
||||
* Return the current user.
|
||||
@@ -117,3 +123,106 @@ export const getMyOrganizations = async (req: Request, res: Response) => {
|
||||
organizations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return API keys belonging to current user.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const getMyAPIKeys = async (req: Request, res: Response) => {
|
||||
const apiKeyData = await APIKeyData.find({
|
||||
user: req.user._id,
|
||||
});
|
||||
|
||||
return res.status(200).send(apiKeyData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new API key for current user.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const createAPIKey = async (req: Request, res: Response) => {
|
||||
const { name, expiresIn } = req.body;
|
||||
|
||||
const secret = crypto.randomBytes(16).toString("hex");
|
||||
const secretHash = await bcrypt.hash(secret, await getSaltRounds());
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
|
||||
|
||||
let apiKeyData = await new APIKeyData({
|
||||
name,
|
||||
lastUsed: new Date(),
|
||||
expiresAt,
|
||||
user: req.user._id,
|
||||
secretHash,
|
||||
}).save();
|
||||
|
||||
// return api key data without sensitive data
|
||||
apiKeyData = (await APIKeyData.findById(apiKeyData._id)) as any;
|
||||
|
||||
if (!apiKeyData) throw new Error("Failed to find API key data");
|
||||
|
||||
const apiKey = `ak.${apiKeyData._id.toString()}.${secret}`;
|
||||
|
||||
return res.status(200).send({
|
||||
apiKey,
|
||||
apiKeyData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key with id [apiKeyDataId] belonging to current user
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const deleteAPIKey = async (req: Request, res: Response) => {
|
||||
const { apiKeyDataId } = req.params;
|
||||
|
||||
const apiKeyData = await APIKeyData.findOneAndDelete({
|
||||
_id: new Types.ObjectId(apiKeyDataId),
|
||||
user: req.user._id
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
apiKeyData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return active sessions (TokenVersion) belonging to user
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const getMySessions = async (req: Request, res: Response) => {
|
||||
const tokenVersions = await TokenVersion.find({
|
||||
user: req.user._id
|
||||
});
|
||||
|
||||
return res.status(200).send(tokenVersions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all active sessions belong to user
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const deleteMySessions = async (req: Request, res: Response) => {
|
||||
await TokenVersion.updateMany({
|
||||
user: req.user._id,
|
||||
}, {
|
||||
$inc: {
|
||||
refreshVersion: 1,
|
||||
accessVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
message: "Successfully revoked all sessions"
|
||||
});
|
||||
}
|
||||
@@ -54,23 +54,20 @@ export const getSecretVersions = async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
*/
|
||||
const { secretId, workspaceId, environment, folderId } = req.params;
|
||||
const { secretId } = req.params;
|
||||
|
||||
const offset: number = parseInt(req.query.offset as string);
|
||||
const limit: number = parseInt(req.query.limit as string);
|
||||
|
||||
const secretVersions = await SecretVersion.find({
|
||||
secret: secretId,
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
folder: folderId,
|
||||
secret: secretId
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(offset)
|
||||
.limit(limit);
|
||||
|
||||
return res.status(200).send({
|
||||
secretVersions,
|
||||
secretVersions
|
||||
});
|
||||
};
|
||||
|
||||
@@ -135,7 +132,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
||||
// validate secret version
|
||||
const oldSecretVersion = await SecretVersion.findOne({
|
||||
secret: secretId,
|
||||
version,
|
||||
version
|
||||
}).select("+secretBlindIndex");
|
||||
|
||||
if (!oldSecretVersion) throw new Error("Failed to find secret version");
|
||||
@@ -154,7 +151,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
||||
secretValueTag,
|
||||
algorithm,
|
||||
folder,
|
||||
keyEncoding,
|
||||
keyEncoding
|
||||
} = oldSecretVersion;
|
||||
|
||||
// update secret
|
||||
@@ -162,7 +159,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
||||
secretId,
|
||||
{
|
||||
$inc: {
|
||||
version: 1,
|
||||
version: 1
|
||||
},
|
||||
workspace,
|
||||
type,
|
||||
@@ -177,10 +174,10 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
||||
secretValueTag,
|
||||
folderId: folder,
|
||||
algorithm,
|
||||
keyEncoding,
|
||||
keyEncoding
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
@@ -204,17 +201,17 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
||||
secretValueTag,
|
||||
folder,
|
||||
algorithm,
|
||||
keyEncoding,
|
||||
keyEncoding
|
||||
}).save();
|
||||
|
||||
// take secret snapshot
|
||||
await EESecretService.takeSecretSnapshot({
|
||||
workspaceId: secret.workspace,
|
||||
environment,
|
||||
folderId: folder,
|
||||
folderId: folder
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
secret,
|
||||
secret
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
secrets as v2SecretsRouter,
|
||||
serviceTokenData as v2ServiceTokenDataRouter,
|
||||
serviceAccounts as v2ServiceAccountsRouter,
|
||||
apiKeyData as v2APIKeyDataRouter,
|
||||
environment as v2EnvironmentRouter,
|
||||
tags as v2TagsRouter,
|
||||
} from "./routes/v2";
|
||||
@@ -138,7 +137,6 @@ const main = async () => {
|
||||
app.use("/api/v2/secrets", v2SecretsRouter); // note: in the process of moving to v3/secrets
|
||||
app.use("/api/v2/service-token", v2ServiceTokenDataRouter);
|
||||
app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new
|
||||
app.use("/api/v2/api-key", v2APIKeyDataRouter);
|
||||
|
||||
// v3 routes (experimental)
|
||||
app.use("/api/v3/auth", v3AuthRouter);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import express from "express";
|
||||
const router = express.Router();
|
||||
import { body, param } from "express-validator";
|
||||
import {
|
||||
requireAuth,
|
||||
validateRequest,
|
||||
} from "../../middleware";
|
||||
import { apiKeyDataController } from "../../controllers/v2";
|
||||
import {
|
||||
AUTH_MODE_JWT,
|
||||
} from "../../variables";
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
apiKeyDataController.getAPIKeyData
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
body("name").exists().trim(),
|
||||
body("expiresIn"), // measured in ms
|
||||
validateRequest,
|
||||
apiKeyDataController.createAPIKeyData
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:apiKeyDataId",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
param("apiKeyDataId").exists().trim(),
|
||||
validateRequest,
|
||||
apiKeyDataController.deleteAPIKeyData
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -7,7 +7,6 @@ import secret from "./secret"; // deprecated
|
||||
import secrets from "./secrets";
|
||||
import serviceTokenData from "./serviceTokenData";
|
||||
import serviceAccounts from "./serviceAccounts";
|
||||
import apiKeyData from "./apiKeyData";
|
||||
import environment from "./environment"
|
||||
import tags from "./tags"
|
||||
|
||||
@@ -21,7 +20,6 @@ export {
|
||||
secrets,
|
||||
serviceTokenData,
|
||||
serviceAccounts,
|
||||
apiKeyData,
|
||||
environment,
|
||||
tags,
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
requireAuth,
|
||||
validateRequest,
|
||||
} from "../../middleware";
|
||||
import { body } from "express-validator";
|
||||
import { body, param } from "express-validator";
|
||||
import { usersController } from "../../controllers/v2";
|
||||
import {
|
||||
AUTH_MODE_API_KEY,
|
||||
@@ -37,4 +37,49 @@ router.get(
|
||||
usersController.getMyOrganizations
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/me/api-keys",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
usersController.getMyAPIKeys
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/me/api-keys",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
body("name").exists().isString().trim(),
|
||||
body("expiresIn").isNumeric(),
|
||||
validateRequest,
|
||||
usersController.createAPIKey
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/me/api-keys/:apiKeyDataId",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
param("apiKeyDataId").exists().trim(),
|
||||
validateRequest,
|
||||
usersController.deleteAPIKey
|
||||
);
|
||||
|
||||
router.get( // new
|
||||
"/me/sessions",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
usersController.getMySessions
|
||||
);
|
||||
|
||||
router.delete( // new
|
||||
"/me/sessions",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||
}),
|
||||
usersController.deleteMySessions
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -50,6 +50,7 @@ metadata:
|
||||
spec:
|
||||
# The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used
|
||||
hostAPI: https://app.infisical.com/api
|
||||
resyncInterval: 60 # <-- the time in seconds between secret re-sync. Faster re-syncs will require higher rate limits
|
||||
authentication:
|
||||
serviceToken:
|
||||
serviceTokenSecretReference:
|
||||
@@ -79,6 +80,11 @@ spec:
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="resyncInterval">
|
||||
This property defines the time in seconds between each secret re-sync from Infisical. Shorter time between re-syncs will require higher rate limits only available on paid plans.
|
||||
Default re-sync interval is every 1 minute.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="authentication">
|
||||
The `authentication` property tells the operator where it should look to find credentials needed to fetch secrets from Infisical.
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
import addAPIKey from "@app/pages/api/apiKey/addAPIKey";
|
||||
|
||||
import Button from "../buttons/Button";
|
||||
import InputField from "../InputField";
|
||||
import ListBox from "../Listbox";
|
||||
|
||||
const expiryMapping = {
|
||||
"1 day": 86400,
|
||||
"7 days": 604800,
|
||||
"1 month": 2592000,
|
||||
"6 months": 15552000,
|
||||
"12 months": 31104000
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
closeModal: () => void;
|
||||
// TODO: These never and any will be filled by single folder that contains types and hooks about the API
|
||||
apiKeys: any[];
|
||||
setApiKeys: (arg: any[]) => void;
|
||||
};
|
||||
|
||||
// TODO: convert to TS
|
||||
const AddApiKeyDialog = ({ isOpen, closeModal, apiKeys, setApiKeys }: Props) => {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiKeyName, setApiKeyName] = useState("");
|
||||
const [apiKeyExpiresIn, setApiKeyExpiresIn] = useState("1 day");
|
||||
const [apiKeyCopied, setApiKeyCopied] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const generateAPIKey = async () => {
|
||||
const newApiKey = await addAPIKey({
|
||||
name: apiKeyName,
|
||||
expiresIn: expiryMapping[apiKeyExpiresIn as keyof typeof expiryMapping]
|
||||
});
|
||||
|
||||
setApiKeys([...apiKeys, newApiKey.apiKeyData]);
|
||||
setApiKey(newApiKey.apiKey);
|
||||
};
|
||||
|
||||
function copyToClipboard() {
|
||||
// Get the text field
|
||||
const copyText = document.getElementById("apiKey") as HTMLInputElement;
|
||||
|
||||
// Select the text field
|
||||
copyText.select();
|
||||
copyText.setSelectionRange(0, 99999); // For mobile devices
|
||||
|
||||
// Copy the text inside the text field
|
||||
navigator.clipboard.writeText(copyText.value);
|
||||
|
||||
setApiKeyCopied(true);
|
||||
setTimeout(() => setApiKeyCopied(false), 2000);
|
||||
// Alert the copied text
|
||||
// alert("Copied the text: " + copyText.value);
|
||||
}
|
||||
|
||||
const closeAddApiKeyModal = () => {
|
||||
closeModal();
|
||||
setApiKeyName("");
|
||||
setApiKey("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-50">
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-bunker-700 bg-opacity-80" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
{apiKey === "" ? (
|
||||
<Dialog.Panel className="w-full max-w-md transform rounded-md border border-gray-700 bg-bunker-800 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="z-50 text-lg font-medium leading-6 text-gray-400"
|
||||
>
|
||||
{t("section.api-key.add-dialog.title")}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 mb-4">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("section.api-key.add-dialog.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 max-h-28">
|
||||
<InputField
|
||||
label={t("section.api-key.add-dialog.name")}
|
||||
onChangeHandler={setApiKeyName}
|
||||
type="varName"
|
||||
value={apiKeyName}
|
||||
placeholder=""
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-28">
|
||||
<ListBox
|
||||
isSelected={apiKeyExpiresIn}
|
||||
onChange={setApiKeyExpiresIn}
|
||||
data={["1 day", "7 days", "1 month", "6 months", "12 months"]}
|
||||
isFull
|
||||
text={`${t("common.expired-in")}: `}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-max">
|
||||
<div className="mt-6 flex w-max flex-col justify-start">
|
||||
<Button
|
||||
onButtonPressed={() => generateAPIKey()}
|
||||
color="mineshaft"
|
||||
text={t("section.api-key.add-dialog.add") as string}
|
||||
textDisabled={t("section.api-key.add-dialog.add") as string}
|
||||
size="md"
|
||||
active={apiKeyName !== ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
) : (
|
||||
<Dialog.Panel className="w-full max-w-md transform rounded-md border border-gray-700 bg-bunker-800 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="z-50 text-lg font-medium leading-6 text-gray-400"
|
||||
>
|
||||
{t("section.api-key.add-dialog.copy-service-token")}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 mb-4">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("section.api-key.add-dialog.copy-service-token-description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="mt-2 mr-2 flex h-20 w-full items-center justify-end rounded-md bg-white/[0.07] text-base text-gray-400">
|
||||
<input
|
||||
type="text"
|
||||
value={apiKey}
|
||||
disabled
|
||||
id="apiKey"
|
||||
className="invisible w-full min-w-full bg-white/0 py-2 px-2 text-gray-400 outline-none"
|
||||
/>
|
||||
<div className="w-full max-w-md break-words bg-white/0 py-2 pl-14 pr-2 text-sm text-gray-400 outline-none">
|
||||
{apiKey}
|
||||
</div>
|
||||
<div className="group relative inline-block h-full font-normal text-gray-400 underline duration-200 hover:text-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyToClipboard}
|
||||
className="h-full border-l border-white/20 py-2 pl-3.5 pr-4 duration-200 hover:bg-white/[0.12]"
|
||||
>
|
||||
{apiKeyCopied ? (
|
||||
<FontAwesomeIcon icon={faCheck} className="pr-0.5" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
)}
|
||||
</button>
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-chicago-900 px-3 py-2 text-center text-sm text-gray-400 duration-300 group-hover:flex group-hover:animate-popup">
|
||||
{t("common.click-to-copy")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex w-max flex-col justify-start">
|
||||
<Button
|
||||
onButtonPressed={() => closeAddApiKeyModal()}
|
||||
color="mineshaft"
|
||||
text="Close"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
)}
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddApiKeyDialog;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { faX } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
|
||||
import deleteAPIKey from "../../../pages/api/apiKey/deleteAPIKey";
|
||||
import guidGenerator from "../../utilities/randomId";
|
||||
import Button from "../buttons/Button";
|
||||
|
||||
interface TokenProps {
|
||||
_id: string;
|
||||
name: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface ServiceTokensProps {
|
||||
data: TokenProps[];
|
||||
setApiKeys: (value: TokenProps[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the component that we utilize for the api key table
|
||||
* @param {object} obj
|
||||
* @param {any[]} obj.data - current state of the api key table
|
||||
* @param {function} obj.setApiKeys - updating the state of the api key table
|
||||
* @returns
|
||||
*/
|
||||
const ApiKeyTable = ({ data, setApiKeys }: ServiceTokensProps) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
return (
|
||||
<div className="table-container w-full bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1">
|
||||
<div className="absolute rounded-t-md w-full h-12 bg-white/5" />
|
||||
<table className="w-full my-1">
|
||||
<thead className="text-bunker-300 text-sm font-light">
|
||||
<tr>
|
||||
<th className="text-left pl-6 pt-2.5 pb-2">API KEY NAME</th>
|
||||
<th className="text-left pl-6 pt-2.5 pb-2">VALID UNTIL</th>
|
||||
<th aria-label="button" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.length > 0 ? (
|
||||
data?.map((row) => (
|
||||
<tr
|
||||
key={guidGenerator()}
|
||||
className="bg-bunker-800 hover:bg-bunker-800/5 duration-100"
|
||||
>
|
||||
<td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.name}
|
||||
</td>
|
||||
<td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300">
|
||||
{new Date(row.expiresAt).toUTCString()}
|
||||
</td>
|
||||
<td className="py-2 border-mineshaft-700 border-t">
|
||||
<div className="opacity-50 hover:opacity-100 duration-200 flex items-center">
|
||||
<Button
|
||||
onButtonPressed={() => {
|
||||
deleteAPIKey({ apiKeyId: row._id });
|
||||
setApiKeys(data.filter((token) => token._id !== row._id));
|
||||
createNotification({
|
||||
text: `'${row.name}' API key has been revoked.`,
|
||||
type: "error"
|
||||
});
|
||||
}}
|
||||
color="red"
|
||||
size="icon-sm"
|
||||
icon={faX}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center pt-7 pb-5 text-bunker-300 text-sm">
|
||||
No API keys yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyTable;
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from "@fortawesome/free-solid-svg-icons";
|
||||
import { plans } from "public/data/frequentConstants";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Select, SelectItem } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import updateUserProjectPermission from "@app/ee/api/memberships/UpdateUserProjectPermission";
|
||||
import getOrganizationSubscriptions from "@app/pages/api/organization/GetOrgSubscription";
|
||||
import changeUserRoleInWorkspace from "@app/pages/api/workspace/changeUserRoleInWorkspace";
|
||||
import deleteUserFromWorkspace from "@app/pages/api/workspace/deleteUserFromWorkspace";
|
||||
import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey";
|
||||
@@ -40,13 +39,12 @@ type EnvironmentProps = {
|
||||
* @returns
|
||||
*/
|
||||
const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoading }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const [roleSelected, setRoleSelected] = useState(
|
||||
Array(userData?.length).fill(userData.map((user) => user.role))
|
||||
);
|
||||
const host = window.location.origin;
|
||||
const router = useRouter();
|
||||
const [myRole, setMyRole] = useState("member");
|
||||
const [currentPlan, setCurrentPlan] = useState("");
|
||||
const [workspaceEnvs, setWorkspaceEnvs] = useState<EnvironmentProps[]>([]);
|
||||
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
||||
const { createNotification } = useNotificationContext();
|
||||
@@ -128,7 +126,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
|
||||
denials = [];
|
||||
}
|
||||
|
||||
if (currentPlan !== plans.professional && host === "https://app.infisical.com" && workspaceId !== "63ea8121b6e2b0543ba79616") {
|
||||
if (subscription?.rbac === false) {
|
||||
setIsUpgradeModalOpen(true);
|
||||
} else {
|
||||
const allDenials = userData[index].deniedPermissions
|
||||
@@ -167,14 +165,6 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
|
||||
(async () => {
|
||||
const result = await getProjectInfo({ projectId: workspaceId });
|
||||
setWorkspaceEnvs(result.environments);
|
||||
|
||||
const orgId = localStorage.getItem("orgData.id") as string;
|
||||
const subscriptions = await getOrganizationSubscriptions({
|
||||
orgId
|
||||
});
|
||||
if (subscriptions) {
|
||||
setCurrentPlan(subscriptions.data[0].plan.product);
|
||||
}
|
||||
})();
|
||||
}, [userData, myUser]);
|
||||
|
||||
@@ -208,11 +198,13 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
|
||||
return (
|
||||
<div className="table-container relative mb-6 mt-1 min-w-max rounded-md border border-mineshaft-600 bg-bunker">
|
||||
<div className="absolute h-[3.1rem] w-full rounded-t-md bg-white/5" />
|
||||
<UpgradePlanModal
|
||||
isOpen={isUpgradeModalOpen}
|
||||
onClose={closeUpgradeModal}
|
||||
text="You can change user permissions if you switch to Infisical's Professional plan."
|
||||
/>
|
||||
{subscription && (
|
||||
<UpgradePlanModal
|
||||
isOpen={isUpgradeModalOpen}
|
||||
onClose={closeUpgradeModal}
|
||||
text={subscription.slug === null ? "You can use RBAC under an Enterprise license" : "You can use RBAC if you switch to Infisical's Team Plan."}
|
||||
/>
|
||||
)}
|
||||
<table className="my-0.5 w-full">
|
||||
<thead className="text-xs font-light text-gray-400 bg-mineshaft-800">
|
||||
<tr>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import StripeRedirect from "@app/pages/api/organization/StripeRedirect";
|
||||
|
||||
import { tempLocalStorage } from "../utilities/checks/tempLocalStorage";
|
||||
|
||||
interface Props {
|
||||
plan: {
|
||||
name: string;
|
||||
price: string;
|
||||
priceExplanation?: string;
|
||||
text: string;
|
||||
subtext?: string;
|
||||
buttonTextMain: string;
|
||||
buttonTextSecondary: string;
|
||||
current: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Plan({ plan }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col justify-between border min-w-fit w-96 rounded-lg h-68 mr-4 bg-mineshaft-800 ${
|
||||
plan.name !== "Starter" && plan.current === true ? "border-2 border-primary" : "border-mineshaft-600"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row justify-between items-center relative z-10">
|
||||
<p className="px-6 py-4 text-3xl font-semibold text-gray-400">{plan.name}</p>
|
||||
</div>
|
||||
<div className="flex flwx-row items-end justify-start mb-4">
|
||||
<p className="pl-6 text-3xl font-semibold text-primary">{plan.price}</p>
|
||||
<p className="pl-3 mb-1 text-lg text-gray-400">{plan.priceExplanation}</p>
|
||||
</div>
|
||||
<p className="relative z-10 max-w-fit px-6 text-base text-gray-400">{plan.text}</p>
|
||||
<p className="relative z-10 max-w-fit px-6 text-base text-gray-400">{plan.subtext}</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
{plan.current === false ? (
|
||||
<>
|
||||
{plan.buttonTextMain === "Schedule a Demo" ? (
|
||||
<a href="/scheduledemo" target='_blank rel="noopener"'>
|
||||
<div className="relative z-10 mx-5 mt-3 mb-4 py-2 px-4 border border-1 border-mineshaft-600 hover:text-black hover:border-primary text-gray-400 font-semibold hover:bg-primary bg-bunker duration-200 cursor-pointer rounded-md flex w-max">
|
||||
{plan.buttonTextMain}
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
className={`relative z-10 mx-5 mt-3 mb-4 py-2 px-4 border border-1 border-mineshaft-600 text-gray-400 font-semibold ${
|
||||
plan.buttonTextMain === "Downgrade"
|
||||
? "hover:bg-red hover:text-white hover:border-red"
|
||||
: "hover:bg-primary hover:text-black hover:border-primary"
|
||||
} bg-bunker duration-200 cursor-pointer rounded-md flex w-max`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
StripeRedirect({
|
||||
orgId: tempLocalStorage("orgData.id")
|
||||
})
|
||||
}
|
||||
>
|
||||
{plan.buttonTextMain}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<a href="https://infisical.com/pricing" target='_blank rel="noopener"'>
|
||||
<div className="relative z-10 text-gray-400 font-semibold hover:text-primary duration-200 cursor-pointer mb-0.5">
|
||||
{plan.buttonTextSecondary}
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={`h-8 w-full rounded-b-md flex justify-center items-center z-10 ${
|
||||
plan.name !== "Starter" && plan.current === true ? "bg-primary" : "bg-mineshaft-400"
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs text-black font-semibold">CURRENT PLAN</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export default function NavHeader({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ml-4 flex flex-row items-center pt-6">
|
||||
<div className="flex flex-row items-center pt-6">
|
||||
<div className="mr-2 flex h-6 w-6 items-center justify-center rounded-md bg-primary-900 text-mineshaft-100">
|
||||
{currentOrg?.name?.charAt(0)}
|
||||
</div>
|
||||
|
||||
108
frontend/src/components/utilities/attemptChangePassword.ts
Normal file
108
frontend/src/components/utilities/attemptChangePassword.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable new-cap */
|
||||
import crypto from "crypto";
|
||||
|
||||
import jsrp from "jsrp";
|
||||
|
||||
import changePassword2 from "@app/pages/api/auth/ChangePassword2";
|
||||
import SRP1 from "@app/pages/api/auth/SRP1";
|
||||
|
||||
import Aes256Gcm from "./cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "./cryptography/crypto";
|
||||
import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage";
|
||||
|
||||
const clientOldPassword = new jsrp.client();
|
||||
const clientNewPassword = new jsrp.client();
|
||||
|
||||
type Params = {
|
||||
email: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
const attemptChangePassword = ({ email, currentPassword, newPassword }: Params): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
clientOldPassword.init({ username: email, password: currentPassword }, async () => {
|
||||
let serverPublicKey; let salt;
|
||||
|
||||
try {
|
||||
const clientPublicKey = clientOldPassword.getPublicKey();
|
||||
|
||||
const res = await SRP1({ clientPublicKey });
|
||||
|
||||
serverPublicKey = res.serverPublicKey;
|
||||
salt = res.salt;
|
||||
|
||||
clientOldPassword.setSalt(salt);
|
||||
clientOldPassword.setServerPublicKey(serverPublicKey);
|
||||
|
||||
const clientProof = clientOldPassword.getProof();
|
||||
|
||||
clientNewPassword.init({ username: email, password: newPassword }, async () => {
|
||||
clientNewPassword.createVerifier(async (err, result) => {
|
||||
try {
|
||||
const derivedKey = await deriveArgonKey({
|
||||
password: newPassword,
|
||||
salt: result.salt,
|
||||
mem: 65536,
|
||||
time: 3,
|
||||
parallelism: 1,
|
||||
hashLen: 32
|
||||
});
|
||||
|
||||
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
|
||||
const {
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: localStorage.getItem("PRIVATE_KEY") as string,
|
||||
secret: key
|
||||
});
|
||||
|
||||
const {
|
||||
ciphertext: protectedKey,
|
||||
iv: protectedKeyIV,
|
||||
tag: protectedKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: key.toString("hex"),
|
||||
secret: Buffer.from(derivedKey.hash)
|
||||
});
|
||||
|
||||
await changePassword2({
|
||||
clientProof,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
encryptedPrivateKey,
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
salt: result.salt,
|
||||
verifier: result.verifier
|
||||
});
|
||||
|
||||
saveTokenToLocalStorage({
|
||||
encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag
|
||||
});
|
||||
|
||||
resolve();
|
||||
} catch (err2) {
|
||||
console.error(err2);
|
||||
reject(err2);
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default attemptChangePassword;
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
useGetAuthToken,
|
||||
useGetCommonPasswords,
|
||||
useRevokeAllSessions,
|
||||
useSendMfaToken,
|
||||
useSendMfaToken,
|
||||
useVerifyMfaToken
|
||||
} from "./queries";
|
||||
} from "./queries"
|
||||
|
||||
@@ -51,15 +51,6 @@ export const useGetAuthToken = () =>
|
||||
retry: 0
|
||||
});
|
||||
|
||||
export const useRevokeAllSessions = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiRequest.delete("/api/v1/auth/sessions");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fetchCommonPasswords = async () => {
|
||||
const { data } = await apiRequest.get("/api/v1/auth/common-passwords");
|
||||
return data || [];
|
||||
|
||||
@@ -81,76 +81,79 @@ export const useGetProjectSecrets = ({
|
||||
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
|
||||
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
|
||||
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
|
||||
select: useCallback((data: EncryptedSecret[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const sharedSecrets: DecryptedSecret[] = [];
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
// this used for add-only mode in dashboard
|
||||
// type won't be there thus only one key is shown
|
||||
const duplicateSecretKey: Record<string, boolean> = {};
|
||||
data.forEach((encSecret: EncryptedSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
select: useCallback(
|
||||
(data: EncryptedSecret[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
});
|
||||
const sharedSecrets: DecryptedSecret[] = [];
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
// this used for add-only mode in dashboard
|
||||
// type won't be there thus only one key is shown
|
||||
const duplicateSecretKey: Record<string, boolean> = {};
|
||||
data.forEach((encSecret: EncryptedSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
const decryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
};
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
const decryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||
sharedSecrets.push(decryptedSecret);
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||
sharedSecrets.push(decryptedSecret);
|
||||
}
|
||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||
}
|
||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||
}
|
||||
});
|
||||
sharedSecrets.forEach((val) => {
|
||||
const dupKey = `${val.key}-${val.env}`;
|
||||
if (personalSecrets?.[dupKey]) {
|
||||
val.idOverride = personalSecrets[dupKey].id;
|
||||
val.valueOverride = personalSecrets[dupKey].value;
|
||||
val.overrideAction = "modified";
|
||||
}
|
||||
});
|
||||
return { secrets: sharedSecrets };
|
||||
}, [decryptFileKey])
|
||||
});
|
||||
sharedSecrets.forEach((val) => {
|
||||
const dupKey = `${val.key}-${val.env}`;
|
||||
if (personalSecrets?.[dupKey]) {
|
||||
val.idOverride = personalSecrets[dupKey].id;
|
||||
val.valueOverride = personalSecrets[dupKey].value;
|
||||
val.overrideAction = "modified";
|
||||
}
|
||||
});
|
||||
return { secrets: sharedSecrets };
|
||||
},
|
||||
[decryptFileKey]
|
||||
)
|
||||
});
|
||||
|
||||
export const useGetProjectSecretsByKey = ({
|
||||
@@ -167,82 +170,85 @@ export const useGetProjectSecretsByKey = ({
|
||||
// right now secretpath is passed as folderid as only this is used in overview
|
||||
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath),
|
||||
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
|
||||
select: useCallback((data: EncryptedSecret[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const sharedSecrets: Record<string, DecryptedSecret[]> = {};
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
// this used for add-only mode in dashboard
|
||||
// type won't be there thus only one key is shown
|
||||
const duplicateSecretKey: Record<string, boolean> = {};
|
||||
const uniqSecKeys: Record<string, boolean> = {};
|
||||
data.forEach((encSecret: EncryptedSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
select: useCallback(
|
||||
(data: EncryptedSecret[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
const sharedSecrets: Record<string, DecryptedSecret[]> = {};
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
// this used for add-only mode in dashboard
|
||||
// type won't be there thus only one key is shown
|
||||
const duplicateSecretKey: Record<string, boolean> = {};
|
||||
const uniqSecKeys: Record<string, boolean> = {};
|
||||
data.forEach((encSecret: EncryptedSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
|
||||
|
||||
const decryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
};
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
|
||||
const decryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = [];
|
||||
sharedSecrets[secretKey].push(decryptedSecret);
|
||||
}
|
||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||
}
|
||||
});
|
||||
Object.keys(sharedSecrets).forEach((secName) => {
|
||||
sharedSecrets[secName].forEach((val) => {
|
||||
const dupKey = `${val.key}-${val.env}`;
|
||||
if (personalSecrets?.[dupKey]) {
|
||||
val.idOverride = personalSecrets[dupKey].id;
|
||||
val.valueOverride = personalSecrets[dupKey].value;
|
||||
val.overrideAction = "modified";
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = [];
|
||||
sharedSecrets[secretKey].push(decryptedSecret);
|
||||
}
|
||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
Object.keys(sharedSecrets).forEach((secName) => {
|
||||
sharedSecrets[secName].forEach((val) => {
|
||||
const dupKey = `${val.key}-${val.env}`;
|
||||
if (personalSecrets?.[dupKey]) {
|
||||
val.idOverride = personalSecrets[dupKey].id;
|
||||
val.valueOverride = personalSecrets[dupKey].value;
|
||||
val.overrideAction = "modified";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
|
||||
}, [decryptFileKey])
|
||||
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
|
||||
},
|
||||
[decryptFileKey]
|
||||
)
|
||||
});
|
||||
|
||||
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
|
||||
@@ -263,29 +269,32 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
|
||||
enabled: Boolean(dto.secretId && dto.decryptFileKey),
|
||||
queryKey: secretKeys.getSecretVersion(dto.secretId),
|
||||
queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit),
|
||||
select: useCallback((data: EncryptedSecretVersion[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = dto.decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
select: useCallback(
|
||||
(data: EncryptedSecretVersion[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = dto.decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
return data
|
||||
.map((el) => ({
|
||||
createdAt: el.createdAt,
|
||||
id: el._id,
|
||||
value: decryptSymmetric({
|
||||
ciphertext: el.secretValueCiphertext,
|
||||
iv: el.secretValueIV,
|
||||
tag: el.secretValueTag,
|
||||
key
|
||||
})
|
||||
}))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}, [])
|
||||
return data
|
||||
.map((el) => ({
|
||||
createdAt: el.createdAt,
|
||||
id: el._id,
|
||||
value: decryptSymmetric({
|
||||
ciphertext: el.secretValueCiphertext,
|
||||
iv: el.secretValueIV,
|
||||
tag: el.secretValueTag,
|
||||
key
|
||||
})
|
||||
}))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
},
|
||||
[dto.decryptFileKey]
|
||||
)
|
||||
});
|
||||
|
||||
export const useBatchSecretsOp = () => {
|
||||
|
||||
@@ -2,11 +2,15 @@ export {
|
||||
fetchOrgUsers,
|
||||
useAddUserToOrg,
|
||||
useAddUserToWs,
|
||||
useCreateAPIKey,
|
||||
useDeleteAPIKey,
|
||||
useDeleteOrgMembership,
|
||||
useGetMyAPIKeys,
|
||||
useGetMySessions,
|
||||
useGetOrgUsers,
|
||||
useGetUser,
|
||||
useGetUserAction,
|
||||
useLogoutUser,
|
||||
useRegisterUserAction,
|
||||
useUpdateOrgUserRole
|
||||
} from "./queries";
|
||||
useRevokeMySessions,
|
||||
useUpdateOrgUserRole} from "./queries";
|
||||
|
||||
@@ -12,16 +12,20 @@ import {
|
||||
AddUserToOrgDTO,
|
||||
AddUserToWsDTO,
|
||||
AddUserToWsRes,
|
||||
APIKeyData,
|
||||
CreateAPIKeyRes,
|
||||
DeletOrgMembershipDTO,
|
||||
OrgUser,
|
||||
TokenVersion,
|
||||
UpdateOrgUserRoleDTO,
|
||||
User
|
||||
} from "./types";
|
||||
User} from "./types";
|
||||
|
||||
const userKeys = {
|
||||
getUser: ["user"] as const,
|
||||
userAction: ["user-action"] as const,
|
||||
getOrgUsers: (orgId: string) => [{ orgId }, "user"]
|
||||
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
|
||||
myAPIKeys: ["api-keys"] as const,
|
||||
mySessions: ["sessions"] as const
|
||||
};
|
||||
|
||||
export const fetchUserDetails = async () => {
|
||||
@@ -167,3 +171,90 @@ export const useLogoutUser = () =>
|
||||
localStorage.setItem("PRIVATE_KEY", "");
|
||||
}
|
||||
});
|
||||
|
||||
export const useGetMyAPIKeys = () => {
|
||||
return useQuery({
|
||||
queryKey: userKeys.myAPIKeys,
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<APIKeyData[]>(
|
||||
"/api/v2/users/me/api-keys"
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: true
|
||||
});
|
||||
}
|
||||
|
||||
export const useCreateAPIKey = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
name,
|
||||
expiresIn
|
||||
}: {
|
||||
name: string;
|
||||
expiresIn: number;
|
||||
}) => {
|
||||
const { data } = await apiRequest.post<CreateAPIKeyRes>(
|
||||
"/api/v2/users/me/api-keys",
|
||||
{
|
||||
name,
|
||||
expiresIn
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(userKeys.myAPIKeys);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const useDeleteAPIKey = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (apiKeyDataId: string) => {
|
||||
const { data } = await apiRequest.delete(
|
||||
`/api/v2/users/me/api-keys/${apiKeyDataId}`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(userKeys.myAPIKeys);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const useGetMySessions = () => {
|
||||
return useQuery({
|
||||
queryKey: userKeys.mySessions,
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TokenVersion[]>(
|
||||
"/api/v2/users/me/sessions"
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
enabled: true
|
||||
});
|
||||
}
|
||||
|
||||
export const useRevokeMySessions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
console.log("useRevokeAllSessions 1");
|
||||
const { data } = await apiRequest.delete(
|
||||
"/api/v2/users/me/sessions"
|
||||
);
|
||||
|
||||
console.log("useRevokeAllSessions 2: ", data);
|
||||
return data;
|
||||
},
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(userKeys.mySessions);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -61,3 +61,27 @@ export type AddUserToOrgDTO = {
|
||||
inviteeEmail: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export type CreateAPIKeyRes = {
|
||||
apiKey: string;
|
||||
apiKeyData: APIKeyData;
|
||||
}
|
||||
|
||||
export type APIKeyData = {
|
||||
_id: string;
|
||||
name: string;
|
||||
user: string;
|
||||
lastUsed: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export type TokenVersion = {
|
||||
_id: string;
|
||||
user: string;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
lastUsed: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useLeaveConfirm } from "./useLeaveConfirm";
|
||||
export { usePersistentState } from "./usePersistentState";
|
||||
export { usePopUp } from "./usePopUp";
|
||||
export { useSyntaxHighlight } from "./useSyntaxHighlight";
|
||||
export { useToggle } from "./useToggle";
|
||||
|
||||
45
frontend/src/hooks/useSyntaxHighlight.tsx
Normal file
45
frontend/src/hooks/useSyntaxHighlight.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from "react";
|
||||
import { faCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
const REGEX = /([$]{.*?})/g;
|
||||
|
||||
export const useSyntaxHighlight = () => {
|
||||
const syntaxHighlight = useCallback((text: string, isHidden?: boolean) => {
|
||||
if (isHidden) {
|
||||
return text
|
||||
.split("")
|
||||
.slice(0, 200)
|
||||
.map((el, i) =>
|
||||
el === "\n" ? (
|
||||
el
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
key={`${text}_${el}_${i + 1}`}
|
||||
className="mr-0.5 text-xxs"
|
||||
icon={faCircle}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// append a space on last new line this to show new line in ui for code component
|
||||
const val = text.at(-1) === "\n" ? text.concat(" ") : text;
|
||||
if (val?.length === 0) return <span className="font-mono text-bunker-400/80">EMPTY</span>;
|
||||
return val?.split(REGEX).map((word, i) =>
|
||||
word.match(REGEX) !== null ? (
|
||||
<span className="ph-no-capture text-yellow" key={`${val}-${i + 1}`}>
|
||||
${
|
||||
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
||||
}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`${word}_${i + 1}`} className="ph-no-capture">
|
||||
{word}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return syntaxHighlight;
|
||||
};
|
||||
@@ -158,7 +158,9 @@ export default function Activity() {
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Head>
|
||||
<NavHeader pageName="Audit Logs" isProjectRelated />
|
||||
<div className="ml-6">
|
||||
<NavHeader pageName="Audit Logs" isProjectRelated />
|
||||
</div>
|
||||
{currentSidebarAction && (
|
||||
<ActivitySideBar toggleSidebar={toggleSidebar} currentAction={currentSidebarAction} />
|
||||
)}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This route adds an API key for the user
|
||||
* @param {object} obj
|
||||
* @param {string} obj.name - name of the API key
|
||||
* @param {string} obj.expiresIn - how soon the API key expires in ms
|
||||
* @returns
|
||||
*/
|
||||
const addAPIKey = ({ name, expiresIn }: Props) =>
|
||||
SecurityClient.fetchCall("/api/v2/api-key/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
expiresIn
|
||||
})
|
||||
}).then(async (res) => {
|
||||
if (res && res.status === 200) {
|
||||
return res.json();
|
||||
}
|
||||
console.log("Failed to add API key");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
export default addAPIKey;
|
||||
@@ -1,27 +0,0 @@
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
|
||||
interface Props {
|
||||
apiKeyId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This route revokes the API key with id [apiKeyId]
|
||||
* @param {object} obj
|
||||
* @param {string} obj.apiKeyId - id of the API key to delete
|
||||
* @returns
|
||||
*/
|
||||
const deleteAPIKey = ({ apiKeyId }: Props) =>
|
||||
SecurityClient.fetchCall(`/api/v2/api-key/${apiKeyId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}).then(async (res) => {
|
||||
if (res && res.status === 200) {
|
||||
return res.json();
|
||||
}
|
||||
console.log("Failed to delete API key");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
export default deleteAPIKey;
|
||||
@@ -1,22 +0,0 @@
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
|
||||
/**
|
||||
* This route gets API keys for the user
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
const getAPIKeys = () =>
|
||||
SecurityClient.fetchCall("/api/v2/api-key", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}).then(async (res) => {
|
||||
if (res && res.status === 200) {
|
||||
return (await res.json()).apiKeyData;
|
||||
}
|
||||
console.log("Failed to get API keys");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
export default getAPIKeys;
|
||||
@@ -1,322 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { faBan,faCheck, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import Button from "@app/components/basic/buttons/Button";
|
||||
import InputField from "@app/components/basic/InputField";
|
||||
import ListBox from "@app/components/basic/Listbox";
|
||||
import ApiKeyTable from "@app/components/basic/table/ApiKeyTable";
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import checkPassword from "@app/components/utilities/checks/checkPassword";
|
||||
import changePassword from "@app/components/utilities/cryptography/changePassword";
|
||||
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
||||
import {
|
||||
useGetCommonPasswords,
|
||||
useRevokeAllSessions} from "@app/hooks/api";
|
||||
import { SecuritySection } from "@app/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection";
|
||||
|
||||
import AddApiKeyDialog from "../../../components/basic/dialog/AddApiKeyDialog";
|
||||
import getAPIKeys from "../../api/apiKey/getAPIKeys";
|
||||
import getUser from "../../api/user/getUser";
|
||||
|
||||
type Errors = {
|
||||
length?: string,
|
||||
upperCase?: string,
|
||||
lowerCase?: string,
|
||||
number?: string,
|
||||
specialChar?: string,
|
||||
repeatedChar?: string,
|
||||
};
|
||||
import { PersonalSettingsPage } from "@app/views/Settings/PersonalSettingsPage";
|
||||
|
||||
export default function PersonalSettings() {
|
||||
const { data: commonPasswords } = useGetCommonPasswords();
|
||||
const [personalEmail, setPersonalEmail] = useState("");
|
||||
const [personalName, setPersonalName] = useState("");
|
||||
const [currentPasswordError, setCurrentPasswordError] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [backupPassword, setBackupPassword] = useState("");
|
||||
const [passwordChanged, setPasswordChanged] = useState(false);
|
||||
const [backupKeyIssued, setBackupKeyIssued] = useState(false);
|
||||
const [backupKeyError, setBackupKeyError] = useState(false);
|
||||
const [isAddApiKeyDialogOpen, setIsAddApiKeyDialogOpen] = useState(false);
|
||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
|
||||
const revokeAllSessions = useRevokeAllSessions();
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const lang = router.locale ?? "en";
|
||||
|
||||
const setLanguage = async (to: string) => {
|
||||
router.push(router.asPath, router.asPath, { locale: to });
|
||||
localStorage.setItem("lang", to);
|
||||
i18n.changeLanguage(to);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const user = await getUser();
|
||||
setApiKeys(await getAPIKeys());
|
||||
setPersonalEmail(user.email);
|
||||
setPersonalName(`${user.firstName} ${user.lastName}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const closeAddApiKeyModal = () => {
|
||||
setIsAddApiKeyDialogOpen(false);
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex max-h-screen flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="bg-bunker-800 text-white h-full">
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("settings.personal.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
<AddApiKeyDialog
|
||||
isOpen={isAddApiKeyDialogOpen}
|
||||
closeModal={closeAddApiKeyModal}
|
||||
apiKeys={apiKeys}
|
||||
setApiKeys={setApiKeys}
|
||||
/>
|
||||
<div className="flex flex-row">
|
||||
<div className="max-h-screen w-full pb-2">
|
||||
<NavHeader pageName={t("settings.personal.title")} isProjectRelated={false} />
|
||||
<div className="ml-6 mt-8 mb-6 flex max-w-5xl flex-row items-center justify-between text-xl">
|
||||
<div className="flex flex-col items-start justify-start text-3xl">
|
||||
<p className="mr-4 font-semibold text-gray-200">{t("settings.personal.title")}</p>
|
||||
<p className="mr-4 text-base font-normal text-gray-400">
|
||||
{t("settings.personal.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6 mr-6 flex max-w-5xl flex-col text-mineshaft-50">
|
||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
|
||||
<p className="self-start text-xl font-semibold">
|
||||
{t("settings.personal.change-language")}
|
||||
</p>
|
||||
<div className="w-ful mt-4 max-h-28">
|
||||
<ListBox
|
||||
isSelected={lang}
|
||||
onChange={setLanguage}
|
||||
data={["en", "ko", "fr", "es"]}
|
||||
text={`${t("common.language")}: `}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SecuritySection />
|
||||
<div className="mt-2 mb-8 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-4">
|
||||
<div className="flex w-full flex-row justify-between">
|
||||
<div className="flex w-full flex-col">
|
||||
<p className="mb-3 text-xl font-semibold">
|
||||
{t("settings.personal.api-keys.title")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("settings.personal.api-keys.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 w-40">
|
||||
<Button
|
||||
text={String(t("settings.personal.api-keys.add-new"))}
|
||||
onButtonPressed={() => {
|
||||
setIsAddApiKeyDialogOpen(true);
|
||||
}}
|
||||
color="mineshaft"
|
||||
icon={faPlus}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ApiKeyTable data={apiKeys} setApiKeys={setApiKeys as any} />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-5 pb-6">
|
||||
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
|
||||
<div className="flex w-full max-w-3xl flex-col justify-between">
|
||||
<p className="mb-3 min-w-max text-xl font-semibold">
|
||||
{t("section.password.change")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full max-w-xl">
|
||||
<InputField
|
||||
label={t("section.password.current") as string}
|
||||
onChangeHandler={(password) => {
|
||||
setCurrentPassword(password);
|
||||
}}
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
isRequired
|
||||
error={currentPasswordError}
|
||||
errorText={t("section.password.current-wrong") as string}
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
/>
|
||||
<div className="py-2" />
|
||||
<InputField
|
||||
label={t("section.password.new") as string}
|
||||
onChangeHandler={(password) => {
|
||||
setNewPassword(password);
|
||||
checkPassword({
|
||||
password,
|
||||
commonPasswords,
|
||||
setErrors
|
||||
});
|
||||
}}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
isRequired
|
||||
error={Object.keys(errors).length > 0}
|
||||
autoComplete="new-password"
|
||||
id="new-password"
|
||||
/>
|
||||
</div>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="mt-4 flex w-full flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
||||
<div className="mb-2 text-sm text-gray-400">{t("section.password.validate-base")}</div>
|
||||
{Object.keys(errors).map((key) => {
|
||||
if (errors[key as keyof Errors]) {
|
||||
return (
|
||||
<div className="ml-1 flex flex-row items-top justify-start" key={key}>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
icon={faXmark}
|
||||
className="text-md text-red ml-0.5 mr-2.5"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{errors[key as keyof Errors]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex w-52 flex-row items-center pr-3">
|
||||
<Button
|
||||
text={t("section.password.change") as string}
|
||||
onButtonPressed={() => {
|
||||
const errorCheck = checkPassword({
|
||||
password: newPassword,
|
||||
commonPasswords,
|
||||
setErrors
|
||||
});
|
||||
if (!errorCheck) {
|
||||
changePassword(
|
||||
personalEmail,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
setCurrentPasswordError,
|
||||
setPasswordChanged,
|
||||
setCurrentPassword,
|
||||
setNewPassword
|
||||
);
|
||||
}
|
||||
}}
|
||||
active={Object.keys(errors).length === 0 && currentPassword !== ""}
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
textDisabled={t("section.password.change") as string}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className={`ml-4 text-3xl text-primary ${
|
||||
passwordChanged ? "opacity-100" : "opacity-0"
|
||||
} duration-300`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-2">
|
||||
<div className="my-4 flex w-full flex-row justify-between">
|
||||
<p className="text-xl font-semibold w-full">
|
||||
Sessions
|
||||
</p>
|
||||
<div className="w-40">
|
||||
<Button
|
||||
text="Revoke all"
|
||||
onButtonPressed={async () => {
|
||||
await revokeAllSessions.mutateAsync();
|
||||
router.push("/login");
|
||||
}}
|
||||
color="mineshaft"
|
||||
icon={faBan}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-5 text-sm text-mineshaft-300">
|
||||
Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your account out all active sessions across all browsers and CLIs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-5 pb-6">
|
||||
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
|
||||
<div className="flex w-full max-w-3xl flex-col justify-between">
|
||||
<p className="mb-3 min-w-max text-xl font-semibold">
|
||||
{t("settings.personal.emergency.name")}
|
||||
</p>
|
||||
<p className="min-w-max text-sm text-mineshaft-300">
|
||||
{t("settings.personal.emergency.text1")}
|
||||
</p>
|
||||
<p className="mb-5 min-w-max text-sm text-mineshaft-300">
|
||||
{t("settings.personal.emergency.text2")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 w-full max-w-xl">
|
||||
<InputField
|
||||
label={t("section.password.current") as string}
|
||||
onChangeHandler={setBackupPassword}
|
||||
type="password"
|
||||
value={backupPassword}
|
||||
isRequired
|
||||
error={backupKeyError}
|
||||
errorText={t("section.password.current-wrong") as string}
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex w-60 flex-row items-center">
|
||||
<Button
|
||||
text={t("settings.personal.emergency.download") as string}
|
||||
onButtonPressed={() => {
|
||||
issueBackupKey({
|
||||
email: personalEmail,
|
||||
password: backupPassword,
|
||||
personalName,
|
||||
setBackupKeyError,
|
||||
setBackupKeyIssued
|
||||
});
|
||||
}}
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
active={backupPassword !== ""}
|
||||
textDisabled={t("settings.personal.emergency.download") as string}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className={`ml-4 text-3xl text-primary ${
|
||||
backupKeyIssued ? "opacity-100" : "opacity-0"
|
||||
} duration-300`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PersonalSettingsPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,9 @@ export default function Users() {
|
||||
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
<NavHeader pageName={t("settings.members.title")} isProjectRelated />
|
||||
<div className="ml-6">
|
||||
<NavHeader pageName={t("settings.members.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4">
|
||||
<p className="mr-4 font-semibold text-white">{t("settings.members.title")}</p>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,7 @@ export const DashboardEnvOverview = () => {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<div className="relative right-5">
|
||||
<div className="relative right-5 ml-4">
|
||||
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -545,7 +545,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
<FormProvider {...method}>
|
||||
<form autoComplete="off" className="h-full">
|
||||
{/* breadcrumb row */}
|
||||
<div className="relative right-6 -top-2 mb-2">
|
||||
<div className="relative right-6 -top-2 mb-2 ml-6">
|
||||
<NavHeader
|
||||
pageName={t("dashboard.title")}
|
||||
currentEnv={
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { useCallback, useState } from "react";
|
||||
import { faCircle, faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useSyntaxHighlight } from "@app/hooks";
|
||||
|
||||
import { useToggle } from "~/hooks/useToggle";
|
||||
|
||||
type Props = {
|
||||
secrets: any[] | undefined;
|
||||
@@ -12,7 +15,8 @@ type Props = {
|
||||
userAvailableEnvs?: any[];
|
||||
};
|
||||
|
||||
const REGEX = /([$]{.*?})/g;
|
||||
const SEC_VAL_LINE_HEIGHT = 21;
|
||||
const MAX_MULTI_LINE = 6;
|
||||
|
||||
const DashboardInput = ({
|
||||
isOverridden,
|
||||
@@ -25,84 +29,60 @@ const DashboardInput = ({
|
||||
isReadOnly?: boolean;
|
||||
secret?: any;
|
||||
}): JSX.Element => {
|
||||
const syntaxHighlight = useCallback((val: string) => {
|
||||
if (val === undefined)
|
||||
return (
|
||||
<span className="cursor-default font-sans text-xs italic text-red-500/80">
|
||||
<FontAwesomeIcon icon={faMinus} className="mt-1" />
|
||||
</span>
|
||||
);
|
||||
if (val?.length === 0)
|
||||
return <span className="w-full font-sans text-bunker-400/80">EMPTY</span>;
|
||||
return val?.split(REGEX).map((word, index) =>
|
||||
word.match(REGEX) !== null ? (
|
||||
<span className="ph-no-capture text-yellow" key={`${val}-${index + 1}`}>
|
||||
{word.slice(0, 2)}
|
||||
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
||||
{word.slice(word.length - 1, word.length) === "}" ? (
|
||||
<span className="ph-no-capture text-yellow">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-no-capture text-yellow-400">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={word} className="ph-no-capture">
|
||||
{word}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
const [isFocused, setIsFocused] = useToggle();
|
||||
const syntaxHighlight = useSyntaxHighlight();
|
||||
|
||||
const value = isOverridden ? secret.valueOverride : secret?.value;
|
||||
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
|
||||
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
|
||||
|
||||
return (
|
||||
<td
|
||||
key={`row-${secret?.key || ""}--`}
|
||||
className={`flex h-10 w-full cursor-default flex-row items-center justify-center ${
|
||||
className={`flex w-full cursor-default flex-row ${
|
||||
!(secret?.value || secret?.value === "") ? "bg-red-800/10" : "bg-mineshaft-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="group relative flex w-full cursor-default flex-col justify-center whitespace-pre">
|
||||
<input
|
||||
value={isOverridden ? secret.valueOverride : secret?.value || ""}
|
||||
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
|
||||
<textarea
|
||||
readOnly={isReadOnly}
|
||||
className={twMerge(
|
||||
"ph-no-capture no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full cursor-default bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-transparent outline-none no-scrollbar",
|
||||
isSecretValueHidden && "text-transparent focus:text-transparent active:text-transparent"
|
||||
)}
|
||||
value={value}
|
||||
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
|
||||
style={{ height: `${maxMultilineHeight}px` }}
|
||||
spellCheck="false"
|
||||
onBlur={() => setIsFocused.off()}
|
||||
onFocus={() => setIsFocused.on()}
|
||||
onInput={(el) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
onScroll={(el) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full cursor-default flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible",
|
||||
isSecretValueHidden && secret?.value ? "invisible" : "visible",
|
||||
isSecretValueHidden &&
|
||||
secret?.value &&
|
||||
"duration-50 text-bunker-800 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400",
|
||||
!secret?.value && "justify-center text-bunker-400"
|
||||
)}
|
||||
>
|
||||
{syntaxHighlight(secret?.value)}
|
||||
</div>
|
||||
{isSecretValueHidden && secret?.value && (
|
||||
<div className="duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden">
|
||||
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
|
||||
{(isOverridden ? secret.valueOverride : secret?.value || "")
|
||||
?.split("")
|
||||
.map((_a: string, index: number) => (
|
||||
<FontAwesomeIcon
|
||||
key={`${secret?.value}_${index + 1}`}
|
||||
className="mr-0.5 text-xxs"
|
||||
icon={faCircle}
|
||||
/>
|
||||
))}
|
||||
{(isOverridden ? secret.valueOverride : secret?.value || "")?.split("").length ===
|
||||
0 && <span className="text-sm text-bunker-400/80">EMPTY</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
<code
|
||||
ref={ref}
|
||||
className={`absolute top-1.5 left-3.5 z-10 overflow-auto font-mono text-sm transition-all no-scrollbar ${
|
||||
isOverridden && "text-primary-300"
|
||||
}`}
|
||||
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
|
||||
>
|
||||
{value === undefined ? (
|
||||
<span className="cursor-default font-sans text-xs italic text-red-500/80">
|
||||
<FontAwesomeIcon icon={faMinus} className="mt-1" />
|
||||
</span>
|
||||
) : (
|
||||
syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
@@ -122,14 +102,14 @@ export const EnvComparisonRow = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className="group flex min-w-full flex-row items-center hover:bg-mineshaft-800">
|
||||
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-bunker-400">
|
||||
<tr className="group flex min-w-full flex-row hover:bg-mineshaft-800">
|
||||
<td className="flex w-10 justify-center border-none px-4">
|
||||
<div className="flex h-8 w-10 items-center justify-center text-center text-xs text-bunker-400">
|
||||
<FontAwesomeIcon icon={faKey} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
|
||||
<div className="flex h-8 cursor-default flex-row items-center truncate">
|
||||
<td className="flex min-w-[200px] flex-row justify-between lg:min-w-[220px] xl:min-w-[250px]">
|
||||
<div className="flex h-8 cursor-default flex-row items-center justify-center truncate">
|
||||
{secrets![0].key || ""}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -193,8 +193,8 @@ export const SecretDetailDrawer = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
||||
<div className="rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||
<div className="font-mono">{value}</div>
|
||||
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||
<div className="break-all font-mono">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { faCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useRef } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useSyntaxHighlight, useToggle } from "@app/hooks";
|
||||
|
||||
import { FormData } from "../../DashboardPage.utils";
|
||||
|
||||
@@ -13,95 +12,94 @@ type Props = {
|
||||
index: number;
|
||||
};
|
||||
|
||||
const REGEX = /([$]{.*?})/g;
|
||||
const SEC_VAL_LINE_HEIGHT = 21;
|
||||
const MAX_MULTI_LINE = 6;
|
||||
|
||||
export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridden }: Props) => {
|
||||
const { register, control } = useFormContext<FormData>();
|
||||
const { control } = useFormContext<FormData>();
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
const [isFocused, setIsFocused] = useToggle();
|
||||
const syntaxHighlight = useSyntaxHighlight();
|
||||
|
||||
const secretValue = useWatch({ control, name: `secrets.${index}.value` });
|
||||
const secretValueOverride = useWatch({ control, name: `secrets.${index}.valueOverride` });
|
||||
const value = isOverridden ? secretValueOverride : secretValue;
|
||||
|
||||
const syntaxHighlight = useCallback((val: string) => {
|
||||
if (val?.length === 0) return <span className="font-sans text-bunker-400/80">EMPTY</span>;
|
||||
return val?.split(REGEX).map((word, i) =>
|
||||
word.match(REGEX) !== null ? (
|
||||
<span className="ph-no-capture text-yellow" key={`${val}-${i + 1}`}>
|
||||
{word.slice(0, 2)}
|
||||
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
||||
{word.slice(word.length - 1, word.length) === "}" ? (
|
||||
<span className="ph-no-capture text-yellow">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-no-capture text-yellow-400">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`${word}_${i + 1}`} className="ph-no-capture">
|
||||
{word}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
|
||||
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
|
||||
|
||||
return (
|
||||
<div className="group relative flex w-full flex-col justify-center whitespace-pre px-1.5">
|
||||
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
|
||||
{isOverridden ? (
|
||||
<input
|
||||
{...register(`secrets.${index}.valueOverride`)}
|
||||
readOnly={isReadOnly}
|
||||
className={twMerge(
|
||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar",
|
||||
!isSecretValueHidden &&
|
||||
"text-transparent focus:text-transparent active:text-transparent"
|
||||
<Controller
|
||||
control={control}
|
||||
name={`secrets.${index}.valueOverride`}
|
||||
render={({ field }) => (
|
||||
<textarea
|
||||
key={`secrets.${index}.valueOverride`}
|
||||
{...field}
|
||||
readOnly={isReadOnly}
|
||||
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
|
||||
style={{ height: `${maxMultilineHeight}px` }}
|
||||
spellCheck="false"
|
||||
onBlur={() => setIsFocused.off()}
|
||||
onFocus={() => setIsFocused.on()}
|
||||
onInput={(el) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
onScroll={(el) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
spellCheck="false"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...register(`secrets.${index}.value`)}
|
||||
readOnly={isReadOnly}
|
||||
className={twMerge(
|
||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar",
|
||||
!isSecretValueHidden &&
|
||||
"text-transparent focus:text-transparent active:text-transparent"
|
||||
<Controller
|
||||
control={control}
|
||||
name={`secrets.${index}.value`}
|
||||
key={`secrets.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<textarea
|
||||
{...field}
|
||||
readOnly={isReadOnly}
|
||||
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
|
||||
style={{ height: `${maxMultilineHeight}px` }}
|
||||
spellCheck="false"
|
||||
onBlur={() => setIsFocused.off()}
|
||||
onFocus={() => setIsFocused.on()}
|
||||
onInput={(el) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
onScroll={(el) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
spellCheck="false"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible",
|
||||
isSecretValueHidden ? "invisible" : "visible",
|
||||
isOverridden
|
||||
? "text-primary-300"
|
||||
: "duration-50 text-gray-400 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400"
|
||||
)}
|
||||
>
|
||||
{syntaxHighlight(value || "")}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden",
|
||||
!isSecretValueHidden ? "invisible" : "visible"
|
||||
)}
|
||||
>
|
||||
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
|
||||
{value?.split("").map((val, i) => (
|
||||
<FontAwesomeIcon
|
||||
key={`${value}_${val}_${i + 1}`}
|
||||
className="mr-0.5 text-xxs"
|
||||
icon={faCircle}
|
||||
/>
|
||||
))}
|
||||
{value?.split("").length === 0 && (
|
||||
<span className="text-sm text-bunker-400/80">EMPTY</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
<code
|
||||
ref={ref}
|
||||
className={`absolute top-1.5 left-3.5 z-10 w-full overflow-auto font-mono text-sm transition-all no-scrollbar ${
|
||||
isOverridden && "text-primary-300"
|
||||
}`}
|
||||
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
|
||||
>
|
||||
{syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -157,7 +157,7 @@ export const SecretInputRow = memo(
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="group flex flex-row items-center" key={index}>
|
||||
<tr className="group flex flex-row" key={index}>
|
||||
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
|
||||
</td>
|
||||
@@ -198,7 +198,7 @@ export const SecretInputRow = memo(
|
||||
</HoverCard>
|
||||
)}
|
||||
/>
|
||||
<td className="flex h-10 w-full flex-grow flex-row items-center justify-center border-r border-none border-red">
|
||||
<td className="flex w-full flex-grow flex-row border-r border-none border-red">
|
||||
<MaskedInput
|
||||
isReadOnly={
|
||||
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
||||
@@ -208,8 +208,8 @@ export const SecretInputRow = memo(
|
||||
index={index}
|
||||
/>
|
||||
</td>
|
||||
<td className="min-w-sm flex h-10 items-center">
|
||||
<div className="flex items-center pl-2">
|
||||
<td className="min-w-sm flex">
|
||||
<div className="flex h-8 items-center pl-2">
|
||||
{secretTags.map(({ id, slug }, i) => (
|
||||
<Tag
|
||||
className={cx(
|
||||
@@ -289,7 +289,7 @@ export const SecretInputRow = memo(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-full flex-row items-center pr-2">
|
||||
<div className="flex h-8 flex-row items-center pr-2">
|
||||
{!isAddOnly && (
|
||||
<div>
|
||||
<Tooltip content="Override with a personal value">
|
||||
@@ -346,35 +346,37 @@ export const SecretInputRow = memo(
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
||||
{!isAddOnly && (
|
||||
<div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all">
|
||||
<div className="flex h-8 items-center space-x-2.5">
|
||||
{!isAddOnly && (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Settings">
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
onClick={onRowExpand}
|
||||
ariaLabel="expand"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Settings">
|
||||
<Tooltip content="Delete">
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="primary"
|
||||
size="md"
|
||||
variant="plain"
|
||||
onClick={onRowExpand}
|
||||
ariaLabel="expand"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
onClick={() => onSecretDelete(index, secId, idOverride)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
onClick={() => onSecretDelete(index, secId, idOverride)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -178,7 +178,9 @@ export const IntegrationsPage = ({ frameworkIntegrations }: Props) => {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-7xl px-8 pb-12 text-white">
|
||||
<NavHeader pageName={t("integrations.title")} isProjectRelated />
|
||||
<div className="ml-4">
|
||||
<NavHeader pageName={t("integrations.title")} isProjectRelated />
|
||||
</div>
|
||||
<IntegrationsSection
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
|
||||
@@ -9,16 +9,13 @@ import {
|
||||
export const BillingSettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="h-full py-8 px-4">
|
||||
<NavHeader pageName={t("billing.title")} />
|
||||
|
||||
<div className="ml-4 flex text-3xl mt-8 items-start max-w-screen-lg">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-200">{t("billing.title")}</p>
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||
<div className="max-w-screen-lg w-full">
|
||||
<NavHeader pageName={t("billing.title")} />
|
||||
<div className="my-8">
|
||||
<p className="text-3xl font-semibold text-gray-200">{t("billing.title")}</p>
|
||||
<div />
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<BillingTabGroup />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const CurrentPlanSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<h2 className="text-xl font-semibold flex-1 text-white mb-8">Current Usage</h2>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const PreviewSection = () => {
|
||||
return (
|
||||
<div>
|
||||
{!isSubscriptionLoading && subscription?.slug !== "enterprise" && subscription?.slug !== "pro" && subscription?.slug !== "pro-annual" && (
|
||||
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mt-8 flex items-center bg-mineshaft-600 max-w-screen-lg">
|
||||
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mb-6 flex items-center bg-mineshaft-600 max-w-screen-lg">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-mineshaft-50">Become Infisical</h2>
|
||||
<p className="text-gray-400 mt-4">Unlimited members, projects, RBAC, smart alerts, and so much more</p>
|
||||
@@ -58,8 +58,8 @@ export const PreviewSection = () => {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && data && subscription && (
|
||||
<div className="flex mt-8 max-w-screen-lg">
|
||||
{!isLoading && subscription && data && (
|
||||
<div className="flex mb-6 max-w-screen-lg">
|
||||
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 mr-4 border border-mineshaft-600">
|
||||
<p className="mb-2 text-gray-400">Current plan</p>
|
||||
<p className="text-2xl mb-8 text-mineshaft-50 font-semibold">
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import Button from "@app/components/basic/buttons/Button";
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { FormControl,Input } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useGetOrgBillingDetails,
|
||||
@@ -26,7 +28,7 @@ export const CompanyNameSection = () => {
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
|
||||
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
|
||||
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -40,7 +42,7 @@ export const CompanyNameSection = () => {
|
||||
try {
|
||||
if (!currentOrg?._id) return;
|
||||
if (name === "") return;
|
||||
await updateOrgBillingDetails.mutateAsync({
|
||||
await mutateAsync({
|
||||
name,
|
||||
organizationId: currentOrg._id
|
||||
});
|
||||
@@ -61,7 +63,7 @@ export const CompanyNameSection = () => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||
Business name
|
||||
@@ -82,15 +84,14 @@ export const CompanyNameSection = () => {
|
||||
name="name"
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-block">
|
||||
<Button
|
||||
text="Save"
|
||||
type="submit"
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
onButtonPressed={() => console.log("Saved company name")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import Button from "@app/components/basic/buttons/Button";
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
@@ -28,7 +28,7 @@ export const InvoiceEmailSection = () => {
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
|
||||
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
|
||||
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -43,7 +43,7 @@ export const InvoiceEmailSection = () => {
|
||||
if (!currentOrg?._id) return;
|
||||
if (email === "") return;
|
||||
|
||||
await updateOrgBillingDetails.mutateAsync({
|
||||
await mutateAsync({
|
||||
email,
|
||||
organizationId: currentOrg._id
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export const InvoiceEmailSection = () => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-white mb-8">
|
||||
Invoice email recipient
|
||||
@@ -85,15 +85,13 @@ export const InvoiceEmailSection = () => {
|
||||
name="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-block">
|
||||
<Button
|
||||
text="Save"
|
||||
type="submit"
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
onButtonPressed={() => console.log("Saved email address")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import Button from "@app/components/basic/buttons/Button";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useAddOrgPmtMethod } from "@app/hooks/api";
|
||||
|
||||
@@ -8,11 +12,11 @@ import { PmtMethodsTable } from "./PmtMethodsTable";
|
||||
|
||||
export const PmtMethodsSection = () => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const addOrgPmtMethod = useAddOrgPmtMethod();
|
||||
const { mutateAsync, isLoading } = useAddOrgPmtMethod();
|
||||
|
||||
const handleAddPmtMethodBtnClick = async () => {
|
||||
if (!currentOrg?._id) return;
|
||||
const url = await addOrgPmtMethod.mutateAsync({
|
||||
const url = await mutateAsync({
|
||||
organizationId: currentOrg._id,
|
||||
success_url: window.location.href,
|
||||
cancel_url: window.location.href
|
||||
@@ -22,21 +26,19 @@ export const PmtMethodsSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="flex items-center mb-8">
|
||||
<h2 className="text-xl font-semibold flex-1 text-white">
|
||||
Payment Methods
|
||||
</h2>
|
||||
<div className="inline-block">
|
||||
<Button
|
||||
text="Add method"
|
||||
type="submit"
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
icon={faPlus}
|
||||
onButtonPressed={handleAddPmtMethodBtnClick}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddPmtMethodBtnClick}
|
||||
colorSchema="secondary"
|
||||
isLoading={isLoading}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add method
|
||||
</Button>
|
||||
</div>
|
||||
<PmtMethodsTable />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import Button from "@app/components/basic/buttons/Button";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button } from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { TaxIDModal } from "./TaxIDModal";
|
||||
@@ -12,21 +14,18 @@ export const TaxIDSection = () => {
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="flex items-center mb-8">
|
||||
<h2 className="text-xl font-semibold flex-1 text-white">
|
||||
Tax ID
|
||||
</h2>
|
||||
<div className="inline-block">
|
||||
<Button
|
||||
text="Add Tax ID"
|
||||
type="submit"
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
icon={faPlus}
|
||||
onButtonPressed={() => handlePopUpOpen("addTaxID")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handlePopUpOpen("addTaxID")}
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add method
|
||||
</Button>
|
||||
</div>
|
||||
<TaxIDTable />
|
||||
<TaxIDModal
|
||||
|
||||
@@ -2,7 +2,7 @@ import { InvoicesTable } from "./InvoicesTable";
|
||||
|
||||
export const BillingReceiptsTab = () => {
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<h2 className="text-xl font-semibold flex-1 text-white">Invoices</h2>
|
||||
<InvoicesTable />
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ const tabs = [
|
||||
export const BillingTabGroup = () => {
|
||||
return (
|
||||
<Tab.Group>
|
||||
<Tab.List className="mt-8 border-b-2 border-mineshaft-800 max-w-screen-lg">
|
||||
<Tab.List className="mt-8 mb-6 border-b-2 border-mineshaft-800 max-w-screen-lg">
|
||||
{tabs.map((tab) => (
|
||||
<Tab as={Fragment} key={tab.key}>
|
||||
{({ selected }) => (
|
||||
|
||||
@@ -1,301 +1,66 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddIncidentContact,
|
||||
useAddUserToOrg,
|
||||
useDeleteIncidentContact,
|
||||
useDeleteOrgMembership,
|
||||
useGetOrgIncidentContact,
|
||||
useGetOrgUsers,
|
||||
useGetUserWorkspaceMemberships,
|
||||
useGetUserWsKey,
|
||||
useRenameOrg,
|
||||
useUpdateOrgUserRole,
|
||||
useUploadWsKey
|
||||
} from "@app/hooks/api";
|
||||
|
||||
import {
|
||||
OrgIncidentContactsTable,
|
||||
OrgMembersTable,
|
||||
OrgIncidentContactsSection,
|
||||
OrgMembersSection,
|
||||
OrgNameChangeSection,
|
||||
OrgServiceAccountsTable
|
||||
} from "./components";
|
||||
|
||||
export const OrgSettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { user } = useUser();
|
||||
const { subscription } = useSubscription();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const orgId = currentOrg?._id || "";
|
||||
|
||||
const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
|
||||
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
|
||||
useGetUserWorkspaceMemberships(orgId);
|
||||
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
|
||||
const { data: incidentContact, isLoading: IsIncidentContactLoading } =
|
||||
useGetOrgIncidentContact(orgId);
|
||||
|
||||
const renameOrg = useRenameOrg();
|
||||
const removeUserOrgMembership = useDeleteOrgMembership();
|
||||
const addUserToOrg = useAddUserToOrg();
|
||||
const updateOrgUserRole = useUpdateOrgUserRole();
|
||||
const uploadWsKey = useUploadWsKey();
|
||||
const addIncidentContact = useAddIncidentContact();
|
||||
const removeIncidentContact = useDeleteIncidentContact();
|
||||
|
||||
const [completeInviteLink, setcompleteInviteLink] = useState<string | undefined>("");
|
||||
|
||||
const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false;
|
||||
|
||||
const onRenameOrg = async (name: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
|
||||
createNotification({
|
||||
text: "Successfully renamed organization",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to rename organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveUserOrgMembership = async (membershipId: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
|
||||
createNotification({
|
||||
text: "Successfully removed user from org",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove user from the organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
const onAddUserToOrg = async (email: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
const { data } = await addUserToOrg.mutateAsync({
|
||||
organizationId: currentOrg?._id,
|
||||
inviteeEmail: email
|
||||
});
|
||||
setcompleteInviteLink(data?.completeInviteLink);
|
||||
|
||||
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
|
||||
if (!data.completeInviteLink) {
|
||||
createNotification({
|
||||
text: "Successfully invited user to the organization.",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to invite user to org",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateOrgUserRole = async (membershipId: string, role: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
|
||||
createNotification({
|
||||
text: "Successfully updated user role",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to update user role",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onGrantUserAccess = async (userId: string, publicKey: string) => {
|
||||
try {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
if (!PRIVATE_KEY || !wsKey) return;
|
||||
|
||||
// assymmetrically decrypt symmetric key with local private key
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: wsKey.encryptedKey,
|
||||
nonce: wsKey.nonce,
|
||||
publicKey: wsKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const { ciphertext, nonce } = encryptAssymmetric({
|
||||
plaintext: key,
|
||||
publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
await uploadWsKey.mutateAsync({
|
||||
userId,
|
||||
nonce,
|
||||
encryptedKey: ciphertext,
|
||||
workspaceId: currentWorkspace?._id || ""
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to grant access to user",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddIncidentContact = async (email: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await addIncidentContact.mutateAsync({ orgId, email });
|
||||
createNotification({
|
||||
text: "Successfully added incident contact",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to add incident contact",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveIncidentContact = async (email: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await removeIncidentContact.mutateAsync({ orgId, email });
|
||||
createNotification({
|
||||
text: "Successfully removed incident contact",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove incident contact",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||
<div className="max-w-screen-lg w-full">
|
||||
<NavHeader pageName={t("settings.org.title")} />
|
||||
<div className="my-8 ml-8 max-w-5xl">
|
||||
<div className="my-8">
|
||||
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
|
||||
<p className="text-base font-normal text-gray-400">{t("settings.org.description")}</p>
|
||||
</div>
|
||||
<div className="max-w-8xl ml-6 mr-6 flex flex-col text-mineshaft-50">
|
||||
<OrgNameChangeSection orgName={currentOrg?.name} onOrgNameChange={onRenameOrg} />
|
||||
<div className="mb-6 w-full rounded-md bg-white/5 p-6">
|
||||
<p className="mr-4 mb-4 text-xl font-semibold text-white">
|
||||
{t("section.members.org-members")}
|
||||
</p>
|
||||
<OrgMembersTable
|
||||
isLoading={isOrgUserLoading || IsWsMembershipLoading}
|
||||
isMoreUserNotAllowed={isMoreUsersNotAllowed}
|
||||
orgName={currentOrg?.name || ""}
|
||||
members={orgUsers}
|
||||
workspaceMemberships={workspaceMemberships}
|
||||
onInviteMember={onAddUserToOrg}
|
||||
userId={user?._id || ""}
|
||||
onRemoveMember={onRemoveUserOrgMembership}
|
||||
onRoleChange={onUpdateOrgUserRole}
|
||||
onGrantAccess={onGrantUserAccess}
|
||||
completeInviteLink={completeInviteLink}
|
||||
setCompleteInviteLink={setcompleteInviteLink}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6 mt-2 w-full rounded-md bg-white/5 p-6">
|
||||
<p className="mr-4 mb-4 text-xl font-semibold text-white">Service Accounts</p>
|
||||
<OrgNameChangeSection />
|
||||
<OrgMembersSection />
|
||||
<OrgIncidentContactsSection />
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<OrgServiceAccountsTable />
|
||||
</div>
|
||||
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
|
||||
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
|
||||
<div className="flex w-full max-w-3xl flex-col justify-between">
|
||||
<p className="mb-3 min-w-max text-xl font-semibold">
|
||||
{t("section.incident.incident-contacts")}
|
||||
</p>
|
||||
<p className="mb-2 min-w-max text-xs text-gray-500">
|
||||
{t("section.incident.incident-contacts-description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<OrgIncidentContactsTable
|
||||
isLoading={IsIncidentContactLoading}
|
||||
contacts={incidentContact}
|
||||
onRemoveContact={onRemoveIncidentContact}
|
||||
onAddContact={onAddIncidentContact}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="border-l border-red pb-4 pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pt-2 max-w-6xl">
|
||||
<p className="text-xl font-bold text-red">
|
||||
Danger Zone
|
||||
</p>
|
||||
<p className="mt-4 text-md text-gray-400">
|
||||
As soon as you delete an organization, you will
|
||||
not be able to undo it. This will immediately
|
||||
remove all organization members and cancel your
|
||||
subscription. If you still want to do that,
|
||||
please enter the name of the organization below.
|
||||
</p>
|
||||
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
|
||||
<InputField
|
||||
label="Organization to be Deleted"
|
||||
onChangeHandler={
|
||||
setWorkspaceToBeDeletedName
|
||||
}
|
||||
type="varName"
|
||||
value={workspaceToBeDeletedName}
|
||||
placeholder=""
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
onClick={executeDeletingWorkspace}
|
||||
>
|
||||
Delete Project
|
||||
</button>
|
||||
<p className="mt-0.5 ml-1 text-xs text-gray-500">
|
||||
Note: You can only delete a project in case you
|
||||
have more than one.
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
<p className="text-xl font-bold text-red">
|
||||
Danger Zone
|
||||
</p>
|
||||
<p className="mt-4 text-md text-gray-400">
|
||||
As soon as you delete an organization, you will
|
||||
not be able to undo it. This will immediately
|
||||
remove all organization members and cancel your
|
||||
subscription. If you still want to do that,
|
||||
please enter the name of the organization below.
|
||||
</p>
|
||||
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
|
||||
<InputField
|
||||
label="Organization to be Deleted"
|
||||
onChangeHandler={
|
||||
setWorkspaceToBeDeletedName
|
||||
}
|
||||
type="varName"
|
||||
value={workspaceToBeDeletedName}
|
||||
placeholder=""
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
onClick={executeDeletingWorkspace}
|
||||
>
|
||||
Delete Project
|
||||
</button>
|
||||
<p className="mt-0.5 ml-1 text-xs text-gray-500">
|
||||
Note: You can only delete a project in case you
|
||||
have more than one.
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useAddIncidentContact
|
||||
} from "@app/hooks/api";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const addContactFormSchema = yup.object({
|
||||
email: yup.string().email().required().label("Email").trim()
|
||||
});
|
||||
|
||||
type TAddContactForm = yup.InferType<typeof addContactFormSchema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["addContact"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["addContact"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addContact"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const AddOrgIncidentContactModal = ({
|
||||
popUp,
|
||||
handlePopUpClose,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { data: serverDetails } = useFetchServerStatus()
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset
|
||||
} = useForm<TAddContactForm>({ resolver: yupResolver(addContactFormSchema) });
|
||||
|
||||
const { mutateAsync, isLoading } = useAddIncidentContact();
|
||||
|
||||
const onFormSubmit = async ({ email }: TAddContactForm) => {
|
||||
try {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
await mutateAsync({
|
||||
orgId: currentOrg._id,
|
||||
email
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully added incident contact",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (serverDetails?.emailConfigured){
|
||||
handlePopUpClose("addContact");
|
||||
}
|
||||
|
||||
reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to add incident contact",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addContact?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addContact", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Add an Incident Contact"
|
||||
subTitle="This contact will be notified in the unlikely event of a severe incident."
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Add Incident Contact
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("addContact")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
|
||||
import { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal";
|
||||
import { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
||||
|
||||
export const OrgIncidentContactsSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addContact"
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="flex justify-between mb-8">
|
||||
<p className="min-w-max text-xl font-semibold">
|
||||
{t("section.incident.incident-contacts")}
|
||||
</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addContact")}
|
||||
>
|
||||
Add contact
|
||||
</Button>
|
||||
</div>
|
||||
<OrgIncidentContactsTable />
|
||||
<AddOrgIncidentContactModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faContactBook,
|
||||
faMagnifyingGlass,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useDeleteIncidentContact,
|
||||
useGetOrgIncidentContact} from "@app/hooks/api";
|
||||
|
||||
export const OrgIncidentContactsTable = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { data: contacts, isLoading } = useGetOrgIncidentContact(currentOrg?._id ?? "");
|
||||
const [searchContact, setSearchContact] = useState("");
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"removeContact",
|
||||
"setUpEmail"
|
||||
] as const);
|
||||
const { mutateAsync } = useDeleteIncidentContact();
|
||||
|
||||
const onRemoveIncidentContact = async () => {
|
||||
try {
|
||||
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
|
||||
|
||||
if (!currentOrg?._id) return;
|
||||
await mutateAsync({
|
||||
orgId: currentOrg._id,
|
||||
email: incidentContactEmail
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed incident contact",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("removeContact");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to remove incident contact",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredContacts = contacts ? contacts.filter(({ email }) =>
|
||||
email.toLocaleLowerCase().includes(searchContact)
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={searchContact}
|
||||
onChange={(e) => setSearchContact(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search incident contact by email..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Email</Th>
|
||||
<Th aria-label="actions" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={2} key="incident-contact" />}
|
||||
{filteredContacts?.map(({ email }) => (
|
||||
<Tr key={email}>
|
||||
<Td className="w-full">{email}</Td>
|
||||
<Td className="mr-4">
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
colorSchema="danger"
|
||||
onClick={() => handlePopUpOpen("removeContact", { email })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{filteredContacts?.length === 0 && !isLoading && (
|
||||
<EmptyState title="No incident contacts found" icon={faContactBook} />
|
||||
)}
|
||||
</TableContainer>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeContact.isOpen}
|
||||
deleteKey="remove"
|
||||
title="Do you want to remove this email from incident contact?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeContact", isOpen)}
|
||||
onDeleteApproved={onRemoveIncidentContact}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal"
|
||||
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faContactBook,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmailServiceSetupModal,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import { IncidentContact } from "@app/hooks/api/types";
|
||||
|
||||
type Props = {
|
||||
isLoading?: boolean;
|
||||
contacts?: IncidentContact[];
|
||||
onRemoveContact: (email: string) => Promise<void>;
|
||||
onAddContact: (email: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const addContactFormSchema = yup.object({
|
||||
email: yup.string().email().required().label("Email").trim()
|
||||
});
|
||||
|
||||
type TAddContactForm = yup.InferType<typeof addContactFormSchema>;
|
||||
|
||||
export const OrgIncidentContactsTable = ({
|
||||
contacts = [],
|
||||
onAddContact,
|
||||
onRemoveContact,
|
||||
isLoading
|
||||
}: Props) => {
|
||||
const [searchContact, setSearchContact] = useState("");
|
||||
const {data: serverDetails } = useFetchServerStatus()
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addContact",
|
||||
"removeContact",
|
||||
"setUpEmail"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TAddContactForm>({ resolver: yupResolver(addContactFormSchema) });
|
||||
|
||||
const onAddIncidentContact = ({ email }: TAddContactForm) => {
|
||||
onAddContact(email);
|
||||
handlePopUpClose("addContact");
|
||||
reset();
|
||||
};
|
||||
|
||||
const onRemoveIncidentContact = async () => {
|
||||
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
|
||||
await onRemoveContact(incidentContactEmail);
|
||||
handlePopUpClose("removeContact");
|
||||
};
|
||||
|
||||
const filteredContacts = contacts.filter(({ email }) =>
|
||||
email.toLocaleLowerCase().includes(searchContact)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex">
|
||||
<div className="mr-4 flex-1">
|
||||
<Input
|
||||
value={searchContact}
|
||||
onChange={(e) => setSearchContact(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search incident contact by email..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (serverDetails?.emailConfigured){
|
||||
handlePopUpOpen("addContact");
|
||||
} else {
|
||||
handlePopUpOpen("setUpEmail");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Contact
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Email</Th>
|
||||
<Th aria-label="actions" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={2} key="incident-contact" />}
|
||||
{filteredContacts?.map(({ email }) => (
|
||||
<Tr key={email}>
|
||||
<Td className="w-full">{email}</Td>
|
||||
<Td className="mr-4">
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
colorSchema="danger"
|
||||
onClick={() => handlePopUpOpen("removeContact", { email })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{filteredContacts?.length === 0 && !isLoading && (
|
||||
<EmptyState title="No incident contacts found" icon={faContactBook} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={popUp?.addContact?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addContact", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Add an Incident Contact"
|
||||
subTitle="This contact will be notified in the unlikely event of a severe incident."
|
||||
>
|
||||
<form onSubmit={handleSubmit(onAddIncidentContact)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Add Incident Contact
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("addContact")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeContact.isOpen}
|
||||
deleteKey="remove"
|
||||
title="Do you want to remove this email from incident contact?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeContact", isOpen)}
|
||||
onDeleteApproved={onRemoveIncidentContact}
|
||||
/>
|
||||
<EmailServiceSetupModal
|
||||
isOpen={popUp.setUpEmail?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
useAddUserToOrg
|
||||
} from "@app/hooks/api";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const addMemberFormSchema = yup.object({
|
||||
email: yup.string().email().required().label("Email").trim()
|
||||
});
|
||||
|
||||
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>; // TODO: change to FormData
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["addMember"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["addMember"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
// TODO: test no-SMTP setup case
|
||||
|
||||
export const AddOrgMemberModal = ({
|
||||
popUp,
|
||||
handlePopUpToggle,
|
||||
handlePopUpClose
|
||||
}: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||
const [completeInviteLink, setCompleteInviteLink] = useState<string | undefined>("");
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset
|
||||
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
||||
|
||||
const { mutateAsync, isLoading } = useAddUserToOrg();
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isInviteLinkCopied) {
|
||||
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInviteLinkCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText(completeInviteLink as string);
|
||||
setInviteLinkCopied.on();
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ email }: TAddMemberForm) => {
|
||||
try {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
const { data } = await mutateAsync({
|
||||
organizationId: currentOrg?._id,
|
||||
inviteeEmail: email
|
||||
});
|
||||
|
||||
setCompleteInviteLink(data?.completeInviteLink);
|
||||
|
||||
if (!data.completeInviteLink) {
|
||||
createNotification({
|
||||
text: "Successfully sent an invite to the user.",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
|
||||
if (serverDetails?.emailConfigured){
|
||||
handlePopUpClose("addMember");
|
||||
}
|
||||
|
||||
reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to send an invite to the user",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addMember?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addMember", isOpen);
|
||||
setCompleteInviteLink(undefined);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title={`Invite others to ${currentOrg?.name ?? ""}`}
|
||||
subTitle={
|
||||
<div>
|
||||
{!completeInviteLink && <div>
|
||||
An invite is specific to an email address and expires after 1 day.
|
||||
<br />
|
||||
For security reasons, you will need to separately add members to projects.
|
||||
</div>}
|
||||
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!completeInviteLink && (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} >
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("addMember")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{completeInviteLink && (
|
||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{completeInviteLink}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
|
||||
import { AddOrgMemberModal } from "./AddOrgMemberModal";
|
||||
import { OrgMembersTable } from "./OrgMembersTable";
|
||||
|
||||
export const OrgMembersSection = () => {
|
||||
const { subscription } = useSubscription();
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addMember",
|
||||
"upgradePlan",
|
||||
] as const);
|
||||
|
||||
const isMoreMembersAllowed = subscription?.memberLimit ? (subscription.membersUsed < subscription.memberLimit) : true;
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="flex justify-between mb-8">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">
|
||||
Organization members
|
||||
</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (isMoreMembersAllowed) {
|
||||
handlePopUpOpen("addMember");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
<OrgMembersTable />
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="Add more members by upgrading to a higher Infisical plan."
|
||||
/>
|
||||
<AddOrgMemberModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { faCheck, faCopy, faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmailServiceSetupModal, EmptyState,
|
||||
FormControl,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
@@ -25,73 +24,63 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr,
|
||||
UpgradePlanModal} from "@app/components/v2";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useUser, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useAddUserToOrg,
|
||||
useDeleteOrgMembership,
|
||||
useGetOrgUsers,
|
||||
useGetUserWorkspaceMemberships,
|
||||
useGetUserWsKey,
|
||||
useUpdateOrgUserRole,
|
||||
useUploadWsKey
|
||||
} from "@app/hooks/api";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import { OrgUser, Workspace } from "@app/hooks/api/types";
|
||||
|
||||
type Props = {
|
||||
members?: OrgUser[];
|
||||
workspaceMemberships?: Record<string, Workspace[]>;
|
||||
orgName: string;
|
||||
isLoading?: boolean;
|
||||
isMoreUserNotAllowed: boolean;
|
||||
onRemoveMember: (userId: string) => Promise<void>;
|
||||
onInviteMember: (email: string) => Promise<void>;
|
||||
onRoleChange: (membershipId: string, role: string) => Promise<void>;
|
||||
onGrantAccess: (userId: string, publicKey: string) => Promise<void>;
|
||||
// the current user id to block remove org button
|
||||
userId: string;
|
||||
completeInviteLink: string | undefined,
|
||||
setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>>
|
||||
};
|
||||
|
||||
const addMemberFormSchema = yup.object({
|
||||
email: yup.string().email().required().label("Email").trim()
|
||||
});
|
||||
|
||||
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
|
||||
|
||||
export const OrgMembersTable = ({
|
||||
members = [],
|
||||
workspaceMemberships = {},
|
||||
orgName,
|
||||
isMoreUserNotAllowed,
|
||||
onRemoveMember,
|
||||
onInviteMember,
|
||||
onGrantAccess,
|
||||
onRoleChange,
|
||||
userId,
|
||||
isLoading,
|
||||
completeInviteLink,
|
||||
setCompleteInviteLink
|
||||
}: Props) => {
|
||||
export const OrgMembersTable = () => {
|
||||
const router = useRouter();
|
||||
const { user: currentUser } = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
const {data: serverDetails } = useFetchServerStatus()
|
||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||
|
||||
const { data: serverDetails } = useFetchServerStatus()
|
||||
const { data: members, isLoading: isOrgUserLoading } = useGetOrgUsers(currentOrg?._id ?? ""); // members
|
||||
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = useGetUserWorkspaceMemberships(currentOrg?._id ?? "");
|
||||
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
|
||||
|
||||
const uploadWsKey = useUploadWsKey();
|
||||
const addUserToOrg = useAddUserToOrg();
|
||||
const removeUserOrgMembership = useDeleteOrgMembership();
|
||||
const updateOrgUserRole = useUpdateOrgUserRole();
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addMember",
|
||||
"removeMember",
|
||||
"upgradePlan",
|
||||
"setUpEmail"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
||||
const isLoading = isOrgUserLoading || IsWsMembershipLoading;
|
||||
const userId = currentUser?._id || "";
|
||||
const onRemoveMember = async (membershipId: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
const onAddMember = async ({ email }: TAddMemberForm) => {
|
||||
await onInviteMember(email);
|
||||
if (serverDetails?.emailConfigured){
|
||||
handlePopUpClose("addMember");
|
||||
}
|
||||
|
||||
reset();
|
||||
try {
|
||||
if (!currentOrg?._id) return;
|
||||
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
|
||||
createNotification({
|
||||
text: "Successfully removed user from org",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove user from the organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveOrgMemberApproved = async () => {
|
||||
@@ -100,62 +89,111 @@ export const OrgMembersTable = ({
|
||||
handlePopUpClose("removeMember");
|
||||
};
|
||||
|
||||
|
||||
const onInviteMember = async (email: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
const { data } = await addUserToOrg.mutateAsync({
|
||||
organizationId: currentOrg?._id,
|
||||
inviteeEmail: email
|
||||
});
|
||||
|
||||
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
|
||||
if (!data.completeInviteLink) {
|
||||
createNotification({
|
||||
text: "Successfully invited user to the organization.",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to invite user to org",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onGrantAccess = async (targetUserId: string, publicKey: string) => {
|
||||
try {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
if (!PRIVATE_KEY || !wsKey) return;
|
||||
|
||||
// assymmetrically decrypt symmetric key with local private key
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: wsKey.encryptedKey,
|
||||
nonce: wsKey.nonce,
|
||||
publicKey: wsKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const { ciphertext, nonce } = encryptAssymmetric({
|
||||
plaintext: key,
|
||||
publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
await uploadWsKey.mutateAsync({
|
||||
userId: targetUserId,
|
||||
nonce,
|
||||
encryptedKey: ciphertext,
|
||||
workspaceId: currentWorkspace?._id || ""
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to grant access to user",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRoleChange = async (membershipId: string, role: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
|
||||
createNotification({
|
||||
text: "Successfully updated user role",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to update user role",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isIamOwner = useMemo(
|
||||
() => members.find(({ user }) => userId === user?._id)?.role === "owner",
|
||||
() => members ? members.find(({ user }) => userId === user?._id)?.role === "owner" : [],
|
||||
[userId, members]
|
||||
);
|
||||
|
||||
const filterdUser = useMemo(
|
||||
() =>
|
||||
members.filter(
|
||||
members ? members.filter(
|
||||
({ user, inviteEmail }) =>
|
||||
user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
|
||||
user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
|
||||
user?.email?.toLowerCase().includes(searchMemberFilter) ||
|
||||
inviteEmail?.includes(searchMemberFilter)
|
||||
),
|
||||
) : [],
|
||||
[members, searchMemberFilter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isInviteLinkCopied) {
|
||||
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInviteLinkCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText(completeInviteLink as string);
|
||||
setInviteLinkCopied.on();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex">
|
||||
<div className="mr-4 flex-1">
|
||||
<Input
|
||||
<Input
|
||||
value={searchMemberFilter}
|
||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (isMoreUserNotAllowed) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
} else {
|
||||
handlePopUpOpen("addMember");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<TableContainer>
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
@@ -167,7 +205,7 @@ export const OrgMembersTable = ({
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={5} key="org-members" />}
|
||||
{isLoading && <TableSkeleton columns={5} key="org-memberships" />}
|
||||
{!isLoading &&
|
||||
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
|
||||
const name = user ? `${user.firstName} ${user.lastName}` : "-";
|
||||
@@ -213,7 +251,7 @@ export const OrgMembersTable = ({
|
||||
<Td>
|
||||
{userWs ? (
|
||||
userWs?.map(({ name: wsName, _id }) => (
|
||||
<Tag key={`user-${user._id}-workspace-${_id}`} className="my-1">
|
||||
<Tag key={`user-${currentUser._id}-workspace-${_id}`} className="my-1">
|
||||
{wsName}
|
||||
</Tag>
|
||||
))
|
||||
@@ -254,74 +292,6 @@ export const OrgMembersTable = ({
|
||||
<EmptyState title="No project members found" icon={faUsers} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={popUp?.addMember?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addMember", isOpen);
|
||||
setCompleteInviteLink(undefined)
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title={`Invite others to ${orgName}`}
|
||||
subTitle={
|
||||
<div>
|
||||
{!completeInviteLink && <div>
|
||||
An invite is specific to an email address and expires after 1 day.
|
||||
<br />
|
||||
For security reasons, you will need to separately add members to projects.
|
||||
</div>}
|
||||
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!completeInviteLink && <form onSubmit={handleSubmit(onAddMember)} >
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("addMember")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>}
|
||||
{
|
||||
completeInviteLink &&
|
||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{completeInviteLink}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
deleteKey="remove"
|
||||
@@ -329,15 +299,6 @@ export const OrgMembersTable = ({
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
|
||||
onDeleteApproved={onRemoveOrgMemberApproved}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add custom environments if you switch to Infisical's Team plan."
|
||||
/>
|
||||
<EmailServiceSetupModal
|
||||
isOpen={popUp.setUpEmail?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
@@ -1 +0,0 @@
|
||||
export { OrgMembersTable } from "./OrgMembersTable";
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
orgName?: string;
|
||||
onOrgNameChange: (name: string) => Promise<void>;
|
||||
};
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useRenameOrg } from "@app/hooks/api";
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup.string().required().label("Project Name")
|
||||
@@ -19,33 +14,55 @@ const formSchema = yup.object({
|
||||
|
||||
type FormData = yup.InferType<typeof formSchema>;
|
||||
|
||||
export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.Element => {
|
||||
export const OrgNameChangeSection = (): JSX.Element => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { isDirty, isSubmitting }
|
||||
reset
|
||||
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync, isLoading } = useRenameOrg();
|
||||
|
||||
useEffect(() => {
|
||||
reset({ name: orgName });
|
||||
}, [orgName]);
|
||||
if (currentOrg) {
|
||||
reset({ name: currentOrg.name });
|
||||
}
|
||||
}, [currentOrg]);
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
await onOrgNameChange(name);
|
||||
try {
|
||||
if (!currentOrg?._id) return;
|
||||
if (name === "") return;
|
||||
|
||||
await mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
|
||||
createNotification({
|
||||
text: "Successfully renamed organization",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to rename organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-3">
|
||||
<p className="mb-4 mt-2 text-xl font-semibold">{t("common.display-name")}</p>
|
||||
<div className="mb-2 w-full max-w-lg">
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||
Organization name
|
||||
</p>
|
||||
<div className="mb-2 max-w-md">
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input placeholder="Type your org name" {...field} />
|
||||
<Input placeholder="Acme Corp" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
@@ -53,17 +70,13 @@ export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.E
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
isLoading={isLoading}
|
||||
color="primary"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
>
|
||||
{t("common.save-changes")}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useMemo,useState } from "react";
|
||||
import { Controller,useForm } from "react-hook-form";
|
||||
import {
|
||||
// Controller,
|
||||
// useForm
|
||||
} from "react-hook-form";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faCheck,
|
||||
@@ -10,21 +13,21 @@ import {
|
||||
faServer,
|
||||
faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
|
||||
// import { yupResolver } from "@hookform/resolvers/yup";
|
||||
// import * as yup from "yup";
|
||||
// import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
// FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
// Select,
|
||||
// SelectItem,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -37,25 +40,26 @@ import {
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import {
|
||||
useCreateServiceAccount,
|
||||
// useCreateServiceAccount,
|
||||
useDeleteServiceAccount,
|
||||
useGetServiceAccounts} from "@app/hooks/api";
|
||||
useGetServiceAccounts
|
||||
} from "@app/hooks/api";
|
||||
|
||||
const serviceAccountExpiration = [
|
||||
{ label: "1 Day", value: 86400 },
|
||||
{ label: "7 Days", value: 604800 },
|
||||
{ label: "1 Month", value: 2592000 },
|
||||
{ label: "6 months", value: 15552000 },
|
||||
{ label: "12 months", value: 31104000 },
|
||||
{ label: "Never", value: -1 }
|
||||
];
|
||||
// const serviceAccountExpiration = [
|
||||
// { label: "1 Day", value: 86400 },
|
||||
// { label: "7 Days", value: 604800 },
|
||||
// { label: "1 Month", value: 2592000 },
|
||||
// { label: "6 months", value: 15552000 },
|
||||
// { label: "12 months", value: 31104000 },
|
||||
// { label: "Never", value: -1 }
|
||||
// ];
|
||||
|
||||
const addServiceAccountFormSchema = yup.object({
|
||||
name: yup.string().required().label("Name").trim(),
|
||||
expiresIn: yup.string().required().label("Service Account Expiration")
|
||||
});
|
||||
// const addServiceAccountFormSchema = yup.object({
|
||||
// name: yup.string().required().label("Name").trim(),
|
||||
// expiresIn: yup.string().required().label("Service Account Expiration")
|
||||
// });
|
||||
|
||||
type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
|
||||
// type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
|
||||
|
||||
export const OrgServiceAccountsTable = () => {
|
||||
const router = useRouter();
|
||||
@@ -67,9 +71,9 @@ export const OrgServiceAccountsTable = () => {
|
||||
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
|
||||
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
|
||||
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
|
||||
const [accessKey, setAccessKey] = useState("");
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [accessKey] = useState("");
|
||||
const [publicKey] = useState("");
|
||||
const [privateKey] = useState("");
|
||||
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addServiceAccount",
|
||||
@@ -78,7 +82,7 @@ export const OrgServiceAccountsTable = () => {
|
||||
|
||||
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId);
|
||||
|
||||
const createServiceAccount = useCreateServiceAccount();
|
||||
// const createServiceAccount = useCreateServiceAccount();
|
||||
const removeServiceAccount = useDeleteServiceAccount();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,32 +102,32 @@ export const OrgServiceAccountsTable = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
|
||||
// const {
|
||||
// control,
|
||||
// handleSubmit,
|
||||
// reset,
|
||||
// formState: { isSubmitting }
|
||||
// } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
|
||||
|
||||
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
|
||||
if (!currentOrg?._id) return;
|
||||
// const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
|
||||
// if (!currentOrg?._id) return;
|
||||
|
||||
const keyPair = generateKeyPair();
|
||||
setPublicKey(keyPair.publicKey);
|
||||
setPrivateKey(keyPair.privateKey);
|
||||
// const keyPair = generateKeyPair();
|
||||
// setPublicKey(keyPair.publicKey);
|
||||
// setPrivateKey(keyPair.privateKey);
|
||||
|
||||
const serviceAccountDetails = await createServiceAccount.mutateAsync({
|
||||
name,
|
||||
organizationId: currentOrg?._id,
|
||||
publicKey: keyPair.publicKey,
|
||||
expiresIn: Number(expiresIn)
|
||||
});
|
||||
// const serviceAccountDetails = await createServiceAccount.mutateAsync({
|
||||
// name,
|
||||
// organizationId: currentOrg?._id,
|
||||
// publicKey: keyPair.publicKey,
|
||||
// expiresIn: Number(expiresIn)
|
||||
// });
|
||||
|
||||
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
|
||||
// setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
|
||||
|
||||
setStep(1);
|
||||
reset();
|
||||
}
|
||||
// setStep(1);
|
||||
// reset();
|
||||
// }
|
||||
|
||||
const onRemoveServiceAccount = async () => {
|
||||
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
|
||||
@@ -144,63 +148,67 @@ export const OrgServiceAccountsTable = () => {
|
||||
switch (stepToRender) {
|
||||
case 0:
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onAddServiceAccount)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresIn"
|
||||
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
label="Expiration"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{serviceAccountExpiration.map(({ label, value }) => (
|
||||
<SelectItem value={String(value)} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("addServiceAccount")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
We are currently revising the service account mechanism. In the meantime,
|
||||
please use service tokens or API key to fetch secrets via API request.
|
||||
</div>
|
||||
// <form onSubmit={handleSubmit(onAddServiceAccount)}>
|
||||
// <Controller
|
||||
// control={control}
|
||||
// defaultValue=""
|
||||
// name="name"
|
||||
// render={({ field, fieldState: { error } }) => (
|
||||
// <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
// <Input {...field} />
|
||||
// </FormControl>
|
||||
// )}
|
||||
// />
|
||||
// <Controller
|
||||
// control={control}
|
||||
// name="expiresIn"
|
||||
// defaultValue={String(serviceAccountExpiration?.[0]?.value)}
|
||||
// render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
// return (
|
||||
// <FormControl
|
||||
// label="Expiration"
|
||||
// errorText={error?.message}
|
||||
// isError={Boolean(error)}
|
||||
// >
|
||||
// <Select
|
||||
// defaultValue={field.value}
|
||||
// {...field}
|
||||
// onValueChange={(e) => onChange(e)}
|
||||
// className="w-full"
|
||||
// >
|
||||
// {serviceAccountExpiration.map(({ label, value }) => (
|
||||
// <SelectItem value={String(value)} key={label}>
|
||||
// {label}
|
||||
// </SelectItem>
|
||||
// ))}
|
||||
// </Select>
|
||||
// </FormControl>
|
||||
// );
|
||||
// }}
|
||||
// />
|
||||
// <div className="mt-8 flex items-center">
|
||||
// <Button
|
||||
// className="mr-4"
|
||||
// size="sm"
|
||||
// type="submit"
|
||||
// isLoading={isSubmitting}
|
||||
// isDisabled={isSubmitting}
|
||||
// >
|
||||
// Create Service Account
|
||||
// </Button>
|
||||
// <Button
|
||||
// colorSchema="secondary"
|
||||
// variant="plain"
|
||||
// onClick={() => handlePopUpClose("addServiceAccount")}
|
||||
// >
|
||||
// Cancel
|
||||
// </Button>
|
||||
// </div>
|
||||
// </form>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
@@ -269,27 +277,27 @@ export const OrgServiceAccountsTable = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex">
|
||||
<div className="mr-4 flex-1">
|
||||
<Input
|
||||
value={searchServiceAccountFilter}
|
||||
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search service accounts..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mb-8">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
setStep(0);
|
||||
reset();
|
||||
// reset();
|
||||
handlePopUpOpen("addServiceAccount");
|
||||
}}
|
||||
>
|
||||
Add Service Account
|
||||
</Button>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Input
|
||||
value={searchServiceAccountFilter}
|
||||
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search service accounts..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Th>Name</Th>
|
||||
@@ -345,7 +353,7 @@ export const OrgServiceAccountsTable = () => {
|
||||
isOpen={popUp?.addServiceAccount?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addServiceAccount", isOpen);
|
||||
reset();
|
||||
// reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
||||
export { OrgMembersTable } from "./OrgMembersTable";
|
||||
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
||||
|
||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button } from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddAPIKeyModal } from "./AddAPIKeyModal";
|
||||
import { APIKeyTable } from "./APIKeyTable";
|
||||
|
||||
export const APIKeySection = () => {
|
||||
const { t } = useTranslation();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"addAPIKey"
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="flex justify-between mb-8">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">
|
||||
{t("settings.personal.api-keys.title")}
|
||||
</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addAPIKey")}
|
||||
>
|
||||
Add API Key
|
||||
</Button>
|
||||
</div>
|
||||
<APIKeyTable />
|
||||
<AddAPIKeyModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { faKey,faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
useDeleteAPIKey,
|
||||
useGetMyAPIKeys} from "@app/hooks/api";
|
||||
|
||||
export const APIKeyTable = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { data, isLoading } = useGetMyAPIKeys();
|
||||
const { mutateAsync } = useDeleteAPIKey();
|
||||
|
||||
const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => {
|
||||
try {
|
||||
await mutateAsync(apiKeyDataId);
|
||||
createNotification({
|
||||
text: "Successfully deleted API key",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete API key",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateToFormat: string) => {
|
||||
const date = new Date(dateToFormat);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
const formattedDate = `${day}/${month}/${year}`;
|
||||
|
||||
return formattedDate;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer className="">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="flex-1">Name</Th>
|
||||
<Th className="flex-1">Last active</Th>
|
||||
<Th className="flex-1">Created</Th>
|
||||
<Th className="flex-1">Expiration</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} key="api-keys" />}
|
||||
{!isLoading && data && data.length > 0 && data.map(({
|
||||
_id,
|
||||
name,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
lastUsed
|
||||
}) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`api-key-${_id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>{formatDate(lastUsed)}</Td>
|
||||
<Td>{formatDate(createdAt)}</Td>
|
||||
<Td>{formatDate(expiresAt)}</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await handleDeleteAPIKeyDataClick(_id);
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<EmptyState
|
||||
title="No API Keys on file"
|
||||
icon={faKey}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useCreateAPIKey } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const expirations = [
|
||||
{ label: "1 day", value: "1d" },
|
||||
{ label: "7 days", value: "7d" },
|
||||
{ label: "1 month", value: "1mo" },
|
||||
{ label: "6 months", value: "6mo" },
|
||||
{ label: "12 months", value: "12mo" }
|
||||
];
|
||||
|
||||
const expirationMapping: { [key: string]: number } = {
|
||||
"1d": 86400,
|
||||
"7d": 604800,
|
||||
"1mo": 2592000,
|
||||
"6mo": 15552000,
|
||||
"12mo": 31104000
|
||||
}
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required("API Key name is required"),
|
||||
expiresIn: yup.string().required("API Key expiration window is required")
|
||||
}).required();
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["addAPIKey"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addAPIKey"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const AddAPIKeyModal = ({
|
||||
popUp,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const [newAPIKey, setNewAPIKey] = useState("");
|
||||
const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false);
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { mutateAsync, isLoading } = useCreateAPIKey();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isAPIKeyCopied) {
|
||||
timer = setTimeout(() => setIsAPIKeyCopied.off(), 2000);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [setIsAPIKeyCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText(newAPIKey);
|
||||
setIsAPIKeyCopied.on();
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ name, expiresIn }: FormData) => {
|
||||
try {
|
||||
const { apiKey } = await mutateAsync({
|
||||
name,
|
||||
expiresIn: expirationMapping[expiresIn]
|
||||
});
|
||||
|
||||
setNewAPIKey(apiKey);
|
||||
|
||||
createNotification({
|
||||
text: "Successfully created API key",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to create API key",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasAPIKey = Boolean(newAPIKey);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addAPIKey?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addAPIKey", isOpen);
|
||||
reset();
|
||||
setNewAPIKey("");
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Create API Key">
|
||||
{!hasAPIKey ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="My API Key"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresIn"
|
||||
defaultValue="6mo"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Expiration"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{expirations.map(({ label, value }) => (
|
||||
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{newAPIKey}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isAPIKeyCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||
Click to copy
|
||||
</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export { APIKeySection } from "./APIKeySection";
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import ListBox from "@app/components/basic/Listbox";
|
||||
|
||||
export const ChangeLanguageSection = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const lang = router.locale ?? "en";
|
||||
|
||||
const setLanguage = async (to: string) => {
|
||||
router.push(router.asPath, router.asPath, { locale: to });
|
||||
localStorage.setItem("lang", to);
|
||||
i18n.changeLanguage(to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||
{t("settings.personal.change-language")}
|
||||
</p>
|
||||
<div className="w-28">
|
||||
<ListBox
|
||||
isSelected={lang}
|
||||
onChange={setLanguage}
|
||||
data={["en", "ko", "fr", "es"]}
|
||||
text={`${t("common.language")}: `}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangeLanguageSection } from "./ChangeLanguageSection";
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import attemptChangePassword from "@app/components/utilities/attemptChangePassword";
|
||||
import checkPassword from "@app/components/utilities/checks/checkPassword";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input
|
||||
} from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import { useGetCommonPasswords } from "@app/hooks/api";
|
||||
|
||||
type Errors = {
|
||||
length?: string,
|
||||
upperCase?: string,
|
||||
lowerCase?: string,
|
||||
number?: string,
|
||||
specialChar?: string,
|
||||
repeatedChar?: string,
|
||||
};
|
||||
|
||||
const schema = yup.object({
|
||||
oldPassword: yup.string().required("Old password is required"),
|
||||
newPassword: yup.string().required("New password is required")
|
||||
}).required();
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
export const ChangePasswordSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { user } = useUser();
|
||||
const { data: commonPasswords } = useGetCommonPasswords();
|
||||
const { reset, control, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
oldPassword: "",
|
||||
newPassword: ""
|
||||
},
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => {
|
||||
try {
|
||||
if (!user?.email) return;
|
||||
if (!commonPasswords) return;
|
||||
|
||||
const errorCheck = checkPassword({
|
||||
password: newPassword,
|
||||
commonPasswords,
|
||||
setErrors
|
||||
});
|
||||
|
||||
if (errorCheck) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await attemptChangePassword({
|
||||
email: user.email,
|
||||
currentPassword: oldPassword,
|
||||
newPassword
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Successfully changed password",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
window.location.href = "/login";
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Failed to change password",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||
Change password
|
||||
</h2>
|
||||
<div className="max-w-md">
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input
|
||||
placeholder="Old password"
|
||||
type="password"
|
||||
{...field}
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="oldPassword"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input
|
||||
placeholder="New password"
|
||||
type="password"
|
||||
{...field}
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="newPassword"
|
||||
/>
|
||||
</div>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="my-4 max-w-md flex flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
||||
<div className="mb-2 text-sm text-gray-400">{t("section.password.validate-base")}</div>
|
||||
{Object.keys(errors).map((key) => {
|
||||
if (errors[key as keyof Errors]) {
|
||||
return (
|
||||
<div className="ml-1 flex flex-row items-top justify-start" key={key}>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
icon={faXmark}
|
||||
className="text-md text-red ml-0.5 mr-2.5"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{errors[key as keyof Errors]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangePasswordSection } from "./ChangePasswordSection";
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input
|
||||
} from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
|
||||
const schema = yup.object({
|
||||
password: yup.string().required("Password is required")
|
||||
}).required();
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
export const EmergencyKitSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { user } = useUser();
|
||||
const { reset, control, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = ({
|
||||
password
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (!user?.email) return;
|
||||
|
||||
issueBackupKey({
|
||||
email: user.email,
|
||||
password,
|
||||
personalName: `${user.firstName} ${user.lastName}`,
|
||||
setBackupKeyError: () => {},
|
||||
setBackupKeyIssued: () => {}
|
||||
});
|
||||
|
||||
reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to download emergency kit",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100">
|
||||
Emergency Kit
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-8">
|
||||
The kit contains information you can use to recover your account.
|
||||
</p>
|
||||
<div className="max-w-md">
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
{...field}
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={false}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { EmergencyKitSection } from "./EmergencyKitSection";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { APIKeySection } from "../APIKeySection";
|
||||
|
||||
export const PersonalAPIKeyTab = () => {
|
||||
return (
|
||||
<APIKeySection />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalAPIKeyTab } from "./PersonalAPIKeyTab";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ChangePasswordSection } from "../ChangePasswordSection";
|
||||
import { EmergencyKitSection } from "../EmergencyKitSection";
|
||||
import { SecuritySection } from "../SecuritySection";
|
||||
import { SessionsSection } from "../SessionsSection";
|
||||
|
||||
export const PersonalSecurityTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<SecuritySection />
|
||||
<SessionsSection />
|
||||
<ChangePasswordSection />
|
||||
<EmergencyKitSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalSecurityTab } from "./PersonalSecurityTab";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
|
||||
import { PersonalTabGroup } from "./PersonalTabGroup";
|
||||
|
||||
export const PersonalSettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||
<div className="max-w-screen-lg w-full">
|
||||
<NavHeader pageName={t("settings.personal.title")} isProjectRelated={false} />
|
||||
<div className="my-8">
|
||||
<p className="text-3xl font-semibold text-gray-200">
|
||||
{t("settings.personal.title")}
|
||||
</p>
|
||||
</div>
|
||||
<PersonalTabGroup />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Fragment } from "react"
|
||||
import { Tab } from "@headlessui/react"
|
||||
|
||||
import { PersonalAPIKeyTab } from "../PersonalAPIKeyTab";
|
||||
import { PersonalSecurityTab } from "../PersonalSecurityTab";
|
||||
|
||||
const tabs = [
|
||||
{ name: "Security", key: "tab-account-security" },
|
||||
{ name: "API Keys", key: "tab-account-api-keys" }
|
||||
];
|
||||
|
||||
export const PersonalTabGroup = () => {
|
||||
return (
|
||||
<Tab.Group>
|
||||
<Tab.List className="mb-6 border-b-2 border-mineshaft-800 w-full">
|
||||
{tabs.map((tab) => (
|
||||
<Tab as={Fragment} key={tab.key}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`w-30 p-4 font-semibold outline-none ${selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<PersonalSecurityTab />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PersonalAPIKeyTab />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalTabGroup } from "./PersonalTabGroup";
|
||||
@@ -51,8 +51,8 @@ export const SecuritySection = () => {
|
||||
return (
|
||||
<>
|
||||
<form>
|
||||
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-2">
|
||||
<p className="mb-4 mt-2 text-xl font-semibold">
|
||||
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||
Two-factor Authentication
|
||||
</p>
|
||||
<Checkbox
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { faBan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@app/components/v2";
|
||||
import { useRevokeMySessions } from "@app/hooks/api";
|
||||
|
||||
import { SessionsTable } from "./SessionsTable";
|
||||
|
||||
export const SessionsSection = () => {
|
||||
const { mutateAsync } = useRevokeMySessions();
|
||||
|
||||
const onRevokeAllSessionsClick = async () => {
|
||||
try {
|
||||
await mutateAsync();
|
||||
window.location.href = "/login";
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="flex justify-between mb-8">
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100">
|
||||
Sessions
|
||||
</h2>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faBan} />}
|
||||
onClick={onRevokeAllSessionsClick}
|
||||
>
|
||||
Revoke all
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your account out all active sessions across all browsers and CLIs.
|
||||
</p>
|
||||
<SessionsTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useGetMySessions } from "@app/hooks/api";
|
||||
|
||||
export const SessionsTable = () => {
|
||||
const { data, isLoading } = useGetMySessions();
|
||||
|
||||
const formatDate = (dateToFormat: string) => {
|
||||
const date = new Date(dateToFormat);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
const formattedDate = `${day}/${month}/${year}`;
|
||||
|
||||
return formattedDate;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Created</Th>
|
||||
<Th>Last active</Th>
|
||||
<Th>IP address</Th>
|
||||
<Th>Device</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} key="sesssions" />}
|
||||
{!isLoading && data && data.length > 0 && data.map(({
|
||||
_id,
|
||||
createdAt,
|
||||
lastUsed,
|
||||
ip,
|
||||
userAgent
|
||||
}) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`session-${_id}`}>
|
||||
<Td>{formatDate(createdAt)}</Td>
|
||||
<Td>{formatDate(lastUsed)}</Td>
|
||||
<Td>{ip}</Td>
|
||||
<Td>{userAgent}</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={4}>
|
||||
<EmptyState
|
||||
title="No sessions on file"
|
||||
icon={faServer}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SessionsSection } from "./SessionsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalSettingsPage } from "./PersonalSettingsPage";
|
||||
@@ -1,443 +1,23 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
// TODO(akhilmhdh):Refactor this into a better utility module package
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
decryptSymmetric,
|
||||
encryptSymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useSubscription, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
useCreateServiceToken,
|
||||
useCreateWsEnvironment,
|
||||
useCreateWsTag,
|
||||
useDeleteServiceToken,
|
||||
useDeleteWorkspace,
|
||||
useDeleteWsEnvironment,
|
||||
useDeleteWsTag,
|
||||
useGetUserWsKey,
|
||||
useGetUserWsServiceTokens,
|
||||
useGetWorkspaceIndexStatus,
|
||||
useGetWorkspaceSecrets,
|
||||
useGetWsTags,
|
||||
useNameWorkspaceSecrets,
|
||||
useRenameWorkspace,
|
||||
useToggleAutoCapitalization,
|
||||
useUpdateWsEnvironment
|
||||
} from "@app/hooks/api";
|
||||
|
||||
import { AutoCapitalizationSection } from "./components/AutoCapitalizationSection/AutoCapitalizationSection";
|
||||
import { SecretTagsSection } from "./components/SecretTagsSection";
|
||||
import {
|
||||
CopyProjectIDSection,
|
||||
CreateServiceToken,
|
||||
CreateUpdateEnvFormData,
|
||||
CreateWsTag,
|
||||
E2EESection,
|
||||
EnvironmentSection,
|
||||
ProjectIndexSecretsSection,
|
||||
ProjectNameChangeSection,
|
||||
ServiceTokenSection} from "./components";
|
||||
import { ProjectTabGroup } from "./components";
|
||||
|
||||
export const ProjectSettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentWorkspace, workspaces, isLoading: isWorkspaceLoading } = useWorkspace();
|
||||
const router = useRouter();
|
||||
|
||||
const workspaceID = currentWorkspace?._id || "";
|
||||
const { createNotification } = useNotificationContext();
|
||||
// delete action worksapce
|
||||
const [deleteProjectInput, setDeleteProjectInput] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useToggle();
|
||||
|
||||
const renameWorkspace = useRenameWorkspace();
|
||||
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
|
||||
const toggleAutoCapitalization = useToggleAutoCapitalization();
|
||||
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
// env crud operation
|
||||
const createWsEnv = useCreateWsEnvironment();
|
||||
const updateWsEnv = useUpdateWsEnvironment();
|
||||
const deleteWsEnv = useDeleteWsEnvironment();
|
||||
|
||||
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } =
|
||||
useGetWorkspaceIndexStatus(workspaceID);
|
||||
|
||||
// service token
|
||||
const { data: serviceTokens, isLoading: isServiceTokenLoading } = useGetUserWsServiceTokens({
|
||||
workspaceID: currentWorkspace?._id || ""
|
||||
});
|
||||
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceID);
|
||||
const { data: encryptedSecrets } = useGetWorkspaceSecrets(workspaceID);
|
||||
|
||||
const createServiceToken = useCreateServiceToken();
|
||||
const deleteServiceToken = useDeleteServiceToken();
|
||||
|
||||
// tag
|
||||
const { data: wsTags, isLoading: isTagLoading } = useGetWsTags(workspaceID);
|
||||
const createWsTag = useCreateWsTag();
|
||||
const deleteWsTag = useDeleteWsTag();
|
||||
|
||||
// get user subscription
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const isEnvServiceAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
|
||||
|
||||
const onRenameWorkspace = async (name: string) => {
|
||||
try {
|
||||
await renameWorkspace.mutateAsync({ workspaceID, newWorkspaceName: name });
|
||||
createNotification({
|
||||
text: "Successfully renamed workspace",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to rename workspace",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAutoCapitalizationToggle = async (state: boolean) => {
|
||||
try {
|
||||
await toggleAutoCapitalization.mutateAsync({
|
||||
workspaceID,
|
||||
state
|
||||
});
|
||||
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
|
||||
createNotification({
|
||||
text,
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to update auto capitalization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteWorkspace = async () => {
|
||||
setIsDeleting.on();
|
||||
try {
|
||||
await deleteWorkspace.mutateAsync({ workspaceID });
|
||||
// redirect user to first workspace user is part of
|
||||
const ws = workspaces.find(({ _id }) => _id !== workspaceID);
|
||||
if (!ws) {
|
||||
router.push("/noprojects");
|
||||
}
|
||||
router.push(`/dashboard/${ws?._id}`);
|
||||
createNotification({
|
||||
text: "Successfully deleted workspace",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to delete workspace",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting.off();
|
||||
}
|
||||
};
|
||||
|
||||
// workspace environment operation
|
||||
const onCreateWsEnv = async ({ environmentName, environmentSlug }: CreateUpdateEnvFormData) => {
|
||||
try {
|
||||
await createWsEnv.mutateAsync({ workspaceID, environmentName, environmentSlug });
|
||||
createNotification({
|
||||
text: "Successfully created environment",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to create environment",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateWsEnv = async (
|
||||
oldEnvironmentSlug: string,
|
||||
{ environmentName, environmentSlug }: CreateUpdateEnvFormData
|
||||
) => {
|
||||
try {
|
||||
await updateWsEnv.mutateAsync({
|
||||
workspaceID,
|
||||
environmentName,
|
||||
environmentSlug,
|
||||
oldEnvironmentSlug
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully updated environment",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to update environment",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteWsEnv = async (environmentSlug: string) => {
|
||||
try {
|
||||
await deleteWsEnv.mutateAsync({
|
||||
workspaceID,
|
||||
environmentSlug
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully deleted environment",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to delete environment",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateServiceToken = async ({
|
||||
environment,
|
||||
expiresIn,
|
||||
name,
|
||||
permissions,
|
||||
secretPath
|
||||
}: CreateServiceToken) => {
|
||||
// type guard
|
||||
if (!latestFileKey) return "";
|
||||
try {
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: localStorage.getItem("PRIVATE_KEY") as string
|
||||
});
|
||||
|
||||
const randomBytes = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext: key,
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
const res = await createServiceToken.mutateAsync({
|
||||
encryptedKey: ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
environment,
|
||||
secretPath,
|
||||
expiresIn: Number(expiresIn),
|
||||
name,
|
||||
workspaceId: workspaceID,
|
||||
randomBytes,
|
||||
permissions: Object.entries(permissions)
|
||||
.filter(([, permissionsValue]) => permissionsValue)
|
||||
.map(([permissionsKey]) => permissionsKey)
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully created a service token",
|
||||
type: "success"
|
||||
});
|
||||
return res.serviceToken;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to create a service token",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const onCreateWsTag = async ({ name }: CreateWsTag) => {
|
||||
try {
|
||||
const res = await createWsTag.mutateAsync({
|
||||
workspaceID,
|
||||
tagName: name,
|
||||
tagSlug: name.replace(" ", "_")
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully created a tag",
|
||||
type: "success"
|
||||
});
|
||||
return res.name;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to create a tag",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const onDeleteTag = async (tagID: string) => {
|
||||
try {
|
||||
await deleteWsTag.mutateAsync({ tagID });
|
||||
createNotification({
|
||||
text: "Successfully deleted tag",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to delete the tag",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteServiceToken = async (tokenID: string) => {
|
||||
try {
|
||||
await deleteServiceToken.mutateAsync(tokenID);
|
||||
createNotification({
|
||||
text: "Successfully revoked service token",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to delete service token",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableBlindIndices = async () => {
|
||||
if (!currentWorkspace?._id) return;
|
||||
if (!encryptedSecrets) return;
|
||||
if (!latestFileKey) return;
|
||||
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: localStorage.getItem("PRIVATE_KEY") as string
|
||||
});
|
||||
|
||||
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
|
||||
const secretName = decryptSymmetric({
|
||||
ciphertext: encryptedSecret.secretKeyCiphertext,
|
||||
iv: encryptedSecret.secretKeyIV,
|
||||
tag: encryptedSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
return {
|
||||
secretName,
|
||||
_id: encryptedSecret._id
|
||||
};
|
||||
});
|
||||
|
||||
await nameWorkspaceSecrets.mutateAsync({
|
||||
workspaceId: currentWorkspace._id,
|
||||
secretsToUpdate
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dark container mx-auto flex flex-col px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
{/* TODO(akhilmhdh): Remove this right when layout is refactored */}
|
||||
<div className="relative right-5">
|
||||
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="my-8 flex max-w-5xl flex-row items-center justify-between text-xl">
|
||||
<div className="flex flex-col items-start justify-start text-3xl">
|
||||
<p className="mr-4 font-semibold text-gray-200">{t("settings.project.title")}</p>
|
||||
<p className="mr-4 text-base font-normal text-gray-400">
|
||||
{t("settings.project.description")}
|
||||
</p>
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||
<div className="max-w-screen-lg w-full">
|
||||
<div className="relative right-5 ml-4">
|
||||
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
|
||||
</div>
|
||||
</div>
|
||||
<ProjectNameChangeSection
|
||||
workspaceName={currentWorkspace?.name}
|
||||
onProjectNameChange={onRenameWorkspace}
|
||||
/>
|
||||
<CopyProjectIDSection workspaceID={currentWorkspace?._id || ""} />
|
||||
<EnvironmentSection
|
||||
isLoading={isWorkspaceLoading}
|
||||
environments={currentWorkspace?.environments || []}
|
||||
onCreate={onCreateWsEnv}
|
||||
onDelete={onDeleteWsEnv}
|
||||
onUpdate={onUpdateWsEnv}
|
||||
isEnvServiceAllowed={isEnvServiceAllowed}
|
||||
/>
|
||||
<ServiceTokenSection
|
||||
isLoading={isServiceTokenLoading}
|
||||
tokens={serviceTokens || []}
|
||||
environments={currentWorkspace?.environments || []}
|
||||
onDeleteToken={onDeleteServiceToken}
|
||||
workspaceName={currentWorkspace?.name || ""}
|
||||
onCreateToken={onCreateServiceToken}
|
||||
/>
|
||||
<SecretTagsSection
|
||||
isLoading={isTagLoading}
|
||||
tags={wsTags || []}
|
||||
onDeleteTag={onDeleteTag}
|
||||
workspaceName={currentWorkspace?.name || ""}
|
||||
onCreateTag={onCreateWsTag}
|
||||
/>
|
||||
<AutoCapitalizationSection
|
||||
workspaceAutoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
onAutoCapitalizationChange={onAutoCapitalizationToggle}
|
||||
/>
|
||||
{!isBlindIndexedLoading && !isBlindIndexed && (
|
||||
<ProjectIndexSecretsSection
|
||||
onEnableBlindIndices={onEnableBlindIndices}
|
||||
/>
|
||||
)}
|
||||
<E2EESection
|
||||
workspaceId={currentWorkspace?._id || ""}
|
||||
/>
|
||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md border-l border-red bg-mineshaft-900 px-6 pl-6 pb-4 pt-4">
|
||||
<p className="text-xl font-bold text-red">{t("settings.project.danger-zone")}</p>
|
||||
<p className="text-md mt-2 text-gray-400">{t("settings.project.danger-zone-note")}</p>
|
||||
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
|
||||
<FormControl
|
||||
label={
|
||||
<div className="mb-0.5 text-sm font-normal text-gray-400">
|
||||
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
|
||||
workspace
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
onChange={(e) => setDeleteProjectInput(e.target.value)}
|
||||
value={deleteProjectInput}
|
||||
placeholder="Type the project name to delete"
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="my-8">
|
||||
<p className="text-3xl font-semibold text-gray-200">
|
||||
{t("settings.project.title")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
onClick={onDeleteWorkspace}
|
||||
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting}
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
{t("settings.project.delete-project")}
|
||||
</Button>
|
||||
<p className="mt-3 ml-0.5 text-xs text-gray-500">
|
||||
{t("settings.project.delete-project-note")}
|
||||
</p>
|
||||
<ProjectTabGroup />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Checkbox } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useToggleAutoCapitalization } from "@app/hooks/api";
|
||||
|
||||
type Props = {
|
||||
workspaceAutoCapitalization?: boolean;
|
||||
onAutoCapitalizationChange: (state: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AutoCapitalizationSection = ({
|
||||
workspaceAutoCapitalization,
|
||||
onAutoCapitalizationChange
|
||||
}: Props) => {
|
||||
export const AutoCapitalizationSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync } = useToggleAutoCapitalization();
|
||||
|
||||
const handleToggleCapitalizationToggle = async (state: boolean) => {
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
await mutateAsync({
|
||||
workspaceID: currentWorkspace._id,
|
||||
state
|
||||
});
|
||||
|
||||
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
|
||||
createNotification({
|
||||
text,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update auto capitalization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-2">
|
||||
<p className="mb-4 mt-2 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary"
|
||||
id="autoCapitalization"
|
||||
isChecked={workspaceAutoCapitalization}
|
||||
isChecked={currentWorkspace?.autoCapitalization ?? false}
|
||||
onCheckedChange={(state) => {
|
||||
onAutoCapitalizationChange(state as boolean);
|
||||
handleToggleCapitalizationToggle(state as boolean);
|
||||
}}
|
||||
>
|
||||
{t("settings.project.auto-capitalization-description")}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
workspaceID: string;
|
||||
};
|
||||
|
||||
export const CopyProjectIDSection = ({ workspaceID }: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isProjectIdCopied) {
|
||||
timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isProjectIdCopied]);
|
||||
|
||||
const copyProjectIdToClipboard = () => {
|
||||
navigator.clipboard.writeText(workspaceID);
|
||||
setIsProjectIdCopied.on();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pt-4 pb-2">
|
||||
<p className="self-start text-xl font-semibold">{t("common.project-id")}</p>
|
||||
<p className="mt-4 text-sm text-bunker-300 mb-2">{t("settings.project.auto-generated")}</p>
|
||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] text-base text-gray-400">
|
||||
<p className="mr-2 pl-4 font-bold">{`${t("common.project-id")}:`}</p>
|
||||
<p className="mr-4">{workspaceID}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={() => copyProjectIdToClipboard()}
|
||||
>
|
||||
<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||
{t("common.click-to-copy")}
|
||||
</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { CopyProjectIDSection } from "./CopyProjectIDSection";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
useDeleteWorkspace
|
||||
} from "@app/hooks/api";
|
||||
|
||||
export const DeleteProjectSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace, workspaces } = useWorkspace();
|
||||
const [isDeleting, setIsDeleting] = useToggle();
|
||||
const [deleteProjectInput, setDeleteProjectInput] = useState("");
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
|
||||
const onDeleteWorkspace = async () => {
|
||||
setIsDeleting.on();
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
await deleteWorkspace.mutateAsync({
|
||||
workspaceID: currentWorkspace?._id
|
||||
});
|
||||
// redirect user to first workspace user is part of
|
||||
const ws = workspaces.find(({ _id }) => _id !== currentWorkspace?._id);
|
||||
if (!ws) {
|
||||
router.push("/noprojects");
|
||||
}
|
||||
router.push(`/dashboard/${ws?._id}`);
|
||||
createNotification({
|
||||
text: "Successfully deleted workspace",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to delete workspace",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting.off();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-red">
|
||||
<p className="mb-3 text-xl font-semibold text-red">{t("settings.project.danger-zone")}</p>
|
||||
<p className="text-gray-400 mb-8">{t("settings.project.danger-zone-note")}</p>
|
||||
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
|
||||
<FormControl
|
||||
label={
|
||||
<div className="mb-0.5 text-sm font-normal text-gray-400">
|
||||
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
|
||||
workspace
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
onChange={(e) => setDeleteProjectInput(e.target.value)}
|
||||
value={deleteProjectInput}
|
||||
placeholder="Type the project name to delete"
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
onClick={onDeleteWorkspace}
|
||||
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting}
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
{t("settings.project.delete-project")}
|
||||
</Button>
|
||||
<p className="mt-3 ml-0.5 text-xs text-gray-500">
|
||||
{t("settings.project.delete-project-note")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||
@@ -4,29 +4,27 @@ import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import {
|
||||
Checkbox
|
||||
} from "@app/components/v2";
|
||||
import { Checkbox } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
|
||||
import getBot from "../../../../../pages/api/bot/getBot";
|
||||
import setBotActiveStatus from "../../../../../pages/api/bot/setBotActiveStatus";
|
||||
import getLatestFileKey from "../../../../../pages/api/workspace/getLatestFileKey";
|
||||
|
||||
type Props = {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const E2EESection = ({
|
||||
workspaceId
|
||||
}: Props) => {
|
||||
export const E2EESection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const [bot, setBot] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// get project bot
|
||||
setBot(await getBot({ workspaceId }));
|
||||
if (currentWorkspace) {
|
||||
// get project bot
|
||||
setBot(await getBot({
|
||||
workspaceId: currentWorkspace._id
|
||||
}));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}, [currentWorkspace]);
|
||||
|
||||
/**
|
||||
* Activate bot for project by performing the following steps:
|
||||
@@ -38,12 +36,16 @@ export const E2EESection = ({
|
||||
const toggleBotActivate = async () => {
|
||||
let botKey;
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
if (bot) {
|
||||
// case: there is a bot
|
||||
|
||||
if (!bot.isActive) {
|
||||
// bot is not active -> activate bot
|
||||
const key = await getLatestFileKey({ workspaceId });
|
||||
const key = await getLatestFileKey({
|
||||
workspaceId: currentWorkspace._id
|
||||
});
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
|
||||
|
||||
if (!PRIVATE_KEY) {
|
||||
@@ -91,12 +93,12 @@ export const E2EESection = ({
|
||||
};
|
||||
|
||||
return bot ? (
|
||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-2">
|
||||
<p className="mb-4 mt-2 text-xl font-semibold">End-to-End Encryption</p>
|
||||
<p className="text-md my-2 text-gray-400">
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values.
|
||||
</p>
|
||||
<p className="text-md my-2 mb-4 text-gray-400">
|
||||
<p className="text-gray-400 mb-8">
|
||||
Note that, even with E2EE disabled, your secrets are always encrypted at rest.
|
||||
</p>
|
||||
<Checkbox
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useCreateWsEnvironment } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["createEnv"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
const schema = yup.object({
|
||||
environmentName: yup.string().label("Environment Name").required(),
|
||||
environmentSlug: yup.string().label("Environment Slug").required()
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
export const AddEnvironmentModal = ({
|
||||
popUp,
|
||||
handlePopUpClose,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync, isLoading } = useCreateWsEnvironment();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({
|
||||
environmentName,
|
||||
environmentSlug
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
await mutateAsync({
|
||||
workspaceID: currentWorkspace._id,
|
||||
environmentName,
|
||||
environmentSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully created environment",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("createEnv");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to create environment",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.createEnv?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("createEnv", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Create a new environment">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="environmentName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="environmentSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment Slug"
|
||||
helperText="Slugs are shorthands used in cli to access environment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +1,100 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faPencil, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription,useWorkspace } from "@app/context";
|
||||
import {
|
||||
useDeleteWsEnvironment
|
||||
} from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
environments: Array<{ name: string; slug: string }>;
|
||||
isLoading?: boolean;
|
||||
isEnvServiceAllowed: boolean;
|
||||
onCreate: (data: CreateUpdateEnvFormData) => Promise<void>;
|
||||
onUpdate: (oldEnvSlug: string, data: CreateUpdateEnvFormData) => Promise<void>;
|
||||
onDelete: (envSlug: string) => Promise<void>;
|
||||
};
|
||||
import { AddEnvironmentModal } from "./AddEnvironmentModal";
|
||||
import { EnvironmentTable } from "./EnvironmentTable";
|
||||
import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal";
|
||||
|
||||
const createUpdateEnvSchema = yup.object({
|
||||
environmentName: yup.string().label("Environment Name").required(),
|
||||
environmentSlug: yup.string().label("Environment Slug").required()
|
||||
});
|
||||
export const EnvironmentSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
export type CreateUpdateEnvFormData = yup.InferType<typeof createUpdateEnvSchema>;
|
||||
const deleteWsEnvironment = useDeleteWsEnvironment();
|
||||
|
||||
export const EnvironmentSection = ({
|
||||
environments,
|
||||
isEnvServiceAllowed,
|
||||
onCreate,
|
||||
onDelete,
|
||||
isLoading,
|
||||
onUpdate
|
||||
}: Props): JSX.Element => {
|
||||
const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"createUpdateEnv",
|
||||
"createEnv",
|
||||
"updateEnv",
|
||||
"deleteEnv",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<CreateUpdateEnvFormData>({
|
||||
resolver: yupResolver(createUpdateEnvSchema)
|
||||
});
|
||||
const onEnvDeleteSubmit = async (environmentSlug: string) => {
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
await deleteWsEnvironment.mutateAsync({
|
||||
workspaceID: currentWorkspace._id,
|
||||
environmentSlug
|
||||
});
|
||||
|
||||
const isEnvUpdate = Boolean(popUp?.createUpdateEnv?.data);
|
||||
const oldEnvSlug = (popUp?.createUpdateEnv?.data as { slug: string })?.slug;
|
||||
|
||||
const onEnvModalSubmit = async (data: CreateUpdateEnvFormData) => {
|
||||
if (isEnvUpdate) {
|
||||
await onUpdate(oldEnvSlug, data);
|
||||
} else {
|
||||
await onCreate(data);
|
||||
createNotification({
|
||||
text: "Successfully deleted environment",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteEnv");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete environment",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
handlePopUpClose("createUpdateEnv");
|
||||
};
|
||||
|
||||
const onEnvDeleteSubmit = async (envSlug: string) => {
|
||||
await onDelete(envSlug);
|
||||
handlePopUpClose("deleteEnv");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 mb-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 p-6">
|
||||
<div className="mb-2 flex w-full flex-row justify-between">
|
||||
<div className="flex w-full flex-col">
|
||||
<p className="mb-3 text-xl font-semibold">Project Environments</p>
|
||||
<p className="mb-4 text-base text-gray-400">
|
||||
Choose which environments will show up in your dashboard like development, staging,
|
||||
production
|
||||
</p>
|
||||
<p className="mr-1 self-start text-sm text-gray-500">
|
||||
Note: the text in slugs shows how these environmant should be accessed in CLI.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="flex justify-between mb-8">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">
|
||||
Environments
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (isEnvServiceAllowed) {
|
||||
handlePopUpOpen("createUpdateEnv");
|
||||
if (isMoreEnvironmentsAllowed) {
|
||||
handlePopUpOpen("createEnv");
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add New Environment
|
||||
Create environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="button" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={3} key="project-envs" />}
|
||||
{!isLoading &&
|
||||
environments.map(({ name, slug }) => (
|
||||
<Tr key={name}>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td className="flex items-center justify-end">
|
||||
<IconButton
|
||||
className="mr-3 py-2"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("createUpdateEnv", { name, slug });
|
||||
reset({ environmentName: name, environmentSlug: slug });
|
||||
}}
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteEnv", { name, slug });
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{!isLoading && environments?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={3}>
|
||||
<EmptyState title="No environments found" />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Modal
|
||||
isOpen={popUp?.createUpdateEnv?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("createUpdateEnv", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={isEnvUpdate ? "Update environment" : "Create a new environment"}>
|
||||
<form onSubmit={handleSubmit(onEnvModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="environmentName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="environmentSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment Slug"
|
||||
helperText="Slugs are shorthands used in cli to access environment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{isEnvUpdate ? "Update" : "Create"}
|
||||
</Button>
|
||||
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Choose which environments will show up in your dashboard like development, staging, production
|
||||
</p>
|
||||
<EnvironmentTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
<AddEnvironmentModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<UpdateEnvironmentModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteEnv.isOpen}
|
||||
title={`Are you sure want to delete ${
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr,
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "deleteEnv", "upgradePlan"]>,
|
||||
{
|
||||
name,
|
||||
slug
|
||||
}: {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const EnvironmentTable = ({
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
const { currentWorkspace, isLoading } = useWorkspace();
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="button" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={3} key="project-envs" />}
|
||||
{!isLoading && currentWorkspace && currentWorkspace.environments.map(({ name, slug }) => (
|
||||
<Tr key={name}>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td className="flex items-center justify-end">
|
||||
<IconButton
|
||||
className="mr-3 py-2"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("updateEnv", { name, slug });
|
||||
}}
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteEnv", { name, slug });
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={3}>
|
||||
<EmptyState title="No environments found" />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useUpdateWsEnvironment } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["updateEnv"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["updateEnv"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
const schema = yup.object({
|
||||
environmentName: yup.string().label("Environment Name").required(),
|
||||
environmentSlug: yup.string().label("Environment Slug").required()
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
export const UpdateEnvironmentModal = ({
|
||||
popUp,
|
||||
handlePopUpClose,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync, isLoading } = useUpdateWsEnvironment();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
const oldEnvironmentSlug = (popUp?.updateEnv?.data as { slug: string })?.slug;
|
||||
|
||||
const onFormSubmit = async ({
|
||||
environmentName,
|
||||
environmentSlug
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
await mutateAsync({
|
||||
workspaceID: currentWorkspace._id,
|
||||
environmentName,
|
||||
environmentSlug,
|
||||
oldEnvironmentSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated environment",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("updateEnv");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update environment",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.updateEnv?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("updateEnv", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Update environment">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="environmentName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="environmentSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment Slug"
|
||||
helperText="Slugs are shorthands used in cli to access environment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AutoCapitalizationSection } from "../AutoCapitalizationSection";
|
||||
import { DeleteProjectSection } from "../DeleteProjectSection";
|
||||
import { E2EESection } from "../E2EESection";
|
||||
import { EnvironmentSection } from "../EnvironmentSection";
|
||||
import { ProjectIndexSecretsSection } from "../ProjectIndexSecretsSection";
|
||||
import { ProjectNameChangeSection } from "../ProjectNameChangeSection";
|
||||
import { SecretTagsSection } from "../SecretTagsSection";
|
||||
|
||||
export const ProjectGeneralTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<ProjectNameChangeSection />
|
||||
<EnvironmentSection />
|
||||
<SecretTagsSection />
|
||||
<AutoCapitalizationSection />
|
||||
<ProjectIndexSecretsSection />
|
||||
<E2EESection />
|
||||
<DeleteProjectSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectGeneralTab } from "./ProjectGeneralTab";
|
||||
@@ -1,24 +1,64 @@
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
decryptSymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceIndexStatus,
|
||||
useGetWorkspaceSecrets,
|
||||
useNameWorkspaceSecrets
|
||||
} from "@app/hooks/api";
|
||||
|
||||
// TODO: add check so that this only shows up if user is
|
||||
// an admin in the workspace
|
||||
|
||||
type Props = {
|
||||
onEnableBlindIndices: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ProjectIndexSecretsSection = ({
|
||||
onEnableBlindIndices
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="rounded-md bg-mineshaft-900 p-6 my-2">
|
||||
<p className="mb-4 text-xl font-semibold">Blind Indices</p>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
export const ProjectIndexSecretsSection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? "");
|
||||
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
|
||||
const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? "");
|
||||
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
|
||||
|
||||
const onEnableBlindIndices = async () => {
|
||||
if (!currentWorkspace?._id) return;
|
||||
if (!encryptedSecrets) return;
|
||||
if (!latestFileKey) return;
|
||||
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: localStorage.getItem("PRIVATE_KEY") as string
|
||||
});
|
||||
|
||||
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
|
||||
const secretName = decryptSymmetric({
|
||||
ciphertext: encryptedSecret.secretKeyCiphertext,
|
||||
iv: encryptedSecret.secretKeyIV,
|
||||
tag: encryptedSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
return {
|
||||
secretName,
|
||||
_id: encryptedSecret._id
|
||||
};
|
||||
});
|
||||
|
||||
await nameWorkspaceSecrets.mutateAsync({
|
||||
workspaceId: currentWorkspace._id,
|
||||
secretsToUpdate
|
||||
});
|
||||
};
|
||||
|
||||
return (!isBlindIndexedLoading && !isBlindIndexed) ? (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<p className="mb-3 text-xl font-semibold">Blind Indices</p>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Learn more about it here.
|
||||
</p>
|
||||
<Button
|
||||
onClick={onEnableBlindIndices}
|
||||
color="mineshaft"
|
||||
@@ -28,5 +68,7 @@ export const ProjectIndexSecretsSection = ({
|
||||
Enable Blind Indexing
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
) : (
|
||||
<div />
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
workspaceName?: string;
|
||||
onProjectNameChange: (name: string) => Promise<void>;
|
||||
};
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
useRenameWorkspace
|
||||
} from "@app/hooks/api";
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup.string().required().label("Project Name")
|
||||
@@ -19,36 +17,68 @@ const formSchema = yup.object({
|
||||
|
||||
type FormData = yup.InferType<typeof formSchema>;
|
||||
|
||||
export const ProjectNameChangeSection = ({
|
||||
workspaceName,
|
||||
onProjectNameChange
|
||||
}: Props): JSX.Element => {
|
||||
export const ProjectNameChangeSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync, isLoading } = useRenameWorkspace();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { isDirty, isSubmitting }
|
||||
reset
|
||||
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
reset({ name: workspaceName });
|
||||
}, [workspaceName]);
|
||||
if (currentWorkspace) {
|
||||
reset({
|
||||
name: currentWorkspace.name
|
||||
});
|
||||
}
|
||||
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
await onProjectNameChange(name);
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
await mutateAsync({
|
||||
workspaceID: currentWorkspace._id,
|
||||
newWorkspaceName: name
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully renamed workspace",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to rename workspace",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-3">
|
||||
<p className="mb-4 mt-2 text-xl font-semibold">{t("common.display-name")}</p>
|
||||
<div className="mb-2 w-full max-w-lg">
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||
{t("common.display-name")}
|
||||
</h2>
|
||||
<div className="max-w-md">
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input placeholder="Type your project name" {...field} className="bg-mineshaft-800" />
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
{...field}
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
@@ -56,17 +86,13 @@ export const ProjectNameChangeSection = ({
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
color="primary"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{t("common.save-changes")}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ServiceTokenSection } from "../ServiceTokenSection";
|
||||
|
||||
export const ProjectServiceTokensTab = () => {
|
||||
return (
|
||||
<ServiceTokenSection />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user