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

View File

@@ -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,
});
};

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,

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
});

View File

@@ -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"
});
}

View File

@@ -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
});
};

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);

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;

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,
}

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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,12 +1,11 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from "@fortawesome/free-solid-svg-icons";
import { plans } from "public/data/frequentConstants";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Select, SelectItem } from "@app/components/v2";
import { useSubscription } from "@app/context";
import updateUserProjectPermission from "@app/ee/api/memberships/UpdateUserProjectPermission";
import getOrganizationSubscriptions from "@app/pages/api/organization/GetOrgSubscription";
import changeUserRoleInWorkspace from "@app/pages/api/workspace/changeUserRoleInWorkspace";
import deleteUserFromWorkspace from "@app/pages/api/workspace/deleteUserFromWorkspace";
import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey";
@@ -40,13 +39,12 @@ type EnvironmentProps = {
* @returns
*/
const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoading }: Props) => {
const { subscription } = useSubscription();
const [roleSelected, setRoleSelected] = useState(
Array(userData?.length).fill(userData.map((user) => user.role))
);
const host = window.location.origin;
const router = useRouter();
const [myRole, setMyRole] = useState("member");
const [currentPlan, setCurrentPlan] = useState("");
const [workspaceEnvs, setWorkspaceEnvs] = useState<EnvironmentProps[]>([]);
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
const { createNotification } = useNotificationContext();
@@ -128,7 +126,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
denials = [];
}
if (currentPlan !== plans.professional && host === "https://app.infisical.com" && workspaceId !== "63ea8121b6e2b0543ba79616") {
if (subscription?.rbac === false) {
setIsUpgradeModalOpen(true);
} else {
const allDenials = userData[index].deniedPermissions
@@ -167,14 +165,6 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
(async () => {
const result = await getProjectInfo({ projectId: workspaceId });
setWorkspaceEnvs(result.environments);
const orgId = localStorage.getItem("orgData.id") as string;
const subscriptions = await getOrganizationSubscriptions({
orgId
});
if (subscriptions) {
setCurrentPlan(subscriptions.data[0].plan.product);
}
})();
}, [userData, myUser]);
@@ -208,11 +198,13 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
return (
<div className="table-container relative mb-6 mt-1 min-w-max rounded-md border border-mineshaft-600 bg-bunker">
<div className="absolute h-[3.1rem] w-full rounded-t-md bg-white/5" />
<UpgradePlanModal
isOpen={isUpgradeModalOpen}
onClose={closeUpgradeModal}
text="You can change user permissions if you switch to Infisical's Professional plan."
/>
{subscription && (
<UpgradePlanModal
isOpen={isUpgradeModalOpen}
onClose={closeUpgradeModal}
text={subscription.slug === null ? "You can use RBAC under an Enterprise license" : "You can use RBAC if you switch to Infisical's Team Plan."}
/>
)}
<table className="my-0.5 w-full">
<thead className="text-xs font-light text-gray-400 bg-mineshaft-800">
<tr>

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>
);
}

View File

@@ -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>

View File

@@ -0,0 +1,108 @@
/* eslint-disable new-cap */
import crypto from "crypto";
import jsrp from "jsrp";
import changePassword2 from "@app/pages/api/auth/ChangePassword2";
import SRP1 from "@app/pages/api/auth/SRP1";
import Aes256Gcm from "./cryptography/aes-256-gcm";
import { deriveArgonKey } from "./cryptography/crypto";
import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage";
const clientOldPassword = new jsrp.client();
const clientNewPassword = new jsrp.client();
type Params = {
email: string;
currentPassword: string;
newPassword: string;
}
const attemptChangePassword = ({ email, currentPassword, newPassword }: Params): Promise<void> => {
return new Promise((resolve, reject) => {
clientOldPassword.init({ username: email, password: currentPassword }, async () => {
let serverPublicKey; let salt;
try {
const clientPublicKey = clientOldPassword.getPublicKey();
const res = await SRP1({ clientPublicKey });
serverPublicKey = res.serverPublicKey;
salt = res.salt;
clientOldPassword.setSalt(salt);
clientOldPassword.setServerPublicKey(serverPublicKey);
const clientProof = clientOldPassword.getProof();
clientNewPassword.init({ username: email, password: newPassword }, async () => {
clientNewPassword.createVerifier(async (err, result) => {
try {
const derivedKey = await deriveArgonKey({
password: newPassword,
salt: result.salt,
mem: 65536,
time: 3,
parallelism: 1,
hashLen: 32
});
if (!derivedKey) throw new Error("Failed to derive key from password");
const key = crypto.randomBytes(32);
const {
ciphertext: encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
} = Aes256Gcm.encrypt({
text: localStorage.getItem("PRIVATE_KEY") as string,
secret: key
});
const {
ciphertext: protectedKey,
iv: protectedKeyIV,
tag: protectedKeyTag
} = Aes256Gcm.encrypt({
text: key.toString("hex"),
secret: Buffer.from(derivedKey.hash)
});
await changePassword2({
clientProof,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
salt: result.salt,
verifier: result.verifier
});
saveTokenToLocalStorage({
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
});
resolve();
} catch (err2) {
console.error(err2);
reject(err2);
}
});
});
} catch (err) {
console.error(err);
reject(err);
}
});
});
}
export default attemptChangePassword;

View File

@@ -1,7 +1,6 @@
export {
useGetAuthToken,
useGetCommonPasswords,
useRevokeAllSessions,
useSendMfaToken,
useSendMfaToken,
useVerifyMfaToken
} from "./queries";
} from "./queries"

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 || [];

View File

@@ -81,76 +81,79 @@ export const useGetProjectSecrets = ({
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
select: useCallback((data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey;
const key = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const sharedSecrets: DecryptedSecret[] = [];
const personalSecrets: Record<string, { id: string; value: string }> = {};
// this used for add-only mode in dashboard
// type won't be there thus only one key is shown
const duplicateSecretKey: Record<string, boolean> = {};
data.forEach((encSecret: EncryptedSecret) => {
const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext,
iv: encSecret.secretKeyIV,
tag: encSecret.secretKeyTag,
key
select: useCallback(
(data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey;
const key = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext,
iv: encSecret.secretValueIV,
tag: encSecret.secretValueTag,
key
});
const sharedSecrets: DecryptedSecret[] = [];
const personalSecrets: Record<string, { id: string; value: string }> = {};
// this used for add-only mode in dashboard
// type won't be there thus only one key is shown
const duplicateSecretKey: Record<string, boolean> = {};
data.forEach((encSecret: EncryptedSecret) => {
const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext,
iv: encSecret.secretKeyIV,
tag: encSecret.secretKeyTag,
key
});
const secretComment = decryptSymmetric({
ciphertext: encSecret.secretCommentCiphertext,
iv: encSecret.secretCommentIV,
tag: encSecret.secretCommentTag,
key
});
const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext,
iv: encSecret.secretValueIV,
tag: encSecret.secretValueTag,
key
});
const decryptedSecret = {
_id: encSecret._id,
env: encSecret.environment,
key: secretKey,
value: secretValue,
tags: encSecret.tags,
comment: secretComment,
createdAt: encSecret.createdAt,
updatedAt: encSecret.updatedAt
};
const secretComment = decryptSymmetric({
ciphertext: encSecret.secretCommentCiphertext,
iv: encSecret.secretCommentIV,
tag: encSecret.secretCommentTag,
key
});
if (encSecret.type === "personal") {
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
id: encSecret._id,
value: secretValue
const decryptedSecret = {
_id: encSecret._id,
env: encSecret.environment,
key: secretKey,
value: secretValue,
tags: encSecret.tags,
comment: secretComment,
createdAt: encSecret.createdAt,
updatedAt: encSecret.updatedAt
};
} else {
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
sharedSecrets.push(decryptedSecret);
if (encSecret.type === "personal") {
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
id: encSecret._id,
value: secretValue
};
} else {
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
sharedSecrets.push(decryptedSecret);
}
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
}
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
}
});
sharedSecrets.forEach((val) => {
const dupKey = `${val.key}-${val.env}`;
if (personalSecrets?.[dupKey]) {
val.idOverride = personalSecrets[dupKey].id;
val.valueOverride = personalSecrets[dupKey].value;
val.overrideAction = "modified";
}
});
return { secrets: sharedSecrets };
}, [decryptFileKey])
});
sharedSecrets.forEach((val) => {
const dupKey = `${val.key}-${val.env}`;
if (personalSecrets?.[dupKey]) {
val.idOverride = personalSecrets[dupKey].id;
val.valueOverride = personalSecrets[dupKey].value;
val.overrideAction = "modified";
}
});
return { secrets: sharedSecrets };
},
[decryptFileKey]
)
});
export const useGetProjectSecretsByKey = ({
@@ -167,82 +170,85 @@ export const useGetProjectSecretsByKey = ({
// right now secretpath is passed as folderid as only this is used in overview
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
select: useCallback((data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey;
const key = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const sharedSecrets: Record<string, DecryptedSecret[]> = {};
const personalSecrets: Record<string, { id: string; value: string }> = {};
// this used for add-only mode in dashboard
// type won't be there thus only one key is shown
const duplicateSecretKey: Record<string, boolean> = {};
const uniqSecKeys: Record<string, boolean> = {};
data.forEach((encSecret: EncryptedSecret) => {
const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext,
iv: encSecret.secretKeyIV,
tag: encSecret.secretKeyTag,
key
});
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext,
iv: encSecret.secretValueIV,
tag: encSecret.secretValueTag,
key
select: useCallback(
(data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey;
const key = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const secretComment = decryptSymmetric({
ciphertext: encSecret.secretCommentCiphertext,
iv: encSecret.secretCommentIV,
tag: encSecret.secretCommentTag,
key
});
const sharedSecrets: Record<string, DecryptedSecret[]> = {};
const personalSecrets: Record<string, { id: string; value: string }> = {};
// this used for add-only mode in dashboard
// type won't be there thus only one key is shown
const duplicateSecretKey: Record<string, boolean> = {};
const uniqSecKeys: Record<string, boolean> = {};
data.forEach((encSecret: EncryptedSecret) => {
const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext,
iv: encSecret.secretKeyIV,
tag: encSecret.secretKeyTag,
key
});
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
const decryptedSecret = {
_id: encSecret._id,
env: encSecret.environment,
key: secretKey,
value: secretValue,
tags: encSecret.tags,
comment: secretComment,
createdAt: encSecret.createdAt,
updatedAt: encSecret.updatedAt
};
const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext,
iv: encSecret.secretValueIV,
tag: encSecret.secretValueTag,
key
});
if (encSecret.type === "personal") {
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
id: encSecret._id,
value: secretValue
const secretComment = decryptSymmetric({
ciphertext: encSecret.secretCommentCiphertext,
iv: encSecret.secretCommentIV,
tag: encSecret.secretCommentTag,
key
});
const decryptedSecret = {
_id: encSecret._id,
env: encSecret.environment,
key: secretKey,
value: secretValue,
tags: encSecret.tags,
comment: secretComment,
createdAt: encSecret.createdAt,
updatedAt: encSecret.updatedAt
};
} else {
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = [];
sharedSecrets[secretKey].push(decryptedSecret);
}
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
}
});
Object.keys(sharedSecrets).forEach((secName) => {
sharedSecrets[secName].forEach((val) => {
const dupKey = `${val.key}-${val.env}`;
if (personalSecrets?.[dupKey]) {
val.idOverride = personalSecrets[dupKey].id;
val.valueOverride = personalSecrets[dupKey].value;
val.overrideAction = "modified";
if (encSecret.type === "personal") {
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
id: encSecret._id,
value: secretValue
};
} else {
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = [];
sharedSecrets[secretKey].push(decryptedSecret);
}
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
}
});
});
Object.keys(sharedSecrets).forEach((secName) => {
sharedSecrets[secName].forEach((val) => {
const dupKey = `${val.key}-${val.env}`;
if (personalSecrets?.[dupKey]) {
val.idOverride = personalSecrets[dupKey].id;
val.valueOverride = personalSecrets[dupKey].value;
val.overrideAction = "modified";
}
});
});
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
}, [decryptFileKey])
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
},
[decryptFileKey]
)
});
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
@@ -263,29 +269,32 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
enabled: Boolean(dto.secretId && dto.decryptFileKey),
queryKey: secretKeys.getSecretVersion(dto.secretId),
queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit),
select: useCallback((data: EncryptedSecretVersion[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = dto.decryptFileKey;
const key = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
select: useCallback(
(data: EncryptedSecretVersion[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = dto.decryptFileKey;
const key = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
return data
.map((el) => ({
createdAt: el.createdAt,
id: el._id,
value: decryptSymmetric({
ciphertext: el.secretValueCiphertext,
iv: el.secretValueIV,
tag: el.secretValueTag,
key
})
}))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [])
return data
.map((el) => ({
createdAt: el.createdAt,
id: el._id,
value: decryptSymmetric({
ciphertext: el.secretValueCiphertext,
iv: el.secretValueIV,
tag: el.secretValueTag,
key
})
}))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
},
[dto.decryptFileKey]
)
});
export const useBatchSecretsOp = () => {

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";

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);
}
});
}

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;
}

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";

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;
};

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>
<NavHeader pageName="Audit Logs" isProjectRelated />
<div className="ml-6">
<NavHeader pageName="Audit Logs" isProjectRelated />
</div>
{currentSidebarAction && (
<ActivitySideBar toggleSidebar={toggleSidebar} currentAction={currentSidebarAction} />
)}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

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>
);
}

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>
<NavHeader pageName={t("settings.members.title")} isProjectRelated />
<div className="ml-6">
<NavHeader pageName={t("settings.members.title")} isProjectRelated />
</div>
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4">
<p className="mr-4 font-semibold text-white">{t("settings.members.title")}</p>
</div>

View File

@@ -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">

View File

@@ -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={

View File

@@ -1,8 +1,11 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useState } from "react";
import { faCircle, faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-icons";
import { useCallback, useRef, useState } from "react";
import { faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useSyntaxHighlight } from "@app/hooks";
import { useToggle } from "~/hooks/useToggle";
type Props = {
secrets: any[] | undefined;
@@ -12,7 +15,8 @@ type Props = {
userAvailableEnvs?: any[];
};
const REGEX = /([$]{.*?})/g;
const SEC_VAL_LINE_HEIGHT = 21;
const MAX_MULTI_LINE = 6;
const DashboardInput = ({
isOverridden,
@@ -25,84 +29,60 @@ const DashboardInput = ({
isReadOnly?: boolean;
secret?: any;
}): JSX.Element => {
const syntaxHighlight = useCallback((val: string) => {
if (val === undefined)
return (
<span className="cursor-default font-sans text-xs italic text-red-500/80">
<FontAwesomeIcon icon={faMinus} className="mt-1" />
</span>
);
if (val?.length === 0)
return <span className="w-full font-sans text-bunker-400/80">EMPTY</span>;
return val?.split(REGEX).map((word, index) =>
word.match(REGEX) !== null ? (
<span className="ph-no-capture text-yellow" key={`${val}-${index + 1}`}>
{word.slice(0, 2)}
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
{word.slice(word.length - 1, word.length) === "}" ? (
<span className="ph-no-capture text-yellow">
{word.slice(word.length - 1, word.length)}
</span>
) : (
<span className="ph-no-capture text-yellow-400">
{word.slice(word.length - 1, word.length)}
</span>
)}
</span>
) : (
<span key={word} className="ph-no-capture">
{word}
</span>
)
);
}, []);
const ref = useRef<HTMLElement | null>(null);
const [isFocused, setIsFocused] = useToggle();
const syntaxHighlight = useSyntaxHighlight();
const value = isOverridden ? secret.valueOverride : secret?.value;
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
return (
<td
key={`row-${secret?.key || ""}--`}
className={`flex h-10 w-full cursor-default flex-row items-center justify-center ${
className={`flex w-full cursor-default flex-row ${
!(secret?.value || secret?.value === "") ? "bg-red-800/10" : "bg-mineshaft-900/30"
}`}
>
<div className="group relative flex w-full cursor-default flex-col justify-center whitespace-pre">
<input
value={isOverridden ? secret.valueOverride : secret?.value || ""}
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
<textarea
readOnly={isReadOnly}
className={twMerge(
"ph-no-capture no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full cursor-default bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-transparent outline-none no-scrollbar",
isSecretValueHidden && "text-transparent focus:text-transparent active:text-transparent"
)}
value={value}
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
style={{ height: `${maxMultilineHeight}px` }}
spellCheck="false"
onBlur={() => setIsFocused.off()}
onFocus={() => setIsFocused.on()}
onInput={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
onScroll={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
/>
<div
className={twMerge(
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full cursor-default flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible",
isSecretValueHidden && secret?.value ? "invisible" : "visible",
isSecretValueHidden &&
secret?.value &&
"duration-50 text-bunker-800 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400",
!secret?.value && "justify-center text-bunker-400"
)}
>
{syntaxHighlight(secret?.value)}
</div>
{isSecretValueHidden && secret?.value && (
<div className="duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden">
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
{(isOverridden ? secret.valueOverride : secret?.value || "")
?.split("")
.map((_a: string, index: number) => (
<FontAwesomeIcon
key={`${secret?.value}_${index + 1}`}
className="mr-0.5 text-xxs"
icon={faCircle}
/>
))}
{(isOverridden ? secret.valueOverride : secret?.value || "")?.split("").length ===
0 && <span className="text-sm text-bunker-400/80">EMPTY</span>}
</div>
</div>
)}
<pre className="whitespace-pre-wrap break-words">
<code
ref={ref}
className={`absolute top-1.5 left-3.5 z-10 overflow-auto font-mono text-sm transition-all no-scrollbar ${
isOverridden && "text-primary-300"
}`}
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
>
{value === undefined ? (
<span className="cursor-default font-sans text-xs italic text-red-500/80">
<FontAwesomeIcon icon={faMinus} className="mt-1" />
</span>
) : (
syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)
)}
</code>
</pre>
</div>
</td>
);
@@ -122,14 +102,14 @@ export const EnvComparisonRow = ({
);
return (
<tr className="group flex min-w-full flex-row items-center hover:bg-mineshaft-800">
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-bunker-400">
<tr className="group flex min-w-full flex-row hover:bg-mineshaft-800">
<td className="flex w-10 justify-center border-none px-4">
<div className="flex h-8 w-10 items-center justify-center text-center text-xs text-bunker-400">
<FontAwesomeIcon icon={faKey} />
</div>
</td>
<td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
<div className="flex h-8 cursor-default flex-row items-center truncate">
<td className="flex min-w-[200px] flex-row justify-between lg:min-w-[220px] xl:min-w-[250px]">
<div className="flex h-8 cursor-default flex-row items-center justify-center truncate">
{secrets![0].key || ""}
</div>
<button

View File

@@ -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>
))}

View File

@@ -1,8 +1,7 @@
import { useCallback } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { faCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useRef } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useSyntaxHighlight, useToggle } from "@app/hooks";
import { FormData } from "../../DashboardPage.utils";
@@ -13,95 +12,94 @@ type Props = {
index: number;
};
const REGEX = /([$]{.*?})/g;
const SEC_VAL_LINE_HEIGHT = 21;
const MAX_MULTI_LINE = 6;
export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridden }: Props) => {
const { register, control } = useFormContext<FormData>();
const { control } = useFormContext<FormData>();
const ref = useRef<HTMLElement | null>(null);
const [isFocused, setIsFocused] = useToggle();
const syntaxHighlight = useSyntaxHighlight();
const secretValue = useWatch({ control, name: `secrets.${index}.value` });
const secretValueOverride = useWatch({ control, name: `secrets.${index}.valueOverride` });
const value = isOverridden ? secretValueOverride : secretValue;
const syntaxHighlight = useCallback((val: string) => {
if (val?.length === 0) return <span className="font-sans text-bunker-400/80">EMPTY</span>;
return val?.split(REGEX).map((word, i) =>
word.match(REGEX) !== null ? (
<span className="ph-no-capture text-yellow" key={`${val}-${i + 1}`}>
{word.slice(0, 2)}
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
{word.slice(word.length - 1, word.length) === "}" ? (
<span className="ph-no-capture text-yellow">
{word.slice(word.length - 1, word.length)}
</span>
) : (
<span className="ph-no-capture text-yellow-400">
{word.slice(word.length - 1, word.length)}
</span>
)}
</span>
) : (
<span key={`${word}_${i + 1}`} className="ph-no-capture">
{word}
</span>
)
);
}, []);
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
return (
<div className="group relative flex w-full flex-col justify-center whitespace-pre px-1.5">
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
{isOverridden ? (
<input
{...register(`secrets.${index}.valueOverride`)}
readOnly={isReadOnly}
className={twMerge(
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar",
!isSecretValueHidden &&
"text-transparent focus:text-transparent active:text-transparent"
<Controller
control={control}
name={`secrets.${index}.valueOverride`}
render={({ field }) => (
<textarea
key={`secrets.${index}.valueOverride`}
{...field}
readOnly={isReadOnly}
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
style={{ height: `${maxMultilineHeight}px` }}
spellCheck="false"
onBlur={() => setIsFocused.off()}
onFocus={() => setIsFocused.on()}
onInput={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
onScroll={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
/>
)}
spellCheck="false"
/>
) : (
<input
{...register(`secrets.${index}.value`)}
readOnly={isReadOnly}
className={twMerge(
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar",
!isSecretValueHidden &&
"text-transparent focus:text-transparent active:text-transparent"
<Controller
control={control}
name={`secrets.${index}.value`}
key={`secrets.${index}.value`}
render={({ field }) => (
<textarea
{...field}
readOnly={isReadOnly}
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
style={{ height: `${maxMultilineHeight}px` }}
spellCheck="false"
onBlur={() => setIsFocused.off()}
onFocus={() => setIsFocused.on()}
onInput={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
onScroll={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
/>
)}
spellCheck="false"
/>
)}
<div
className={twMerge(
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible",
isSecretValueHidden ? "invisible" : "visible",
isOverridden
? "text-primary-300"
: "duration-50 text-gray-400 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400"
)}
>
{syntaxHighlight(value || "")}
</div>
<div
className={twMerge(
"duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden",
!isSecretValueHidden ? "invisible" : "visible"
)}
>
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
{value?.split("").map((val, i) => (
<FontAwesomeIcon
key={`${value}_${val}_${i + 1}`}
className="mr-0.5 text-xxs"
icon={faCircle}
/>
))}
{value?.split("").length === 0 && (
<span className="text-sm text-bunker-400/80">EMPTY</span>
)}
</div>
</div>
<pre className="whitespace-pre-wrap break-words">
<code
ref={ref}
className={`absolute top-1.5 left-3.5 z-10 w-full overflow-auto font-mono text-sm transition-all no-scrollbar ${
isOverridden && "text-primary-300"
}`}
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
>
{syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)}
</code>
</pre>
</div>
);
};

View File

@@ -157,7 +157,7 @@ export const SecretInputRow = memo(
}
return (
<tr className="group flex flex-row items-center" key={index}>
<tr className="group flex flex-row" key={index}>
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
</td>
@@ -198,7 +198,7 @@ export const SecretInputRow = memo(
</HoverCard>
)}
/>
<td className="flex h-10 w-full flex-grow flex-row items-center justify-center border-r border-none border-red">
<td className="flex w-full flex-grow flex-row border-r border-none border-red">
<MaskedInput
isReadOnly={
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
@@ -208,8 +208,8 @@ export const SecretInputRow = memo(
index={index}
/>
</td>
<td className="min-w-sm flex h-10 items-center">
<div className="flex items-center pl-2">
<td className="min-w-sm flex">
<div className="flex h-8 items-center pl-2">
{secretTags.map(({ id, slug }, i) => (
<Tag
className={cx(
@@ -289,7 +289,7 @@ export const SecretInputRow = memo(
</div>
)}
</div>
<div className="flex h-full flex-row items-center pr-2">
<div className="flex h-8 flex-row items-center pr-2">
{!isAddOnly && (
<div>
<Tooltip content="Override with a personal value">
@@ -346,35 +346,37 @@ export const SecretInputRow = memo(
</div>
</Tooltip>
</div>
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
{!isAddOnly && (
<div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all">
<div className="flex h-8 items-center space-x-2.5">
{!isAddOnly && (
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Settings">
<IconButton
size="lg"
colorSchema="primary"
variant="plain"
onClick={onRowExpand}
ariaLabel="expand"
>
<FontAwesomeIcon icon={faEllipsis} />
</IconButton>
</Tooltip>
</div>
)}
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Settings">
<Tooltip content="Delete">
<IconButton
size="lg"
colorSchema="primary"
size="md"
variant="plain"
onClick={onRowExpand}
ariaLabel="expand"
colorSchema="danger"
ariaLabel="delete"
isDisabled={isReadOnly || isRollbackMode}
onClick={() => onSecretDelete(index, secId, idOverride)}
>
<FontAwesomeIcon icon={faEllipsis} />
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Tooltip>
</div>
)}
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Delete">
<IconButton
size="md"
variant="plain"
colorSchema="danger"
ariaLabel="delete"
isDisabled={isReadOnly || isRollbackMode}
onClick={() => onSecretDelete(index, secId, idOverride)}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Tooltip>
</div>
</div>
</td>

View File

@@ -178,7 +178,9 @@ export const IntegrationsPage = ({ frameworkIntegrations }: Props) => {
return (
<div className="container mx-auto max-w-7xl px-8 pb-12 text-white">
<NavHeader pageName={t("integrations.title")} isProjectRelated />
<div className="ml-4">
<NavHeader pageName={t("integrations.title")} isProjectRelated />
</div>
<IntegrationsSection
isLoading={isIntegrationLoading}
integrations={integrations}

View File

@@ -9,16 +9,13 @@ import {
export const BillingSettingsPage = () => {
const { t } = useTranslation();
return (
<div className="h-full py-8 px-4">
<NavHeader pageName={t("billing.title")} />
<div className="ml-4 flex text-3xl mt-8 items-start max-w-screen-lg">
<div className="flex-1">
<p className="font-semibold text-gray-200">{t("billing.title")}</p>
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
<div className="max-w-screen-lg w-full">
<NavHeader pageName={t("billing.title")} />
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("billing.title")}</p>
<div />
</div>
<div />
</div>
<div className="ml-4">
<BillingTabGroup />
</div>
</div>

View File

@@ -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>

View File

@@ -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">

View File

@@ -3,9 +3,11 @@ import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import Button from "@app/components/basic/buttons/Button";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { FormControl,Input } from "@app/components/v2";
import {
Button,
FormControl,
Input} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useGetOrgBillingDetails,
@@ -26,7 +28,7 @@ export const CompanyNameSection = () => {
resolver: yupResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
@@ -40,7 +42,7 @@ export const CompanyNameSection = () => {
try {
if (!currentOrg?._id) return;
if (name === "") return;
await updateOrgBillingDetails.mutateAsync({
await mutateAsync({
name,
organizationId: currentOrg._id
});
@@ -61,7 +63,7 @@ export const CompanyNameSection = () => {
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
Business name
@@ -82,15 +84,14 @@ export const CompanyNameSection = () => {
name="name"
/>
</div>
<div className="inline-block">
<Button
text="Save"
type="submit"
color="mineshaft"
size="md"
onButtonPressed={() => console.log("Saved company name")}
/>
</div>
<Button
type="submit"
colorSchema="secondary"
isLoading={isLoading}
isDisabled={isLoading}
>
Save
</Button>
</form>
);
}

View File

@@ -3,9 +3,9 @@ import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import Button from "@app/components/basic/buttons/Button";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
Input} from "@app/components/v2";
import { useOrganization } from "@app/context";
@@ -28,7 +28,7 @@ export const InvoiceEmailSection = () => {
resolver: yupResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
@@ -43,7 +43,7 @@ export const InvoiceEmailSection = () => {
if (!currentOrg?._id) return;
if (email === "") return;
await updateOrgBillingDetails.mutateAsync({
await mutateAsync({
email,
organizationId: currentOrg._id
});
@@ -64,7 +64,7 @@ export const InvoiceEmailSection = () => {
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-white mb-8">
Invoice email recipient
@@ -85,15 +85,13 @@ export const InvoiceEmailSection = () => {
name="email"
/>
</div>
<div className="inline-block">
<Button
text="Save"
type="submit"
color="mineshaft"
size="md"
onButtonPressed={() => console.log("Saved email address")}
/>
</div>
<Button
type="submit"
colorSchema="secondary"
isLoading={isLoading}
>
Save
</Button>
</form>
);
}

View File

@@ -1,6 +1,10 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import Button from "@app/components/basic/buttons/Button";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useAddOrgPmtMethod } from "@app/hooks/api";
@@ -8,11 +12,11 @@ import { PmtMethodsTable } from "./PmtMethodsTable";
export const PmtMethodsSection = () => {
const { currentOrg } = useOrganization();
const addOrgPmtMethod = useAddOrgPmtMethod();
const { mutateAsync, isLoading } = useAddOrgPmtMethod();
const handleAddPmtMethodBtnClick = async () => {
if (!currentOrg?._id) return;
const url = await addOrgPmtMethod.mutateAsync({
const url = await mutateAsync({
organizationId: currentOrg._id,
success_url: window.location.href,
cancel_url: window.location.href
@@ -22,21 +26,19 @@ export const PmtMethodsSection = () => {
}
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex items-center mb-8">
<h2 className="text-xl font-semibold flex-1 text-white">
Payment Methods
</h2>
<div className="inline-block">
<Button
text="Add method"
type="submit"
color="mineshaft"
size="md"
icon={faPlus}
onButtonPressed={handleAddPmtMethodBtnClick}
/>
</div>
<Button
onClick={handleAddPmtMethodBtnClick}
colorSchema="secondary"
isLoading={isLoading}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add method
</Button>
</div>
<PmtMethodsTable />
</div>

View File

@@ -1,6 +1,8 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import Button from "@app/components/basic/buttons/Button";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
import { usePopUp } from "@app/hooks/usePopUp";
import { TaxIDModal } from "./TaxIDModal";
@@ -12,21 +14,18 @@ export const TaxIDSection = () => {
] as const);
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex items-center mb-8">
<h2 className="text-xl font-semibold flex-1 text-white">
Tax ID
</h2>
<div className="inline-block">
<Button
text="Add Tax ID"
type="submit"
color="mineshaft"
size="md"
icon={faPlus}
onButtonPressed={() => handlePopUpOpen("addTaxID")}
/>
</div>
<Button
onClick={() => handlePopUpOpen("addTaxID")}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add method
</Button>
</div>
<TaxIDTable />
<TaxIDModal

View File

@@ -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>

View File

@@ -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 }) => (

View File

@@ -1,301 +1,66 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
import {
useAddIncidentContact,
useAddUserToOrg,
useDeleteIncidentContact,
useDeleteOrgMembership,
useGetOrgIncidentContact,
useGetOrgUsers,
useGetUserWorkspaceMemberships,
useGetUserWsKey,
useRenameOrg,
useUpdateOrgUserRole,
useUploadWsKey
} from "@app/hooks/api";
import {
OrgIncidentContactsTable,
OrgMembersTable,
OrgIncidentContactsSection,
OrgMembersSection,
OrgNameChangeSection,
OrgServiceAccountsTable
} from "./components";
export const OrgSettingsPage = () => {
const { t } = useTranslation();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const { subscription } = useSubscription();
const { createNotification } = useNotificationContext();
const orgId = currentOrg?._id || "";
const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
useGetUserWorkspaceMemberships(orgId);
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
const { data: incidentContact, isLoading: IsIncidentContactLoading } =
useGetOrgIncidentContact(orgId);
const renameOrg = useRenameOrg();
const removeUserOrgMembership = useDeleteOrgMembership();
const addUserToOrg = useAddUserToOrg();
const updateOrgUserRole = useUpdateOrgUserRole();
const uploadWsKey = useUploadWsKey();
const addIncidentContact = useAddIncidentContact();
const removeIncidentContact = useDeleteIncidentContact();
const [completeInviteLink, setcompleteInviteLink] = useState<string | undefined>("");
const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false;
const onRenameOrg = async (name: string) => {
if (!currentOrg?._id) return;
try {
await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
createNotification({
text: "Successfully renamed organization",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to rename organization",
type: "error"
});
}
};
const onRemoveUserOrgMembership = async (membershipId: string) => {
if (!currentOrg?._id) return;
try {
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
createNotification({
text: "Successfully removed user from org",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
};
const onAddUserToOrg = async (email: string) => {
if (!currentOrg?._id) return;
try {
const { data } = await addUserToOrg.mutateAsync({
organizationId: currentOrg?._id,
inviteeEmail: email
});
setcompleteInviteLink(data?.completeInviteLink);
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
if (!data.completeInviteLink) {
createNotification({
text: "Successfully invited user to the organization.",
type: "success"
});
}
} catch (error) {
console.error(error);
createNotification({
text: "Failed to invite user to org",
type: "error"
});
}
};
const onUpdateOrgUserRole = async (membershipId: string, role: string) => {
if (!currentOrg?._id) return;
try {
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const onGrantUserAccess = async (userId: string, publicKey: string) => {
try {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
if (!PRIVATE_KEY || !wsKey) return;
// assymmetrically decrypt symmetric key with local private key
const key = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey.mutateAsync({
userId,
nonce,
encryptedKey: ciphertext,
workspaceId: currentWorkspace?._id || ""
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to grant access to user",
type: "error"
});
}
};
const onAddIncidentContact = async (email: string) => {
if (!currentOrg?._id) return;
try {
await addIncidentContact.mutateAsync({ orgId, email });
createNotification({
text: "Successfully added incident contact",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to add incident contact",
type: "error"
});
}
};
const onRemoveIncidentContact = async (email: string) => {
if (!currentOrg?._id) return;
try {
await removeIncidentContact.mutateAsync({ orgId, email });
createNotification({
text: "Successfully removed incident contact",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove incident contact",
type: "error"
});
}
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
<div className="max-w-screen-lg w-full">
<NavHeader pageName={t("settings.org.title")} />
<div className="my-8 ml-8 max-w-5xl">
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
<p className="text-base font-normal text-gray-400">{t("settings.org.description")}</p>
</div>
<div className="max-w-8xl ml-6 mr-6 flex flex-col text-mineshaft-50">
<OrgNameChangeSection orgName={currentOrg?.name} onOrgNameChange={onRenameOrg} />
<div className="mb-6 w-full rounded-md bg-white/5 p-6">
<p className="mr-4 mb-4 text-xl font-semibold text-white">
{t("section.members.org-members")}
</p>
<OrgMembersTable
isLoading={isOrgUserLoading || IsWsMembershipLoading}
isMoreUserNotAllowed={isMoreUsersNotAllowed}
orgName={currentOrg?.name || ""}
members={orgUsers}
workspaceMemberships={workspaceMemberships}
onInviteMember={onAddUserToOrg}
userId={user?._id || ""}
onRemoveMember={onRemoveUserOrgMembership}
onRoleChange={onUpdateOrgUserRole}
onGrantAccess={onGrantUserAccess}
completeInviteLink={completeInviteLink}
setCompleteInviteLink={setcompleteInviteLink}
/>
</div>
<div className="mb-6 mt-2 w-full rounded-md bg-white/5 p-6">
<p className="mr-4 mb-4 text-xl font-semibold text-white">Service Accounts</p>
<OrgNameChangeSection />
<OrgMembersSection />
<OrgIncidentContactsSection />
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<OrgServiceAccountsTable />
</div>
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
<div className="flex w-full max-w-3xl flex-col justify-between">
<p className="mb-3 min-w-max text-xl font-semibold">
{t("section.incident.incident-contacts")}
</p>
<p className="mb-2 min-w-max text-xs text-gray-500">
{t("section.incident.incident-contacts-description")}
</p>
</div>
</div>
<div className="w-full">
<OrgIncidentContactsTable
isLoading={IsIncidentContactLoading}
contacts={incidentContact}
onRemoveContact={onRemoveIncidentContact}
onAddContact={onAddIncidentContact}
/>
</div>
</div>
{/* <div className="border-l border-red pb-4 pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pt-2 max-w-6xl">
<p className="text-xl font-bold text-red">
Danger Zone
</p>
<p className="mt-4 text-md text-gray-400">
As soon as you delete an organization, you will
not be able to undo it. This will immediately
remove all organization members and cancel your
subscription. If you still want to do that,
please enter the name of the organization below.
</p>
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
<InputField
label="Organization to be Deleted"
onChangeHandler={
setWorkspaceToBeDeletedName
}
type="varName"
value={workspaceToBeDeletedName}
placeholder=""
isRequired
/>
</div>
<button
type="button"
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={executeDeletingWorkspace}
>
Delete Project
</button>
<p className="mt-0.5 ml-1 text-xs text-gray-500">
Note: You can only delete a project in case you
have more than one.
</p>
</div> */}
</div>
<p className="text-xl font-bold text-red">
Danger Zone
</p>
<p className="mt-4 text-md text-gray-400">
As soon as you delete an organization, you will
not be able to undo it. This will immediately
remove all organization members and cancel your
subscription. If you still want to do that,
please enter the name of the organization below.
</p>
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
<InputField
label="Organization to be Deleted"
onChangeHandler={
setWorkspaceToBeDeletedName
}
type="varName"
value={workspaceToBeDeletedName}
placeholder=""
isRequired
/>
</div>
<button
type="button"
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={executeDeletingWorkspace}
>
Delete Project
</button>
<p className="mt-0.5 ml-1 text-xs text-gray-500">
Note: You can only delete a project in case you
have more than one.
</p>
</div> */}
</div>
</div>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -0,0 +1,2 @@
export { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal"
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";

View File

@@ -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>
);
};

View File

@@ -1 +0,0 @@
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,20 +1,19 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMemo, useState } from "react";
import { useRouter } from "next/router";
import { faCheck, faCopy, faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
EmailServiceSetupModal, EmptyState,
FormControl,
EmptyState,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Table,
@@ -25,73 +24,63 @@ import {
Td,
Th,
THead,
Tr,
UpgradePlanModal} from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks";
Tr
} from "@app/components/v2";
import { useOrganization, useUser, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useAddUserToOrg,
useDeleteOrgMembership,
useGetOrgUsers,
useGetUserWorkspaceMemberships,
useGetUserWsKey,
useUpdateOrgUserRole,
useUploadWsKey
} from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { OrgUser, Workspace } from "@app/hooks/api/types";
type Props = {
members?: OrgUser[];
workspaceMemberships?: Record<string, Workspace[]>;
orgName: string;
isLoading?: boolean;
isMoreUserNotAllowed: boolean;
onRemoveMember: (userId: string) => Promise<void>;
onInviteMember: (email: string) => Promise<void>;
onRoleChange: (membershipId: string, role: string) => Promise<void>;
onGrantAccess: (userId: string, publicKey: string) => Promise<void>;
// the current user id to block remove org button
userId: string;
completeInviteLink: string | undefined,
setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>>
};
const addMemberFormSchema = yup.object({
email: yup.string().email().required().label("Email").trim()
});
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
export const OrgMembersTable = ({
members = [],
workspaceMemberships = {},
orgName,
isMoreUserNotAllowed,
onRemoveMember,
onInviteMember,
onGrantAccess,
onRoleChange,
userId,
isLoading,
completeInviteLink,
setCompleteInviteLink
}: Props) => {
export const OrgMembersTable = () => {
const router = useRouter();
const { user: currentUser } = useUser();
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const { createNotification } = useNotificationContext();
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const {data: serverDetails } = useFetchServerStatus()
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const { data: serverDetails } = useFetchServerStatus()
const { data: members, isLoading: isOrgUserLoading } = useGetOrgUsers(currentOrg?._id ?? ""); // members
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = useGetUserWorkspaceMemberships(currentOrg?._id ?? "");
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
const uploadWsKey = useUploadWsKey();
const addUserToOrg = useAddUserToOrg();
const removeUserOrgMembership = useDeleteOrgMembership();
const updateOrgUserRole = useUpdateOrgUserRole();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
"removeMember",
"upgradePlan",
"setUpEmail"
] as const);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
const isLoading = isOrgUserLoading || IsWsMembershipLoading;
const userId = currentUser?._id || "";
const onRemoveMember = async (membershipId: string) => {
if (!currentOrg?._id) return;
const onAddMember = async ({ email }: TAddMemberForm) => {
await onInviteMember(email);
if (serverDetails?.emailConfigured){
handlePopUpClose("addMember");
}
reset();
try {
if (!currentOrg?._id) return;
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
createNotification({
text: "Successfully removed user from org",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
};
const onRemoveOrgMemberApproved = async () => {
@@ -100,62 +89,111 @@ export const OrgMembersTable = ({
handlePopUpClose("removeMember");
};
const onInviteMember = async (email: string) => {
if (!currentOrg?._id) return;
try {
const { data } = await addUserToOrg.mutateAsync({
organizationId: currentOrg?._id,
inviteeEmail: email
});
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
if (!data.completeInviteLink) {
createNotification({
text: "Successfully invited user to the organization.",
type: "success"
});
}
} catch (error) {
console.error(error);
createNotification({
text: "Failed to invite user to org",
type: "error"
});
}
};
const onGrantAccess = async (targetUserId: string, publicKey: string) => {
try {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
if (!PRIVATE_KEY || !wsKey) return;
// assymmetrically decrypt symmetric key with local private key
const key = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey.mutateAsync({
userId: targetUserId,
nonce,
encryptedKey: ciphertext,
workspaceId: currentWorkspace?._id || ""
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to grant access to user",
type: "error"
});
}
};
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?._id) return;
try {
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const isIamOwner = useMemo(
() => members.find(({ user }) => userId === user?._id)?.role === "owner",
() => members ? members.find(({ user }) => userId === user?._id)?.role === "owner" : [],
[userId, members]
);
const filterdUser = useMemo(
() =>
members.filter(
members ? members.filter(
({ user, inviteEmail }) =>
user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
user?.email?.toLowerCase().includes(searchMemberFilter) ||
inviteEmail?.includes(searchMemberFilter)
),
) : [],
[members, searchMemberFilter]
);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isInviteLinkCopied) {
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isInviteLinkCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(completeInviteLink as string);
setInviteLinkCopied.on();
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreUserNotAllowed) {
handlePopUpOpen("upgradePlan");
} else {
handlePopUpOpen("addMember");
}
}}
>
Add Member
</Button>
</div>
<div>
<TableContainer>
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
@@ -167,7 +205,7 @@ export const OrgMembersTable = ({
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={5} key="org-members" />}
{isLoading && <TableSkeleton columns={5} key="org-memberships" />}
{!isLoading &&
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
const name = user ? `${user.firstName} ${user.lastName}` : "-";
@@ -213,7 +251,7 @@ export const OrgMembersTable = ({
<Td>
{userWs ? (
userWs?.map(({ name: wsName, _id }) => (
<Tag key={`user-${user._id}-workspace-${_id}`} className="my-1">
<Tag key={`user-${currentUser._id}-workspace-${_id}`} className="my-1">
{wsName}
</Tag>
))
@@ -254,74 +292,6 @@ export const OrgMembersTable = ({
<EmptyState title="No project members found" icon={faUsers} />
)}
</TableContainer>
</div>
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addMember", isOpen);
setCompleteInviteLink(undefined)
}}
>
<ModalContent
title={`Invite others to ${orgName}`}
subTitle={
<div>
{!completeInviteLink && <div>
An invite is specific to an email address and expires after 1 day.
<br />
For security reasons, you will need to separately add members to projects.
</div>}
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
</div>
}
>
{!completeInviteLink && <form onSubmit={handleSubmit(onAddMember)} >
<Controller
control={control}
defaultValue=""
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add Member
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addMember")}
>
Cancel
</Button>
</div>
</form>}
{
completeInviteLink &&
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{completeInviteLink}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span>
</IconButton>
</div>
}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
@@ -329,15 +299,6 @@ export const OrgMembersTable = ({
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={onRemoveOrgMemberApproved}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
</div>
);
};

View File

@@ -0,0 +1 @@
export { OrgMembersSection } from "./OrgMembersSection";

View File

@@ -1 +0,0 @@
export { OrgMembersTable } from "./OrgMembersTable";

View File

@@ -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>
);
};

View File

@@ -1,5 +1,8 @@
import { useEffect, useMemo,useState } from "react";
import { Controller,useForm } from "react-hook-form";
import {
// Controller,
// useForm
} from "react-hook-form";
import { useRouter } from "next/router";
import {
faCheck,
@@ -10,21 +13,21 @@ import {
faServer,
faTrash} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
// import { yupResolver } from "@hookform/resolvers/yup";
// import * as yup from "yup";
// import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
// FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
// Select,
// SelectItem,
Table,
TableContainer,
TableSkeleton,
@@ -37,25 +40,26 @@ import {
import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import {
useCreateServiceAccount,
// useCreateServiceAccount,
useDeleteServiceAccount,
useGetServiceAccounts} from "@app/hooks/api";
useGetServiceAccounts
} from "@app/hooks/api";
const serviceAccountExpiration = [
{ label: "1 Day", value: 86400 },
{ label: "7 Days", value: 604800 },
{ label: "1 Month", value: 2592000 },
{ label: "6 months", value: 15552000 },
{ label: "12 months", value: 31104000 },
{ label: "Never", value: -1 }
];
// const serviceAccountExpiration = [
// { label: "1 Day", value: 86400 },
// { label: "7 Days", value: 604800 },
// { label: "1 Month", value: 2592000 },
// { label: "6 months", value: 15552000 },
// { label: "12 months", value: 31104000 },
// { label: "Never", value: -1 }
// ];
const addServiceAccountFormSchema = yup.object({
name: yup.string().required().label("Name").trim(),
expiresIn: yup.string().required().label("Service Account Expiration")
});
// const addServiceAccountFormSchema = yup.object({
// name: yup.string().required().label("Name").trim(),
// expiresIn: yup.string().required().label("Service Account Expiration")
// });
type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
// type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => {
const router = useRouter();
@@ -67,9 +71,9 @@ export const OrgServiceAccountsTable = () => {
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
const [accessKey, setAccessKey] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [accessKey] = useState("");
const [publicKey] = useState("");
const [privateKey] = useState("");
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount",
@@ -78,7 +82,7 @@ export const OrgServiceAccountsTable = () => {
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId);
const createServiceAccount = useCreateServiceAccount();
// const createServiceAccount = useCreateServiceAccount();
const removeServiceAccount = useDeleteServiceAccount();
useEffect(() => {
@@ -98,32 +102,32 @@ export const OrgServiceAccountsTable = () => {
return () => clearTimeout(timer);
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
// const {
// control,
// handleSubmit,
// reset,
// formState: { isSubmitting }
// } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
if (!currentOrg?._id) return;
// const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
// if (!currentOrg?._id) return;
const keyPair = generateKeyPair();
setPublicKey(keyPair.publicKey);
setPrivateKey(keyPair.privateKey);
// const keyPair = generateKeyPair();
// setPublicKey(keyPair.publicKey);
// setPrivateKey(keyPair.privateKey);
const serviceAccountDetails = await createServiceAccount.mutateAsync({
name,
organizationId: currentOrg?._id,
publicKey: keyPair.publicKey,
expiresIn: Number(expiresIn)
});
// const serviceAccountDetails = await createServiceAccount.mutateAsync({
// name,
// organizationId: currentOrg?._id,
// publicKey: keyPair.publicKey,
// expiresIn: Number(expiresIn)
// });
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
// setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
setStep(1);
reset();
}
// setStep(1);
// reset();
// }
const onRemoveServiceAccount = async () => {
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
@@ -144,63 +148,67 @@ export const OrgServiceAccountsTable = () => {
switch (stepToRender) {
case 0:
return (
<form onSubmit={handleSubmit(onAddServiceAccount)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{serviceAccountExpiration.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Service Account
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addServiceAccount")}
>
Cancel
</Button>
</div>
</form>
<div>
We are currently revising the service account mechanism. In the meantime,
please use service tokens or API key to fetch secrets via API request.
</div>
// <form onSubmit={handleSubmit(onAddServiceAccount)}>
// <Controller
// control={control}
// defaultValue=""
// name="name"
// render={({ field, fieldState: { error } }) => (
// <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
// <Input {...field} />
// </FormControl>
// )}
// />
// <Controller
// control={control}
// name="expiresIn"
// defaultValue={String(serviceAccountExpiration?.[0]?.value)}
// render={({ field: { onChange, ...field }, fieldState: { error } }) => {
// return (
// <FormControl
// label="Expiration"
// errorText={error?.message}
// isError={Boolean(error)}
// >
// <Select
// defaultValue={field.value}
// {...field}
// onValueChange={(e) => onChange(e)}
// className="w-full"
// >
// {serviceAccountExpiration.map(({ label, value }) => (
// <SelectItem value={String(value)} key={label}>
// {label}
// </SelectItem>
// ))}
// </Select>
// </FormControl>
// );
// }}
// />
// <div className="mt-8 flex items-center">
// <Button
// className="mr-4"
// size="sm"
// type="submit"
// isLoading={isSubmitting}
// isDisabled={isSubmitting}
// >
// Create Service Account
// </Button>
// <Button
// colorSchema="secondary"
// variant="plain"
// onClick={() => handlePopUpClose("addServiceAccount")}
// >
// Cancel
// </Button>
// </div>
// </form>
);
case 1:
return (
@@ -269,27 +277,27 @@ export const OrgServiceAccountsTable = () => {
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
</div>
<div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
reset();
// reset();
handlePopUpOpen("addServiceAccount");
}}
>
Add Service Account
</Button>
</div>
<TableContainer>
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Th>Name</Th>
@@ -345,7 +353,7 @@ export const OrgServiceAccountsTable = () => {
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
reset();
// reset();
}}
>
<ModalContent

View File

@@ -1,5 +1,4 @@
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
export { OrgMembersTable } from "./OrgMembersTable";
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
export { OrgMembersSection } from "./OrgMembersSection";
export { OrgNameChangeSection } from "./OrgNameChangeSection";
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,2 @@
export { APIKeySection } from "./APIKeySection";

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { ChangeLanguageSection } from "./ChangeLanguageSection";

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { ChangePasswordSection } from "./ChangePasswordSection";

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { EmergencyKitSection } from "./EmergencyKitSection";

View File

@@ -0,0 +1,7 @@
import { APIKeySection } from "../APIKeySection";
export const PersonalAPIKeyTab = () => {
return (
<APIKeySection />
);
}

View File

@@ -0,0 +1 @@
export { PersonalAPIKeyTab } from "./PersonalAPIKeyTab";

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { PersonalSecurityTab } from "./PersonalSecurityTab";

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { PersonalTabGroup } from "./PersonalTabGroup";

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { SessionsSection } from "./SessionsSection";

View File

@@ -0,0 +1 @@
export { PersonalSettingsPage } from "./PersonalSettingsPage";

View File

@@ -1,443 +1,23 @@
import crypto from "crypto";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader";
// TODO(akhilmhdh):Refactor this into a better utility module package
import {
decryptAssymmetric,
decryptSymmetric,
encryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { Button, FormControl, Input } from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateServiceToken,
useCreateWsEnvironment,
useCreateWsTag,
useDeleteServiceToken,
useDeleteWorkspace,
useDeleteWsEnvironment,
useDeleteWsTag,
useGetUserWsKey,
useGetUserWsServiceTokens,
useGetWorkspaceIndexStatus,
useGetWorkspaceSecrets,
useGetWsTags,
useNameWorkspaceSecrets,
useRenameWorkspace,
useToggleAutoCapitalization,
useUpdateWsEnvironment
} from "@app/hooks/api";
import { AutoCapitalizationSection } from "./components/AutoCapitalizationSection/AutoCapitalizationSection";
import { SecretTagsSection } from "./components/SecretTagsSection";
import {
CopyProjectIDSection,
CreateServiceToken,
CreateUpdateEnvFormData,
CreateWsTag,
E2EESection,
EnvironmentSection,
ProjectIndexSecretsSection,
ProjectNameChangeSection,
ServiceTokenSection} from "./components";
import { ProjectTabGroup } from "./components";
export const ProjectSettingsPage = () => {
const { t } = useTranslation();
const { currentWorkspace, workspaces, isLoading: isWorkspaceLoading } = useWorkspace();
const router = useRouter();
const workspaceID = currentWorkspace?._id || "";
const { createNotification } = useNotificationContext();
// delete action worksapce
const [deleteProjectInput, setDeleteProjectInput] = useState("");
const [isDeleting, setIsDeleting] = useToggle();
const renameWorkspace = useRenameWorkspace();
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const toggleAutoCapitalization = useToggleAutoCapitalization();
const deleteWorkspace = useDeleteWorkspace();
// env crud operation
const createWsEnv = useCreateWsEnvironment();
const updateWsEnv = useUpdateWsEnvironment();
const deleteWsEnv = useDeleteWsEnvironment();
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } =
useGetWorkspaceIndexStatus(workspaceID);
// service token
const { data: serviceTokens, isLoading: isServiceTokenLoading } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?._id || ""
});
const { data: latestFileKey } = useGetUserWsKey(workspaceID);
const { data: encryptedSecrets } = useGetWorkspaceSecrets(workspaceID);
const createServiceToken = useCreateServiceToken();
const deleteServiceToken = useDeleteServiceToken();
// tag
const { data: wsTags, isLoading: isTagLoading } = useGetWsTags(workspaceID);
const createWsTag = useCreateWsTag();
const deleteWsTag = useDeleteWsTag();
// get user subscription
const { subscription } = useSubscription();
const isEnvServiceAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
const onRenameWorkspace = async (name: string) => {
try {
await renameWorkspace.mutateAsync({ workspaceID, newWorkspaceName: name });
createNotification({
text: "Successfully renamed workspace",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to rename workspace",
type: "error"
});
}
};
const onAutoCapitalizationToggle = async (state: boolean) => {
try {
await toggleAutoCapitalization.mutateAsync({
workspaceID,
state
});
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
createNotification({
text,
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update auto capitalization",
type: "error"
});
}
};
const onDeleteWorkspace = async () => {
setIsDeleting.on();
try {
await deleteWorkspace.mutateAsync({ workspaceID });
// redirect user to first workspace user is part of
const ws = workspaces.find(({ _id }) => _id !== workspaceID);
if (!ws) {
router.push("/noprojects");
}
router.push(`/dashboard/${ws?._id}`);
createNotification({
text: "Successfully deleted workspace",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete workspace",
type: "error"
});
} finally {
setIsDeleting.off();
}
};
// workspace environment operation
const onCreateWsEnv = async ({ environmentName, environmentSlug }: CreateUpdateEnvFormData) => {
try {
await createWsEnv.mutateAsync({ workspaceID, environmentName, environmentSlug });
createNotification({
text: "Successfully created environment",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create environment",
type: "error"
});
}
};
const onUpdateWsEnv = async (
oldEnvironmentSlug: string,
{ environmentName, environmentSlug }: CreateUpdateEnvFormData
) => {
try {
await updateWsEnv.mutateAsync({
workspaceID,
environmentName,
environmentSlug,
oldEnvironmentSlug
});
createNotification({
text: "Successfully updated environment",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update environment",
type: "error"
});
}
};
const onDeleteWsEnv = async (environmentSlug: string) => {
try {
await deleteWsEnv.mutateAsync({
workspaceID,
environmentSlug
});
createNotification({
text: "Successfully deleted environment",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete environment",
type: "error"
});
}
};
const onCreateServiceToken = async ({
environment,
expiresIn,
name,
permissions,
secretPath
}: CreateServiceToken) => {
// type guard
if (!latestFileKey) return "";
try {
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const randomBytes = crypto.randomBytes(16).toString("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: key,
key: randomBytes
});
const res = await createServiceToken.mutateAsync({
encryptedKey: ciphertext,
iv,
tag,
environment,
secretPath,
expiresIn: Number(expiresIn),
name,
workspaceId: workspaceID,
randomBytes,
permissions: Object.entries(permissions)
.filter(([, permissionsValue]) => permissionsValue)
.map(([permissionsKey]) => permissionsKey)
});
createNotification({
text: "Successfully created a service token",
type: "success"
});
return res.serviceToken;
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create a service token",
type: "error"
});
}
return "";
};
const onCreateWsTag = async ({ name }: CreateWsTag) => {
try {
const res = await createWsTag.mutateAsync({
workspaceID,
tagName: name,
tagSlug: name.replace(" ", "_")
});
createNotification({
text: "Successfully created a tag",
type: "success"
});
return res.name;
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create a tag",
type: "error"
});
}
return "";
};
const onDeleteTag = async (tagID: string) => {
try {
await deleteWsTag.mutateAsync({ tagID });
createNotification({
text: "Successfully deleted tag",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete the tag",
type: "error"
});
}
};
const onDeleteServiceToken = async (tokenID: string) => {
try {
await deleteServiceToken.mutateAsync(tokenID);
createNotification({
text: "Successfully revoked service token",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete service token",
type: "error"
});
}
};
const onEnableBlindIndices = async () => {
if (!currentWorkspace?._id) return;
if (!encryptedSecrets) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
const secretName = decryptSymmetric({
ciphertext: encryptedSecret.secretKeyCiphertext,
iv: encryptedSecret.secretKeyIV,
tag: encryptedSecret.secretKeyTag,
key
});
return {
secretName,
_id: encryptedSecret._id
};
});
await nameWorkspaceSecrets.mutateAsync({
workspaceId: currentWorkspace._id,
secretsToUpdate
});
};
return (
<div className="dark container mx-auto flex flex-col px-8 text-mineshaft-50 dark:[color-scheme:dark]">
{/* TODO(akhilmhdh): Remove this right when layout is refactored */}
<div className="relative right-5">
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
</div>
<div className="my-8 flex max-w-5xl flex-row items-center justify-between text-xl">
<div className="flex flex-col items-start justify-start text-3xl">
<p className="mr-4 font-semibold text-gray-200">{t("settings.project.title")}</p>
<p className="mr-4 text-base font-normal text-gray-400">
{t("settings.project.description")}
</p>
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
<div className="max-w-screen-lg w-full">
<div className="relative right-5 ml-4">
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
</div>
</div>
<ProjectNameChangeSection
workspaceName={currentWorkspace?.name}
onProjectNameChange={onRenameWorkspace}
/>
<CopyProjectIDSection workspaceID={currentWorkspace?._id || ""} />
<EnvironmentSection
isLoading={isWorkspaceLoading}
environments={currentWorkspace?.environments || []}
onCreate={onCreateWsEnv}
onDelete={onDeleteWsEnv}
onUpdate={onUpdateWsEnv}
isEnvServiceAllowed={isEnvServiceAllowed}
/>
<ServiceTokenSection
isLoading={isServiceTokenLoading}
tokens={serviceTokens || []}
environments={currentWorkspace?.environments || []}
onDeleteToken={onDeleteServiceToken}
workspaceName={currentWorkspace?.name || ""}
onCreateToken={onCreateServiceToken}
/>
<SecretTagsSection
isLoading={isTagLoading}
tags={wsTags || []}
onDeleteTag={onDeleteTag}
workspaceName={currentWorkspace?.name || ""}
onCreateTag={onCreateWsTag}
/>
<AutoCapitalizationSection
workspaceAutoCapitalization={currentWorkspace?.autoCapitalization}
onAutoCapitalizationChange={onAutoCapitalizationToggle}
/>
{!isBlindIndexedLoading && !isBlindIndexed && (
<ProjectIndexSecretsSection
onEnableBlindIndices={onEnableBlindIndices}
/>
)}
<E2EESection
workspaceId={currentWorkspace?._id || ""}
/>
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md border-l border-red bg-mineshaft-900 px-6 pl-6 pb-4 pt-4">
<p className="text-xl font-bold text-red">{t("settings.project.danger-zone")}</p>
<p className="text-md mt-2 text-gray-400">{t("settings.project.danger-zone-note")}</p>
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
<FormControl
label={
<div className="mb-0.5 text-sm font-normal text-gray-400">
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
workspace
</div>
}
>
<Input
onChange={(e) => setDeleteProjectInput(e.target.value)}
value={deleteProjectInput}
placeholder="Type the project name to delete"
className="bg-mineshaft-800"
/>
</FormControl>
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">
{t("settings.project.title")}
</p>
</div>
<Button
colorSchema="danger"
onClick={onDeleteWorkspace}
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting}
isLoading={isDeleting}
>
{t("settings.project.delete-project")}
</Button>
<p className="mt-3 ml-0.5 text-xs text-gray-500">
{t("settings.project.delete-project-note")}
</p>
<ProjectTabGroup />
</div>
</div>
);

View File

@@ -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")}

View File

@@ -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>
);
};

View File

@@ -1 +0,0 @@
export { CopyProjectIDSection } from "./CopyProjectIDSection";

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection";

View File

@@ -4,29 +4,27 @@ import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Checkbox
} from "@app/components/v2";
import { Checkbox } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import getBot from "../../../../../pages/api/bot/getBot";
import setBotActiveStatus from "../../../../../pages/api/bot/setBotActiveStatus";
import getLatestFileKey from "../../../../../pages/api/workspace/getLatestFileKey";
type Props = {
workspaceId: string;
}
export const E2EESection = ({
workspaceId
}: Props) => {
export const E2EESection = () => {
const { currentWorkspace } = useWorkspace();
const [bot, setBot] = useState<any>(null);
useEffect(() => {
(async () => {
// get project bot
setBot(await getBot({ workspaceId }));
if (currentWorkspace) {
// get project bot
setBot(await getBot({
workspaceId: currentWorkspace._id
}));
}
})();
}, []);
}, [currentWorkspace]);
/**
* Activate bot for project by performing the following steps:
@@ -38,12 +36,16 @@ export const E2EESection = ({
const toggleBotActivate = async () => {
let botKey;
try {
if (!currentWorkspace?._id) return;
if (bot) {
// case: there is a bot
if (!bot.isActive) {
// bot is not active -> activate bot
const key = await getLatestFileKey({ workspaceId });
const key = await getLatestFileKey({
workspaceId: currentWorkspace._id
});
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
if (!PRIVATE_KEY) {
@@ -91,12 +93,12 @@ export const E2EESection = ({
};
return bot ? (
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-2">
<p className="mb-4 mt-2 text-xl font-semibold">End-to-End Encryption</p>
<p className="text-md my-2 text-gray-400">
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
<p className="text-gray-400 mb-8">
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values.
</p>
<p className="text-md my-2 mb-4 text-gray-400">
<p className="text-gray-400 mb-8">
Note that, even with E2EE disabled, your secrets are always encrypted at rest.
</p>
<Checkbox

View File

@@ -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>
);
}

View File

@@ -1,224 +1,100 @@
import { Controller, useForm } from "react-hook-form";
import { faPencil, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
UpgradePlanModal
} from "@app/components/v2";
import { useSubscription,useWorkspace } from "@app/context";
import {
useDeleteWsEnvironment
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
type Props = {
environments: Array<{ name: string; slug: string }>;
isLoading?: boolean;
isEnvServiceAllowed: boolean;
onCreate: (data: CreateUpdateEnvFormData) => Promise<void>;
onUpdate: (oldEnvSlug: string, data: CreateUpdateEnvFormData) => Promise<void>;
onDelete: (envSlug: string) => Promise<void>;
};
import { AddEnvironmentModal } from "./AddEnvironmentModal";
import { EnvironmentTable } from "./EnvironmentTable";
import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal";
const createUpdateEnvSchema = yup.object({
environmentName: yup.string().label("Environment Name").required(),
environmentSlug: yup.string().label("Environment Slug").required()
});
export const EnvironmentSection = () => {
const { createNotification } = useNotificationContext();
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
export type CreateUpdateEnvFormData = yup.InferType<typeof createUpdateEnvSchema>;
const deleteWsEnvironment = useDeleteWsEnvironment();
export const EnvironmentSection = ({
environments,
isEnvServiceAllowed,
onCreate,
onDelete,
isLoading,
onUpdate
}: Props): JSX.Element => {
const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"createUpdateEnv",
"createEnv",
"updateEnv",
"deleteEnv",
"upgradePlan"
] as const);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<CreateUpdateEnvFormData>({
resolver: yupResolver(createUpdateEnvSchema)
});
const onEnvDeleteSubmit = async (environmentSlug: string) => {
try {
if (!currentWorkspace?._id) return;
await deleteWsEnvironment.mutateAsync({
workspaceID: currentWorkspace._id,
environmentSlug
});
const isEnvUpdate = Boolean(popUp?.createUpdateEnv?.data);
const oldEnvSlug = (popUp?.createUpdateEnv?.data as { slug: string })?.slug;
const onEnvModalSubmit = async (data: CreateUpdateEnvFormData) => {
if (isEnvUpdate) {
await onUpdate(oldEnvSlug, data);
} else {
await onCreate(data);
createNotification({
text: "Successfully deleted environment",
type: "success"
});
handlePopUpClose("deleteEnv");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete environment",
type: "error"
});
}
handlePopUpClose("createUpdateEnv");
};
const onEnvDeleteSubmit = async (envSlug: string) => {
await onDelete(envSlug);
handlePopUpClose("deleteEnv");
};
return (
<div className="mt-4 mb-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 p-6">
<div className="mb-2 flex w-full flex-row justify-between">
<div className="flex w-full flex-col">
<p className="mb-3 text-xl font-semibold">Project Environments</p>
<p className="mb-4 text-base text-gray-400">
Choose which environments will show up in your dashboard like development, staging,
production
</p>
<p className="mr-1 self-start text-sm text-gray-500">
Note: the text in slugs shows how these environmant should be accessed in CLI.
</p>
</div>
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">
Environments
</p>
<div>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isEnvServiceAllowed) {
handlePopUpOpen("createUpdateEnv");
if (isMoreEnvironmentsAllowed) {
handlePopUpOpen("createEnv");
} else {
handlePopUpOpen("upgradePlan");
}
}}
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add New Environment
Create environment
</Button>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} key="project-envs" />}
{!isLoading &&
environments.map(({ name, slug }) => (
<Tr key={name}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<IconButton
className="mr-3 py-2"
onClick={() => {
handlePopUpOpen("createUpdateEnv", { name, slug });
reset({ environmentName: name, environmentSlug: slug });
}}
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug });
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && environments?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No environments found" />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.createUpdateEnv?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("createUpdateEnv", isOpen);
reset();
}}
>
<ModalContent title={isEnvUpdate ? "Update environment" : "Create a new environment"}>
<form onSubmit={handleSubmit(onEnvModalSubmit)}>
<Controller
control={control}
defaultValue=""
name="environmentName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="environmentSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Slug"
helperText="Slugs are shorthands used in cli to access environment"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isEnvUpdate ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
<p className="text-gray-400 mb-8">
Choose which environments will show up in your dashboard like development, staging, production
</p>
<EnvironmentTable
handlePopUpOpen={handlePopUpOpen}
/>
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpdateEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteEnv.isOpen}
title={`Are you sure want to delete ${

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { ProjectGeneralTab } from "./ProjectGeneralTab";

View File

@@ -1,24 +1,64 @@
import {
decryptAssymmetric,
decryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useGetUserWsKey,
useGetWorkspaceIndexStatus,
useGetWorkspaceSecrets,
useNameWorkspaceSecrets
} from "@app/hooks/api";
// TODO: add check so that this only shows up if user is
// an admin in the workspace
type Props = {
onEnableBlindIndices: () => Promise<void>;
}
export const ProjectIndexSecretsSection = ({
onEnableBlindIndices
}: Props) => {
return (
<div className="rounded-md bg-mineshaft-900 p-6 my-2">
<p className="mb-4 text-xl font-semibold">Blind Indices</p>
<p className="mb-4 text-sm text-gray-400">
export const ProjectIndexSecretsSection = () => {
const { currentWorkspace } = useWorkspace();
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? "");
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? "");
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const onEnableBlindIndices = async () => {
if (!currentWorkspace?._id) return;
if (!encryptedSecrets) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
const secretName = decryptSymmetric({
ciphertext: encryptedSecret.secretKeyCiphertext,
iv: encryptedSecret.secretKeyIV,
tag: encryptedSecret.secretKeyTag,
key
});
return {
secretName,
_id: encryptedSecret._id
};
});
await nameWorkspaceSecrets.mutateAsync({
workspaceId: currentWorkspace._id,
secretsToUpdate
});
};
return (!isBlindIndexedLoading && !isBlindIndexed) ? (
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<p className="mb-3 text-xl font-semibold">Blind Indices</p>
<p className="text-gray-400 mb-8">
Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing.
</p>
<p className="mb-4 text-sm text-gray-400">
Learn more about it here.
</p>
<Button
onClick={onEnableBlindIndices}
color="mineshaft"
@@ -28,5 +68,7 @@ export const ProjectIndexSecretsSection = ({
Enable Blind Indexing
</Button>
</div>
);
) : (
<div />
)
}

View File

@@ -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>
);
};

View File

@@ -0,0 +1,7 @@
import { ServiceTokenSection } from "../ServiceTokenSection";
export const ProjectServiceTokensTab = () => {
return (
<ServiceTokenSection />
);
}

View File

@@ -0,0 +1 @@
export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab";

Some files were not shown because too many files have changed in this diff Show More