diff --git a/backend/src/controllers/v2/apiKeyDataController.ts b/backend/src/controllers/v2/apiKeyDataController.ts deleted file mode 100644 index ee19b0e4c..000000000 --- a/backend/src/controllers/v2/apiKeyDataController.ts +++ /dev/null @@ -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, - }); -}; diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 5496097db..a5955fef4 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -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, diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 2b4784b29..19d88d56d 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -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" + }); +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 826d5ca9c..2dfa4c5dd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/v2/apiKeyData.ts b/backend/src/routes/v2/apiKeyData.ts deleted file mode 100644 index eae8d7ddd..000000000 --- a/backend/src/routes/v2/apiKeyData.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index fc7353729..807760268 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -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, } \ No newline at end of file diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index 970824eaa..f5a1401dc 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -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; \ No newline at end of file diff --git a/frontend/src/components/basic/dialog/AddApiKeyDialog.tsx b/frontend/src/components/basic/dialog/AddApiKeyDialog.tsx deleted file mode 100644 index eeb0ca6b1..000000000 --- a/frontend/src/components/basic/dialog/AddApiKeyDialog.tsx +++ /dev/null @@ -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 ( -
- - - -
- - -
-
- - {apiKey === "" ? ( - - - {t("section.api-key.add-dialog.title")} - -
-
-

- {t("section.api-key.add-dialog.description")} -

-
-
-
- -
-
- -
-
-
-
-
-
- ) : ( - - - {t("section.api-key.add-dialog.copy-service-token")} - -
-
-

- {t("section.api-key.add-dialog.copy-service-token-description")} -

-
-
-
-
- -
- {apiKey} -
-
- - - {t("common.click-to-copy")} - -
-
-
-
-
-
- )} -
-
-
-
-
-
- ); -}; - -export default AddApiKeyDialog; diff --git a/frontend/src/components/basic/table/ApiKeyTable.tsx b/frontend/src/components/basic/table/ApiKeyTable.tsx deleted file mode 100644 index 1522df00d..000000000 --- a/frontend/src/components/basic/table/ApiKeyTable.tsx +++ /dev/null @@ -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 ( -
-
- - - - - - - - - {data?.length > 0 ? ( - data?.map((row) => ( - - - - - - )) - ) : ( - - - - )} - -
API KEY NAMEVALID UNTIL -
- {row.name} - - {new Date(row.expiresAt).toUTCString()} - -
-
-
- No API keys yet -
-
- ); -}; - -export default ApiKeyTable; diff --git a/frontend/src/components/billing/Plan.tsx b/frontend/src/components/billing/Plan.tsx deleted file mode 100644 index 5b0f2256e..000000000 --- a/frontend/src/components/billing/Plan.tsx +++ /dev/null @@ -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 ( -
-
-
-

{plan.name}

-
-
-

{plan.price}

-

{plan.priceExplanation}

-
-

{plan.text}

-

{plan.subtext}

-
-
- {plan.current === false ? ( - <> - {plan.buttonTextMain === "Schedule a Demo" ? ( - -
- {plan.buttonTextMain} -
-
- ) : ( -
- -
- )} - -
- {plan.buttonTextSecondary} -
-
- - ) : ( -
-

CURRENT PLAN

-
- )} -
-
- ); -} diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index ee7274fdc..4bbdce1bc 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -57,7 +57,7 @@ export default function NavHeader({ ); return ( -
+
{currentOrg?.name?.charAt(0)}
diff --git a/frontend/src/components/utilities/attemptChangePassword.ts b/frontend/src/components/utilities/attemptChangePassword.ts new file mode 100644 index 000000000..5b42e0190 --- /dev/null +++ b/frontend/src/components/utilities/attemptChangePassword.ts @@ -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 => { + 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; \ No newline at end of file diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index fb89ba3fe..e8ca1b6b3 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -1,6 +1,5 @@ export { useGetAuthToken, useGetCommonPasswords, - useRevokeAllSessions, useSendMfaToken, useVerifyMfaToken} from "./queries" diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index d98b5e721..57878d2b3 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -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 || []; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index a997a3fad..e1e530443 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -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"; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 9baf7b784..c1bcf43a2 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -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( + "/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( + "/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( + "/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); + } + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index f7cb4dd22..e272e7bfd 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -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; +} \ No newline at end of file diff --git a/frontend/src/pages/activity/[id].tsx b/frontend/src/pages/activity/[id].tsx index 1f7b7b75a..91f918540 100644 --- a/frontend/src/pages/activity/[id].tsx +++ b/frontend/src/pages/activity/[id].tsx @@ -158,7 +158,9 @@ export default function Activity() { - +
+ +
{currentSidebarAction && ( )} diff --git a/frontend/src/pages/api/apiKey/addAPIKey.ts b/frontend/src/pages/api/apiKey/addAPIKey.ts deleted file mode 100644 index b4f339a16..000000000 --- a/frontend/src/pages/api/apiKey/addAPIKey.ts +++ /dev/null @@ -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; diff --git a/frontend/src/pages/api/apiKey/deleteAPIKey.ts b/frontend/src/pages/api/apiKey/deleteAPIKey.ts deleted file mode 100644 index 8fcc9fcd8..000000000 --- a/frontend/src/pages/api/apiKey/deleteAPIKey.ts +++ /dev/null @@ -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; diff --git a/frontend/src/pages/api/apiKey/getAPIKeys.ts b/frontend/src/pages/api/apiKey/getAPIKeys.ts deleted file mode 100644 index 0c858879c..000000000 --- a/frontend/src/pages/api/apiKey/getAPIKeys.ts +++ /dev/null @@ -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; diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index 54a29a5c4..b401b14ae 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -412,7 +412,9 @@ export default function Integrations() {
- +
+ +
setIsActivateBotDialogOpen(false)} diff --git a/frontend/src/pages/settings/personal/[id].tsx b/frontend/src/pages/settings/personal/[id].tsx index f05918059..62c279200 100644 --- a/frontend/src/pages/settings/personal/[id].tsx +++ b/frontend/src/pages/settings/personal/[id].tsx @@ -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([]); - const [errors, setErrors] = useState({}); - - 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 ( -
+
{t("common.head-title", { title: t("settings.personal.title") })} - -
-
- -
-
-

{t("settings.personal.title")}

-

- {t("settings.personal.description")} -

-
-
-
-
-

- {t("settings.personal.change-language")} -

-
- -
-
- -
-
-
-

- {t("settings.personal.api-keys.title")} -

-

- {t("settings.personal.api-keys.description")} -

-
-
-
-
- -
- -
-
-
-

- {t("section.password.change")} -

-
-
-
- { - setCurrentPassword(password); - }} - type="password" - value={currentPassword} - isRequired - error={currentPasswordError} - errorText={t("section.password.current-wrong") as string} - autoComplete="current-password" - id="current-password" - /> -
- { - setNewPassword(password); - checkPassword({ - password, - commonPasswords, - setErrors - }); - }} - type="password" - value={newPassword} - isRequired - error={Object.keys(errors).length > 0} - autoComplete="new-password" - id="new-password" - /> -
- {Object.keys(errors).length > 0 && ( -
-
{t("section.password.validate-base")}
- {Object.keys(errors).map((key) => { - if (errors[key as keyof Errors]) { - return ( -
-
- -
-

- {errors[key as keyof Errors]} -

-
- ); - } - - return null; - })} -
- )} -
-
-
-
-
-

- Sessions -

-
-
-
-

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

-
- -
-
-
-

- {t("settings.personal.emergency.name")} -

-

- {t("settings.personal.emergency.text1")} -

-

- {t("settings.personal.emergency.text2")} -

-
-
-
- -
-
-
-
-
-
-
+
); } diff --git a/frontend/src/pages/users/[id].tsx b/frontend/src/pages/users/[id].tsx index 75e051d6f..5070030b0 100644 --- a/frontend/src/pages/users/[id].tsx +++ b/frontend/src/pages/users/[id].tsx @@ -154,7 +154,9 @@ export default function Users() { {t("common.head-title", { title: t("settings.members.title") })} - +
+ +

{t("settings.members.title")}

diff --git a/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx b/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx index 60b85139b..9d131dafe 100644 --- a/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx +++ b/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx @@ -143,7 +143,7 @@ export const DashboardEnvOverview = () => { return (
-
+
diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 539d9fea7..2a4f79c4b 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -545,7 +545,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
{/* breadcrumb row */} -
+
{ const { t } = useTranslation(); return ( -
- - -
-
-

{t("billing.title")}

+
+
+ +
+

{t("billing.title")}

+
-
-
-
diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx index c9973646a..d5255043b 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx @@ -43,7 +43,7 @@ export const CurrentPlanSection = () => { } return ( -
+

Current Usage

diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx index 4050e7936..b65ac6b44 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx @@ -39,7 +39,7 @@ export const PreviewSection = () => { return (
{!isSubscriptionLoading && subscription?.slug !== "enterprise" && subscription?.slug !== "pro" && subscription?.slug !== "pro-annual" && ( -
+

Become Infisical

Unlimited members, projects, RBAC, smart alerts, and so much more

@@ -53,7 +53,7 @@ export const PreviewSection = () => {
)} {!isLoading && data && ( -
+

Current plan

Starter

diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx index 07834949a..101f1991e 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx @@ -63,7 +63,7 @@ export const CompanyNameSection = () => { return (

Business name diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx index ee28da914..2e9ba649a 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx @@ -64,7 +64,7 @@ export const InvoiceEmailSection = () => { return (

Invoice email recipient diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx index 1535fa3e4..30af879e3 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx @@ -26,7 +26,7 @@ export const PmtMethodsSection = () => { } return ( -
+

Payment Methods diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx index b7865b2c0..b918205e6 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx @@ -3,7 +3,6 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button } from "@app/components/v2"; -// import Button from "@app/components/basic/buttons/Button"; import { usePopUp } from "@app/hooks/usePopUp"; import { TaxIDModal } from "./TaxIDModal"; @@ -15,7 +14,7 @@ export const TaxIDSection = () => { ] as const); return ( -
+

Tax ID diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx index f5c542186..574435afc 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx @@ -2,7 +2,7 @@ import { InvoicesTable } from "./InvoicesTable"; export const BillingReceiptsTab = () => { return ( -
+

Invoices

diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx index 72b2e942b..8e3aad06f 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx @@ -14,7 +14,7 @@ const tabs = [ export const BillingTabGroup = () => { return ( - + {tabs.map((tab) => ( {({ selected }) => ( diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx index f2c414881..842920f3d 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -1,282 +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, - 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 removeUserOrgMembership = useDeleteOrgMembership(); - const addUserToOrg = useAddUserToOrg(); - const updateOrgUserRole = useUpdateOrgUserRole(); - const uploadWsKey = useUploadWsKey(); - const addIncidentContact = useAddIncidentContact(); - const removeIncidentContact = useDeleteIncidentContact(); - - const [completeInviteLink, setcompleteInviteLink] = useState(""); - - const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false; - - 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 ( -
+
+
-
+

{t("settings.org.title")}

-

{t("settings.org.description")}

-
-
-

- {t("section.members.org-members")} -

- -
-
-

Service Accounts

+ + +
-
-
-
-

- {t("section.incident.incident-contacts")} -

-

- {t("section.incident.incident-contacts-description")} -

-
-
-
- -
-
{/*
-

- Danger Zone -

-

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

-
- -
- -

- Note: You can only delete a project in case you - have more than one. -

-
*/} -
+

+ Danger Zone +

+

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

+
+ +
+ +

+ Note: You can only delete a project in case you + have more than one. +

+
*/} +
); }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/AddOrgIncidentContactModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/AddOrgIncidentContactModal.tsx new file mode 100644 index 000000000..238c5d190 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/AddOrgIncidentContactModal.tsx @@ -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; + +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({ 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 ( + { + handlePopUpToggle("addContact", isOpen); + reset(); + }} + > + + + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsSection.tsx new file mode 100644 index 000000000..dd89bfa15 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsSection.tsx @@ -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 ( +
+
+

+ {t("section.incident.incident-contacts")} +

+ +
+ + +
+ ); +} + diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx new file mode 100644 index 000000000..f2c5d1208 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx @@ -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 ( +
+ setSearchContact(e.target.value)} + leftIcon={} + placeholder="Search incident contact by email..." + /> + +

+ + + + + + + {isLoading && } + {filteredContacts?.map(({ email }) => ( + + + + + ))} + +
Email +
{email} + handlePopUpOpen("removeContact", { email })} + > + + +
+ {filteredContacts?.length === 0 && !isLoading && ( + + )} +
+ handlePopUpToggle("removeContact", isOpen)} + onDeleteApproved={onRemoveIncidentContact} + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/index.tsx new file mode 100644 index 000000000..33511f319 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/index.tsx @@ -0,0 +1,2 @@ +export { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal" +export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection"; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx deleted file mode 100644 index 858c2f210..000000000 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx +++ /dev/null @@ -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; - onAddContact: (email: string) => Promise; -}; - -const addContactFormSchema = yup.object({ - email: yup.string().email().required().label("Email").trim() -}); - -type TAddContactForm = yup.InferType; - -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({ 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 ( -
-
-
- setSearchContact(e.target.value)} - leftIcon={} - placeholder="Search incident contact by email..." - /> -
-
- -
-
-
- - - - - - - - - {isLoading && } - {filteredContacts?.map(({ email }) => ( - - - - - ))} - -
Email -
{email} - handlePopUpOpen("removeContact", { email })} - > - - -
- {filteredContacts?.length === 0 && !isLoading && ( - - )} -
-
- { - handlePopUpToggle("addContact", isOpen); - reset(); - }} - > - -
- ( - - - - )} - /> -
- - -
- -
-
- handlePopUpToggle("removeContact", isOpen)} - onDeleteApproved={onRemoveIncidentContact} - /> - handlePopUpToggle("setUpEmail", isOpen)} - /> -
- ); -}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx deleted file mode 100644 index b1df7cd68..000000000 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/AddOrgMemberModal.tsx new file mode 100644 index 000000000..4966b19bf --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -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; // 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(""); + const { + control, + handleSubmit, + reset + } = useForm({ 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 ( + { + handlePopUpToggle("addMember", isOpen); + setCompleteInviteLink(undefined); + }} + > + + {!completeInviteLink &&
+ An invite is specific to an email address and expires after 1 day. +
+ For security reasons, you will need to separately add members to projects. +
} + {completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} +
+ } + > + {!completeInviteLink && ( +
+ ( + + + + )} + /> +
+ + +
+ + )} + {completeInviteLink && ( +
+

{completeInviteLink}

+ + + click to copy + +
+ )} + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/OrgMembersSection.tsx new file mode 100644 index 000000000..7aafe94b3 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/OrgMembersSection.tsx @@ -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 ( +
+
+

+ Organization members +

+ +
+ + handlePopUpToggle("upgradePlan", isOpen)} + text="Add more members by upgrading to a higher Infisical plan." + /> + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/OrgMembersTable.tsx similarity index 51% rename from frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx rename to frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/OrgMembersTable.tsx index 492f3c317..9efa14673 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/OrgMembersTable.tsx @@ -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; - orgName: string; - isLoading?: boolean; - isMoreUserNotAllowed: boolean; - onRemoveMember: (userId: string) => Promise; - onInviteMember: (email: string) => Promise; - onRoleChange: (membershipId: string, role: string) => Promise; - onGrantAccess: (userId: string, publicKey: string) => Promise; - // the current user id to block remove org button - userId: string; - completeInviteLink: string | undefined, - setCompleteInviteLink: Dispatch> -}; - -const addMemberFormSchema = yup.object({ - email: yup.string().email().required().label("Email").trim() -}); - -type TAddMemberForm = yup.InferType; - -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({ 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 (
-
-
- setSearchMemberFilter(e.target.value)} leftIcon={} placeholder="Search members..." - /> -
- -
-
- + /> + @@ -167,7 +205,7 @@ export const OrgMembersTable = ({ - {isLoading && } + {isLoading && } {!isLoading && filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => { const name = user ? `${user.firstName} ${user.lastName}` : "-"; @@ -213,7 +251,7 @@ export const OrgMembersTable = ({
{userWs ? ( userWs?.map(({ name: wsName, _id }) => ( - + {wsName} )) @@ -254,74 +292,6 @@ export const OrgMembersTable = ({ )} - - { - handlePopUpToggle("addMember", isOpen); - setCompleteInviteLink(undefined) - }} - > - - {!completeInviteLink &&
- An invite is specific to an email address and expires after 1 day. -
- For security reasons, you will need to separately add members to projects. -
} - {completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} - - } - > - {!completeInviteLink &&
- ( - - - - )} - /> -
- - -
- } - { - completeInviteLink && -
-

{completeInviteLink}

- - - click to copy - -
- } -
-
handlePopUpToggle("removeMember", isOpen)} onDeleteApproved={onRemoveOrgMemberApproved} /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You can add custom environments if you switch to Infisical's Team plan." - /> - handlePopUpToggle("setUpEmail", isOpen)} - /> ); }; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/index.tsx new file mode 100644 index 000000000..973e12e3e --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersSection/index.tsx @@ -0,0 +1 @@ +export { OrgMembersSection } from "./OrgMembersSection"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx deleted file mode 100644 index a3d56f438..000000000 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgMembersTable } from "./OrgMembersTable"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index 0a1a59075..03e50e4f8 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -1,6 +1,5 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; @@ -16,7 +15,6 @@ const formSchema = yup.object({ type FormData = yup.InferType; export const OrgNameChangeSection = (): JSX.Element => { - const { t } = useTranslation(); const { currentOrg } = useOrganization(); const { createNotification } = useNotificationContext(); const { @@ -54,9 +52,11 @@ export const OrgNameChangeSection = (): JSX.Element => { return (
-

{t("common.display-name")}

+

+ Organization name +

; +// type TAddServiceAccountForm = yup.InferType; 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({ resolver: yupResolver(addServiceAccountFormSchema) }); + // const { + // control, + // handleSubmit, + // reset, + // formState: { isSubmitting } + // } = useForm({ 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 ( - - ( - - - - )} - /> - { - return ( - - - - ); - }} - /> -
- - -
- +
+ We are currently revising the service account mechanism. In the meantime, + please use service tokens or API key to fetch secrets via API request. +
+ //
+ // ( + // + // + // + // )} + // /> + // { + // return ( + // + // + // + // ); + // }} + // /> + //
+ // + // + //
+ // ); case 1: return ( @@ -269,27 +277,27 @@ export const OrgServiceAccountsTable = () => { return (
-
-
- setSearchServiceAccountFilter(e.target.value)} - leftIcon={} - placeholder="Search service accounts..." - /> -
+
+

Service Accounts

- + setSearchServiceAccountFilter(e.target.value)} + leftIcon={} + placeholder="Search service accounts..." + /> + @@ -345,7 +353,7 @@ export const OrgServiceAccountsTable = () => { isOpen={popUp?.addServiceAccount?.isOpen} onOpenChange={(isOpen) => { handlePopUpToggle("addServiceAccount", isOpen); - reset(); + // reset(); }} > { + const { t } = useTranslation(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addAPIKey" + ] as const); + + return ( +
+
+

+ {t("settings.personal.api-keys.title")} +

+ +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/APIKeyTable.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/APIKeyTable.tsx new file mode 100644 index 000000000..5efaf1281 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/APIKeyTable.tsx @@ -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 ( +
+ +
Name
+ + + + + + + + + + {isLoading && } + {!isLoading && data && data.length > 0 && data.map(({ + _id, + name, + createdAt, + expiresAt, + lastUsed + }) => { + return ( + + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameLast activeCreatedExpiration +
{name}{formatDate(lastUsed)}{formatDate(createdAt)}{formatDate(expiresAt)} + { + await handleDeleteAPIKeyDataClick(_id); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/AddAPIKeyModal.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/AddAPIKeyModal.tsx new file mode 100644 index 000000000..ef6008a67 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/AddAPIKeyModal.tsx @@ -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; + +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({ + 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 ( + { + handlePopUpToggle("addAPIKey", isOpen); + reset(); + setNewAPIKey(""); + }} + > + + {!hasAPIKey ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+

{newAPIKey}

+ + + + Click to copy + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/index.tsx new file mode 100644 index 000000000..912b8709a --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/index.tsx @@ -0,0 +1,2 @@ + +export { APIKeySection } from "./APIKeySection"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/ChangeLanguageSection/ChangeLanguageSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/ChangeLanguageSection/ChangeLanguageSection.tsx new file mode 100644 index 000000000..7d2dfb604 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/ChangeLanguageSection/ChangeLanguageSection.tsx @@ -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 ( +
+

+ {t("settings.personal.change-language")} +

+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/ChangeLanguageSection/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/ChangeLanguageSection/index.tsx new file mode 100644 index 000000000..42fa6d01a --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/ChangeLanguageSection/index.tsx @@ -0,0 +1 @@ +export { ChangeLanguageSection } from "./ChangeLanguageSection"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/ChangePasswordSection/ChangePasswordSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/ChangePasswordSection/ChangePasswordSection.tsx new file mode 100644 index 000000000..e556353f4 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/ChangePasswordSection/ChangePasswordSection.tsx @@ -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; + +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({}); + 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 ( +
+

+ Change password +

+
+ ( + + + + )} + control={control} + name="oldPassword" + /> +
+
+ ( + + + + )} + control={control} + name="newPassword" + /> +
+ {Object.keys(errors).length > 0 && ( +
+
{t("section.password.validate-base")}
+ {Object.keys(errors).map((key) => { + if (errors[key as keyof Errors]) { + return ( +
+
+ +
+

+ {errors[key as keyof Errors]} +

+
+ ); + } + + return null; + })} +
+ )} + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/ChangePasswordSection/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/ChangePasswordSection/index.tsx new file mode 100644 index 000000000..9fce256cf --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/ChangePasswordSection/index.tsx @@ -0,0 +1 @@ +export { ChangePasswordSection } from "./ChangePasswordSection"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/EmergencyKitSection/EmergencyKitSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/EmergencyKitSection/EmergencyKitSection.tsx new file mode 100644 index 000000000..1440d7b33 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/EmergencyKitSection/EmergencyKitSection.tsx @@ -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; + +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 ( +
+

+ Emergency Kit +

+

+ The kit contains information you can use to recover your account. +

+
+ ( + + + + )} + control={control} + name="password" + /> +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/EmergencyKitSection/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/EmergencyKitSection/index.tsx new file mode 100644 index 000000000..cebe0fe23 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/EmergencyKitSection/index.tsx @@ -0,0 +1 @@ +export { EmergencyKitSection } from "./EmergencyKitSection"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx new file mode 100644 index 000000000..24d40a8b1 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx @@ -0,0 +1,7 @@ +import { APIKeySection } from "../APIKeySection"; + +export const PersonalAPIKeyTab = () => { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/index.tsx new file mode 100644 index 000000000..3821b87f1 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/index.tsx @@ -0,0 +1 @@ +export { PersonalAPIKeyTab } from "./PersonalAPIKeyTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalSecurityTab/PersonalSecurityTab.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalSecurityTab/PersonalSecurityTab.tsx new file mode 100644 index 000000000..b7f4511fb --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalSecurityTab/PersonalSecurityTab.tsx @@ -0,0 +1,15 @@ +import { ChangePasswordSection } from "../ChangePasswordSection"; +import { EmergencyKitSection } from "../EmergencyKitSection"; +import { SecuritySection } from "../SecuritySection"; +import { SessionsSection } from "../SessionsSection"; + +export const PersonalSecurityTab = () => { + return ( +
+ + + + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalSecurityTab/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalSecurityTab/index.tsx new file mode 100644 index 000000000..e9d5025cb --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalSecurityTab/index.tsx @@ -0,0 +1 @@ +export { PersonalSecurityTab } from "./PersonalSecurityTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalSettingsPage.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalSettingsPage.tsx new file mode 100644 index 000000000..8893ff4fd --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalSettingsPage.tsx @@ -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 ( +
+
+ +
+

+ {t("settings.personal.title")} +

+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalTabGroup/PersonalTabGroup.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalTabGroup/PersonalTabGroup.tsx new file mode 100644 index 000000000..1a0c38e94 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalTabGroup/PersonalTabGroup.tsx @@ -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 ( + + + {tabs.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalTabGroup/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalTabGroup/index.tsx new file mode 100644 index 000000000..0844cd2ca --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalTabGroup/index.tsx @@ -0,0 +1 @@ +export { PersonalTabGroup } from "./PersonalTabGroup"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx index 51691a8e7..1b02ba46a 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx @@ -51,8 +51,8 @@ export const SecuritySection = () => { return ( <>
-
-

+

+

Two-factor Authentication

{ + const { mutateAsync } = useRevokeMySessions(); + + const onRevokeAllSessionsClick = async () => { + try { + await mutateAsync(); + window.location.href = "/login"; + } catch (err) { + console.error(err); + } + } + + return ( +
+
+

+ Sessions +

+ +
+

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

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/SessionsSection/SessionsTable.tsx b/frontend/src/views/Settings/PersonalSettingsPage/SessionsSection/SessionsTable.tsx new file mode 100644 index 000000000..b49771165 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/SessionsSection/SessionsTable.tsx @@ -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 ( + + + + + + + + + + + + {isLoading && } + {!isLoading && data && data.length > 0 && data.map(({ + _id, + createdAt, + lastUsed, + ip, + userAgent + }) => { + return ( + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
CreatedLast activeIP addressDevice
{formatDate(createdAt)}{formatDate(lastUsed)}{ip}{userAgent}
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/SessionsSection/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/SessionsSection/index.tsx new file mode 100644 index 000000000..653cbb804 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/SessionsSection/index.tsx @@ -0,0 +1 @@ +export { SessionsSection } from "./SessionsSection"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/index.tsx new file mode 100644 index 000000000..6181d7fb6 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/index.tsx @@ -0,0 +1 @@ +export { PersonalSettingsPage } from "./PersonalSettingsPage"; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 239aa2cf8..1a5da7a59 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -356,7 +356,7 @@ export const ProjectSettingsPage = () => { return (
{/* TODO(akhilmhdh): Remove this right when layout is refactored */} -
+