Merge branch 'main' of github.com:atimapreandrew/infisical

This commit is contained in:
Andrew Atimapre
2023-06-30 17:55:22 +01:00
116 changed files with 4108 additions and 3418 deletions
@@ -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,
});
};
-2
View File
@@ -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,
+6 -2
View File
@@ -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
});
};
-2
View File
@@ -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);
-42
View File
@@ -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;
-2
View File
@@ -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,
}
+46 -1
View File
@@ -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" />
{subscription && (
<UpgradePlanModal
isOpen={isUpgradeModalOpen}
onClose={closeUpgradeModal}
text="You can change user permissions if you switch to Infisical's Professional plan."
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>
-86
View File
@@ -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>
@@ -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 -2
View File
@@ -1,7 +1,6 @@
export {
useGetAuthToken,
useGetCommonPasswords,
useRevokeAllSessions,
useSendMfaToken,
useVerifyMfaToken
} from "./queries";
} from "./queries"
-9
View File
@@ -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 || [];
+15 -6
View File
@@ -81,7 +81,8 @@ export const useGetProjectSecrets = ({
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
select: useCallback((data: EncryptedSecret[]) => {
select: useCallback(
(data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey;
const key = decryptAssymmetric({
@@ -150,7 +151,9 @@ export const useGetProjectSecrets = ({
}
});
return { secrets: sharedSecrets };
}, [decryptFileKey])
},
[decryptFileKey]
)
});
export const useGetProjectSecretsByKey = ({
@@ -167,7 +170,8 @@ 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[]) => {
select: useCallback(
(data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey;
const key = decryptAssymmetric({
@@ -242,7 +246,9 @@ export const useGetProjectSecretsByKey = ({
});
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
}, [decryptFileKey])
},
[decryptFileKey]
)
});
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
@@ -263,7 +269,8 @@ 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[]) => {
select: useCallback(
(data: EncryptedSecretVersion[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = dto.decryptFileKey;
const key = decryptAssymmetric({
@@ -285,7 +292,9 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
})
}))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [])
},
[dto.decryptFileKey]
)
});
export const useBatchSecretsOp = () => {
+6 -2
View File
@@ -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";
+94 -3
View File
@@ -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);
}
});
}
+24
View File
@@ -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
View File
@@ -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
View 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}`}>
$&#123;
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
&#125;
</span>
) : (
<span key={`${word}_${i + 1}`} className="ph-no-capture">
{word}
</span>
)
);
}, []);
return syntaxHighlight;
};
+2
View File
@@ -158,7 +158,9 @@ export default function Activity() {
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
<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;
+4 -308
View File
@@ -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>
);
}
+2
View File
@@ -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>
<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"
)}
<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)" }}
>
{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>
{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`)}
<Controller
control={control}
name={`secrets.${index}.valueOverride`}
render={({ field }) => (
<textarea
key={`secrets.${index}.valueOverride`}
{...field}
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"
)}
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;
}
}}
/>
)}
/>
) : (
<input
{...register(`secrets.${index}.value`)}
<Controller
control={control}
name={`secrets.${index}.value`}
key={`secrets.${index}.value`}
render={({ field }) => (
<textarea
{...field}
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"
)}
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 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,7 +346,8 @@ 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">
<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">
@@ -377,6 +378,7 @@ export const SecretInputRow = memo(
</Tooltip>
</div>
</div>
</div>
</td>
</tr>
);
@@ -178,7 +178,9 @@ export const IntegrationsPage = ({ frameworkIntegrations }: Props) => {
return (
<div className="container mx-auto max-w-7xl px-8 pb-12 text-white">
<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">
<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="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>
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("billing.title")}</p>
<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>
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>
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>
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>
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,265 +1,30 @@
/* 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
@@ -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");
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"
});
}
reset();
};
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
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>
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>
// <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";
@@ -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">
<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 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")}
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">
{t("settings.project.title")}
</p>
</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>
<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 () => {
if (currentWorkspace) {
// get project bot
setBot(await getBot({ workspaceId }));
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();
const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
export const EnvironmentSection = ({
environments,
isEnvServiceAllowed,
onCreate,
onDelete,
isLoading,
onUpdate
}: Props): JSX.Element => {
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;
createNotification({
text: "Successfully deleted environment",
type: "success"
});
const onEnvModalSubmit = async (data: CreateUpdateEnvFormData) => {
if (isEnvUpdate) {
await onUpdate(oldEnvSlug, data);
} else {
await onCreate(data);
}
handlePopUpClose("createUpdateEnv");
};
const onEnvDeleteSubmit = async (envSlug: string) => {
await onDelete(envSlug);
handlePopUpClose("deleteEnv");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete environment",
type: "error"
});
}
};
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
<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>
<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>
<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>
)}
<p className="text-gray-400 mb-8">
Choose which environments will show up in your dashboard like development, staging, production
</p>
<EnvironmentTable
handlePopUpOpen={handlePopUpOpen}
/>
<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>
)}
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpdateEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<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>
<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 = () => {
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();
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">
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