feat: added ui components

This commit is contained in:
=
2024-12-13 20:18:41 +05:30
parent 04af313bf0
commit 234e7eb9be
384 changed files with 28801 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
"preview": "vite preview",
"lint": "eslint ./src",
"lint:fix": "eslint --fix ./src",
"type:check": "tsc --project tsconfig.json --noEmit"
"type:check": "tsc --noEmit --project ./tsconfig.app.json"
},
"dependencies": {
"@casl/ability": "^6.7.2",
@@ -28,10 +28,31 @@
"@hookform/resolvers": "^3.9.1",
"@octokit/rest": "^21.0.2",
"@peculiar/x509": "^1.12.3",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-dropdown-menu": "^2.1.3",
"@radix-ui/react-hover-card": "^1.1.3",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-popper": "^1.2.1",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.3",
"@radix-ui/react-tooltip": "^1.1.5",
"@sindresorhus/slugify": "^2.2.1",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-router": "^1.87.9",
"@ucast/mongo2js": "^1.3.4",
"argon2-browser": "^1.18.0",
"axios": "^1.7.9",
"classnames": "^2.5.1",
"cva": "npm:class-variance-authority@^0.7.1",
"date-fns": "^4.1.0",
"file-saver": "^2.0.5",
"framer-motion": "^11.14.1",

View File

@@ -0,0 +1,26 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable no-undef */
import posthog from "posthog-js";
import { ENV, POSTHOG_API_KEY, POSTHOG_HOST } from "../utilities/config";
export const initPostHog = () => {
// @ts-ignore
console.log("Hi there 👋");
try {
if (typeof window !== "undefined") {
// @ts-ignore
if (ENV === "production" && TELEMETRY_CAPTURING_ENABLED === true) {
posthog.init(POSTHOG_API_KEY, {
api_host: POSTHOG_HOST
});
}
}
return posthog;
} catch (e) {
console.log("posthog err", e);
}
return undefined;
};

View File

@@ -0,0 +1,13 @@
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const Error = ({ text }: { text: string }): JSX.Element => {
return (
<div className="relative m-auto flex w-fit flex-row items-center justify-center rounded-full">
<FontAwesomeIcon icon={faExclamationTriangle} className="mx-2 mb-2 mt-1.5 text-red" />
{text && <p className="relative top-0 mr-2 py-1 text-sm text-red">{text}</p>}
</div>
);
};
export default Error;

View File

@@ -0,0 +1,129 @@
import { memo, useState } from "react";
import { faCircle, faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import guidGenerator from "../utilities/randomId";
interface InputFieldProps {
isStatic?: boolean;
label: string;
type: string;
value: string;
placeholder?: string;
isRequired: boolean;
error?: boolean;
text?: string;
name?: string;
blurred?: boolean;
errorText?: string;
onChangeHandler: (value: string) => void;
}
const InputField = ({
isRequired,
label,
onChangeHandler,
type,
value,
autoComplete,
blurred,
error,
errorText,
id,
name,
placeholder,
isStatic,
text
}: InputFieldProps & Pick<JSX.IntrinsicElements["input"], "autoComplete" | "id">) => {
const [passwordVisible, setPasswordVisible] = useState(false);
if (isStatic === true) {
return (
<div className="my-2 flex w-full max-w-md flex-col justify-center md:my-4">
<p className="mb-0.5 text-sm font-semibold text-gray-400">{label}</p>
{text && <p className="mb-2 text-xs text-gray-400">{text}</p>}
<input
onChange={(e) => onChangeHandler(e.target.value)}
type={type}
placeholder={placeholder}
value={value}
required={isRequired}
className="text-md w-full min-w-16 rounded-md border border-gray-600 bg-bunker-800 p-2 text-gray-400 outline-none"
name={name}
readOnly
autoComplete={autoComplete}
id={id}
/>
</div>
);
}
return (
<div className="w-full flex-col">
<div className="mb-0.5 flex flex-row items-center text-mineshaft-300">
<p className="mr-1 text-sm font-semibold">{label}</p>
</div>
<div
className={`group relative flex w-full max-w-2xl flex-col justify-center border ${
error ? "border-red" : "border-mineshaft-500"
} rounded-md`}
>
<input
onChange={(e) => onChangeHandler(e.target.value)}
type={passwordVisible === false ? type : "text"}
placeholder={placeholder}
value={value}
required={isRequired}
className={`${
blurred
? "text-bunker-800 focus:text-gray-400 active:text-gray-400 group-hover:text-gray-400"
: ""
} ${
error ? "focus:ring-red/50" : "focus:ring-primary/50"
} text-md peer relative w-full min-w-16 rounded-md bg-mineshaft-900 p-2 text-gray-400 outline-none duration-200 focus:ring-4`}
name={name}
spellCheck="false"
autoComplete={autoComplete}
id={id}
/>
{label?.includes("Password") && (
<button
type="button"
onClick={() => {
setPasswordVisible(!passwordVisible);
}}
className="absolute mr-3 cursor-pointer self-end text-gray-400"
>
{passwordVisible ? (
<FontAwesomeIcon icon={faEyeSlash} />
) : (
<FontAwesomeIcon icon={faEye} />
)}
</button>
)}
{blurred && (
<div className="peer absolute flex h-10 w-fit max-w-xl items-center overflow-hidden text-clip rounded-md text-gray-400/50 group-hover:hidden peer-hover:hidden peer-focus:hidden peer-active:invisible">
<p className="ml-2" />
{value
.split("")
.slice(0, 54)
.map(() => (
<FontAwesomeIcon
key={guidGenerator()}
className="mx-0.5 text-xxs"
icon={faCircle}
/>
))}
</div>
)}
{/* {error && (
<div className="absolute z-20 flex items-end justify-end mt-4 mr-1.5 self-end">
<Error />
</div>
)} */}
</div>
{error && <p className="mx-0 mb-2 mt-0.5 max-w-xs text-xs text-red">{errorText}</p>}
</div>
);
};
export default memo(InputField);

View File

@@ -0,0 +1,35 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2";
// To give users example of possible values of TTL
export const TtlFormLabel = ({ label }: { label: string }) => (
<div>
<FormLabel
label={label}
icon={
<Tooltip
content={
<span>
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-1 right-1"
/>
</Tooltip>
}
/>
</div>
);

View File

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

View File

@@ -0,0 +1,29 @@
import { useNavigate } from "@tanstack/react-router";
import { Button } from "../v2";
interface IProps {
projectId: string;
}
export const NoEnvironmentsBanner = ({ projectId }: IProps) => {
const navigate = useNavigate();
return (
<div className="mt-4 flex w-full flex-row items-center rounded-md border border-primary-600/70 bg-primary/[.07] p-4 text-base text-white">
<div className="flex w-full flex-col text-sm">
<span className="mb-2 text-lg font-semibold">
No environments in your project was found
</span>
<p className="prose">
In order to use integrations, you need to create at least one environment in your project.
</p>
</div>
<div className="my-2">
<Button onClick={() => navigate({ to: `/project/${projectId}/settings#environments` })}>
Add environments
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,71 @@
import { useOrganization, useSubscription } from "@app/context";
import { useGetOrgTrialUrl } from "@app/hooks/api";
import { Button } from "../../v2/Button";
import { Modal, ModalContent } from "../../v2/Modal";
type Props = {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
text: string;
};
export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Element => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { mutateAsync, isLoading } = useGetOrgTrialUrl();
const link =
subscription && subscription.slug !== null
? `/org/${currentOrg?.id}/billing`
: "https://infisical.com/scheduledemo";
const handleUpgradeBtnClick = async () => {
try {
if (!subscription || !currentOrg) return;
if (!subscription.has_used_trial) {
// direct user to start pro trial
const url = await mutateAsync({
orgId: currentOrg.id,
success_url: window.location.href
});
window.location.href = url;
} else {
// direct user to upgrade their plan
window.location.href = link;
}
} catch (err) {
console.error(err);
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Unleash Infisical's Full Power">
<p className="mb-2 text-bunker-300">{text}</p>
<p className="text-bunker-300">
Upgrade and get access to this, as well as to other powerful enhancements.
</p>
<div className="mt-8 flex items-center">
<Button
isLoading={isLoading}
colorSchema="primary"
onClick={handleUpgradeBtnClick}
className="mr-4"
>
{subscription && !subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => onOpenChange && onOpenChange(false)}
>
Cancel
</Button>
</div>
</ModalContent>
</Modal>
);
};

View File

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

View File

@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { twMerge } from "tailwind-merge";
import { useGetUserTotpRegistration } from "@app/hooks/api";
import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation";
import { createNotification } from "../notifications";
import { Button, ContentLoader, Input } from "../v2";
type Props = {
onComplete?: () => Promise<void>;
shouldCenterQr?: boolean;
};
const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => {
const { data: registration, isLoading } = useGetUserTotpRegistration();
const { mutateAsync: verifyUserTotp, isLoading: isVerifyLoading } =
useVerifyUserTotpRegistration();
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [totp, setTotp] = useState("");
const handleTotpVerify = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await verifyUserTotp({
totp
});
createNotification({
text: "Successfully configured mobile authenticator",
type: "success"
});
if (onComplete) {
onComplete();
}
};
useEffect(() => {
const generateQRCode = async () => {
if (registration?.otpUrl) {
const url = await QRCode.toDataURL(registration.otpUrl);
setQrCodeUrl(url);
}
};
generateQRCode();
}, [registration]);
if (isLoading) {
return <ContentLoader />;
}
return (
<div className="flex max-w-lg flex-col text-bunker-200">
<div className="mb-8">
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
code.
</div>
<div className={twMerge("mb-8 flex items-center", shouldCenterQr && "justify-center")}>
<img src={qrCodeUrl} alt="registration-qr" />
</div>
<form onSubmit={handleTotpVerify}>
<div className="mb-4">2. Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button isLoading={isVerifyLoading} type="submit">
Enable MFA
</Button>
</div>
</form>
</div>
);
};
export default TotpRegistration;

View File

@@ -0,0 +1,220 @@
import { ParsedUrlQuery } from "querystring";
import { useState } from "react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { faAngleRight, faCheck, faCopy, faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useOrganization, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { createNotification } from "../notifications";
import { IconButton, Select, SelectItem, Tooltip } from "../v2";
type Props = {
pageName: string;
isProjectRelated?: boolean;
isOrganizationRelated?: boolean;
currentEnv?: string;
userAvailableEnvs?: any[];
onEnvChange?: (slug: string) => void;
secretPath?: string;
isFolderMode?: boolean;
isProtectedBranch?: boolean;
protectionPolicyName?: string;
};
// TODO: make links clickable and clean up
/**
* This is the component at the top of almost every page.
* It shows how to navigate to a certain page.
* It future these links should also be clickable and hoverable
* @param {object} obj
* @param {string} obj.pageName - Name of the page
* @param {boolean} obj.isProjectRelated - whether or not this page is related to project (determine if it's 2 or 3 navigation steps)
* @param {boolean} obj.isOrganizationRelated - whether or not this page is related to organization (determine if it's 2 or 3 navigation steps)
* @param {string} obj.currentEnv - current environment inside a project
* @param {string} obj.userAvailableEnvs - environments that are available to a user in this project (used for the dropdown)
* @param {string} obj.onEnvChange - the action that happens when an env is changed
* @returns
*/
// TODO(akhilmhdh): simply this header and nav system later
export default function NavHeader({
pageName,
isProjectRelated,
isOrganizationRelated,
currentEnv,
userAvailableEnvs = [],
onEnvChange,
isFolderMode,
secretPath = "/",
isProtectedBranch = false,
protectionPolicyName
}: Props): JSX.Element {
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const [isCopied, { timedToggle: toggleIsCopied }] = useToggle(false);
const [isHoveringCopyButton, setIsHoveringCopyButton] = useState(false);
const navigate = useNavigate();
const secretPathSegments = secretPath.split("/").filter(Boolean);
return (
<div className="flex flex-row items-center pt-6">
<div className="mr-2 flex h-5 w-5 min-w-[1.25rem] items-center justify-center rounded-md bg-primary text-sm text-black">
{currentOrg?.name?.charAt(0)}
</div>
<Link
to={`/org/${currentOrg?.id}/overview`}
className="truncate pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary"
>
{currentOrg?.name}
</Link>
{isProjectRelated && (
<>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-xs text-gray-400" />
<div className="truncate text-sm font-semibold text-bunker-300">
{currentWorkspace?.name}
</div>
</>
)}
{isOrganizationRelated && (
<>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-xs text-gray-400" />
<div className="text-sm font-semibold text-bunker-300">Organization Settings</div>
</>
)}
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-sm text-gray-400" />
{pageName === "Secrets" ? (
<Link to={{ pathname: "/project/[id]/secrets/overview", query: { id: navigate.query.id } }}>
<a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a>
</Link>
) : (
<div className="text-sm text-gray-400">{pageName}</div>
)}
{currentEnv && secretPath === "/" && (
<>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
<div className="rounded-md pl-3 hover:bg-bunker-100/10">
<Tooltip content="Select environment">
<Select
value={currentEnv}
onValueChange={(value) => {
if (value && onEnvChange) onEnvChange(value);
}}
className="border-none bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl"
>
{userAvailableEnvs?.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</Tooltip>
</div>
</>
)}
{isFolderMode && Boolean(secretPathSegments.length) && (
<div className="flex items-center space-x-3">
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
<Link
passHref
legacyBehavior
href={{
pathname: "/project/[id]/secrets/[env]",
query: { id: navigate.query.id, env: navigate.query.env }
}}
>
<a className="text-sm font-semibold text-primary/80 hover:text-primary">
{userAvailableEnvs?.find(({ slug }) => slug === currentEnv)?.name}
</a>
</Link>
</div>
)}
{isFolderMode &&
secretPathSegments?.map((folderName, index) => {
const query: ParsedUrlQuery & { secretPath: string } = {
...navigate.query,
secretPath: `/${secretPathSegments.slice(0, index + 1).join("/")}`
};
return (
<div
className="flex items-center space-x-3"
key={`breadcrumb-secret-path-${folderName}`}
>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
{index + 1 === secretPathSegments?.length ? (
<div className="flex items-center space-x-2">
<span
className={twMerge(
"text-sm font-semibold transition-all",
isHoveringCopyButton ? "text-bunker-200" : "text-bunker-300"
)}
>
{folderName}
</span>
<Tooltip
className="relative right-2"
position="bottom"
content="Copy secret path"
>
<IconButton
variant="plain"
ariaLabel="copy"
onMouseEnter={() => setIsHoveringCopyButton(true)}
onMouseLeave={() => setIsHoveringCopyButton(false)}
onClick={() => {
if (isCopied) return;
navigator.clipboard.writeText(query.secretPath);
createNotification({
text: "Copied secret path to clipboard",
type: "info"
});
toggleIsCopied(2000);
}}
className="hover:bg-bunker-100/10"
>
<FontAwesomeIcon
icon={!isCopied ? faCopy : faCheck}
size="sm"
className="cursor-pointer"
/>
</IconButton>
</Tooltip>
</div>
) : (
<Link
passHref
legacyBehavior
href={{ pathname: "/project/[id]/secrets/[env]", query }}
>
<a
className={twMerge(
"text-sm font-semibold transition-all hover:text-primary",
isHoveringCopyButton ? "text-primary" : "text-primary/80"
)}
>
{folderName}
</a>
</Link>
)}
</div>
);
})}
{isProtectedBranch && (
<Tooltip content={`Protected by policy ${protectionPolicyName}`}>
<FontAwesomeIcon icon={faLock} className="ml-2 text-primary" />
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal, ModalContent, ModalTrigger, Select, SelectItem } from "@app/components/v2";
import { useLocation, useNavigate } from "@tanstack/react-router";
enum Region {
US = "us",
EU = "eu"
}
const regions = [
{
value: Region.US,
label: "United States",
location: "Virginia, USA",
flag: (
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-us" viewBox="0 0 640 480">
<path fill="#bd3d44" d="M0 0h640v480H0" />
<path
stroke="#fff"
strokeWidth="37"
d="M0 55.3h640M0 129h640M0 203h640M0 277h640M0 351h640M0 425h640"
/>
<path fill="#192f5d" d="M0 0h364.8v258.5H0" />
<marker id="us-a" markerHeight="30" markerWidth="30">
<path fill="#fff" d="m14 0 9 27L0 10h28L5 27z" />
</marker>
<path
fill="none"
markerMid="url(#us-a)"
d="m0 0 16 11h61 61 61 61 60L47 37h61 61 60 61L16 63h61 61 61 61 60L47 89h61 61 60 61L16 115h61 61 61 61 60L47 141h61 61 60 61L16 166h61 61 61 61 60L47 192h61 61 60 61L16 218h61 61 61 61 60z"
/>
</svg>
)
},
{
value: Region.EU,
label: "Europe",
location: "Frankfurt, Germany",
flag: (
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-eu" viewBox="0 0 512 512">
<defs>
<g id="eu-d">
<g id="eu-b">
<path id="eu-a" d="m0-1-.3 1 .5.1z" />
<use xlinkHref="#eu-a" transform="scale(-1 1)" />
</g>
<g id="eu-c">
<use xlinkHref="#eu-b" transform="rotate(72)" />
<use xlinkHref="#eu-b" transform="rotate(144)" />
</g>
<use xlinkHref="#eu-c" transform="scale(-1 1)" />
</g>
</defs>
<path fill="#039" d="M0 0h512v512H0z" />
<g fill="#fc0" transform="translate(256 258.4)scale(25.28395)">
<use xlinkHref="#eu-d" width="100%" height="100%" y="-6" />
<use xlinkHref="#eu-d" width="100%" height="100%" y="6" />
<g id="eu-e">
<use xlinkHref="#eu-d" width="100%" height="100%" x="-6" />
<use xlinkHref="#eu-d" width="100%" height="100%" transform="rotate(-144 -2.3 -2.1)" />
<use xlinkHref="#eu-d" width="100%" height="100%" transform="rotate(144 -2.1 -2.3)" />
<use xlinkHref="#eu-d" width="100%" height="100%" transform="rotate(72 -4.7 -2)" />
<use xlinkHref="#eu-d" width="100%" height="100%" transform="rotate(72 -5 .5)" />
</g>
<use xlinkHref="#eu-e" width="100%" height="100%" transform="scale(-1 1)" />
</g>
</svg>
)
}
];
export const RegionSelect = () => {
const navigate = useNavigate();
const location = useLocation();
const handleRegionSelect = (value: Region) => {
navigate({ to: `https://${value}.infisical.com/${location.pathname}` });
};
const shouldDisplay =
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://us.infisical.com") ||
window.location.origin.includes("https://eu.infisical.com") ||
window.location.origin.includes("http://localhost:8080");
// only display region select for cloud
if (!shouldDisplay) return null;
const [subdomain] = window.location.host.split(".");
// default to US if not eu
const currentRegion = subdomain === Region.EU ? regions[1] : regions[0];
return (
<div className="mb-8 flex flex-col items-center">
<Select
className="w-44"
onValueChange={handleRegionSelect}
defaultValue={currentRegion.value}
>
{regions.map(({ value, label, flag }) => (
<SelectItem value={value} key={value}>
<div className="flex items-center gap-2">
<div className="w-4">{flag}</div>
{label}
</div>
</SelectItem>
))}
</Select>
<Modal>
<ModalTrigger>
<button type="button" className="mt-1 text-right text-xs text-mineshaft-400 underline">
Help me pick a data region
</button>
</ModalTrigger>
<ModalContent
title="Infisical Cloud data regions"
subTitle="Select the closest region to you and your team. Contact Infisical if you need to migrate regions."
>
{regions.map(({ value, label, location: regionLocation, flag }) => (
<div className="mb-6" key={value}>
<p className="font-medium">
<span className="mr-2 inline-block w-4">{flag}</span>
{value.toUpperCase()} Region
</p>
<ul className="ml-6 mt-2 flex flex-col gap-1">
<li>
<FontAwesomeIcon size="xs" className="mr-0.5 text-green" icon={faCheck} /> Fastest
option if you are based in {value === Region.US ? "the" : ""} {label}
</li>
<li>
<FontAwesomeIcon size="xs" className="mr-0.5 text-green" icon={faCheck} /> Data
storage compliance for this region
</li>
<li>
<FontAwesomeIcon size="xs" className="mr-0.5 text-green" icon={faCheck} /> Hosted
in {regionLocation}
</li>
</ul>
</div>
))}
</ModalContent>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,79 @@
import { ReactNode } from "react";
import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toastify";
import { faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { CopyButton } from "../v2/CopyButton";
export type TNotification = {
title?: string;
text: ReactNode;
children?: ReactNode;
callToAction?: ReactNode;
copyActions?: { icon?: IconDefinition; value: string; name: string; label?: string }[];
};
export const NotificationContent = ({
title,
text,
children,
callToAction,
copyActions
}: TNotification) => {
return (
<div className="msg-container">
{title && <div className="text-md mb-1 font-medium">{title}</div>}
<div className={title ? "text-sm text-neutral-400" : "text-md"}>{text}</div>
{children && <div className="mt-2">{children}</div>}
{(callToAction || copyActions) && (
<div
className={twMerge(
"mt-2 flex h-7 w-full flex-row items-end gap-2",
callToAction ? "justify-between" : "justify-end"
)}
>
{callToAction}
{copyActions && (
<div className="flex h-7 flex-row items-center gap-2">
{copyActions.map((action) => (
<div className="flex flex-row items-center gap-2" key={`copy-${action.name}`}>
{action.label && (
<span className="ml-2 text-xs text-mineshaft-400">{action.label}</span>
)}
<CopyButton
value={action.value}
name={action.name}
size="xs"
variant="plain"
color="text-mineshaft-400"
icon={action.icon ?? faCopy}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
export const createNotification = (
myProps: TNotification & { type?: TypeOptions },
toastProps: ToastOptions = {}
): Id =>
toast(<NotificationContent {...myProps} />, {
position: "bottom-right",
...toastProps,
theme: "dark",
type: myProps?.type || "info"
});
export const NotificationContainer = () => (
<ToastContainer
pauseOnHover
toastClassName="border border-mineshaft-500"
style={{ width: "400px" }}
/>
);

View File

@@ -0,0 +1 @@
export { createNotification, NotificationContainer } from "./Notifications";

View File

@@ -0,0 +1,33 @@
import { useState } from "react";
import picomatch from "picomatch";
import { FormControl } from "../v2/FormControl";
import { Input } from "../v2/Input";
export const GlobPermissionInfo = () => {
const [pattern, setPattern] = useState("");
const [text, setText] = useState("");
return (
<div>
<div className="mt-2">A glob pattern uses wildcards to match resources or paths.</div>
<div>
<FormControl label="Glob pattern" helperText="Examples: /{a,b}, DB_**">
<Input value={pattern} onChange={(e) => setPattern(e.target.value)} />
</FormControl>
</div>
<div>
<FormControl
label="Test string"
helperText="Type a value to test glob match"
isError={
pattern && text ? !picomatch.isMatch(text, pattern, { strictSlashes: false }) : false
}
errorText="Invalid"
>
<Input value={text} onChange={(e) => setText(e.target.value)} />
</FormControl>
</div>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { FunctionComponent, ReactNode } from "react";
import { BoundCanProps, Can } from "@casl/react";
import { TOrgPermission, useOrgPermission } from "@app/context/OrgPermissionContext";
import { Tooltip } from "../v2";
type Props = {
label?: ReactNode;
// this prop is used when there exist already a tooltip as helper text for users
// so when permission is allowed same tooltip will be reused to show helpertext
renderTooltip?: boolean;
allowedLabel?: string;
} & BoundCanProps<TOrgPermission>;
export const OrgPermissionCan: FunctionComponent<Props> = ({
label = "Access restricted",
children,
passThrough = true,
renderTooltip,
allowedLabel,
...props
}) => {
const { permission } = useOrgPermission();
return (
<Can {...props} passThrough={passThrough} ability={props?.ability || permission}>
{(isAllowed, ability) => {
// akhilmhdh: This is set as type due to error in casl react type.
const finalChild =
typeof children === "function"
? children(isAllowed, ability as TOrgPermission)
: children;
if (!isAllowed && passThrough) {
return <Tooltip content={label}>{finalChild}</Tooltip>;
}
if (isAllowed && renderTooltip) {
return <Tooltip content={allowedLabel}>{finalChild}</Tooltip>;
}
if (!isAllowed) return null;
return finalChild;
}}
</Can>
);
};

View File

@@ -0,0 +1,38 @@
import { ReactNode } from "react";
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
type Props = {
containerClassName?: string;
className?: string;
children?: ReactNode;
};
export const PermissionDeniedBanner = ({ containerClassName, className, children }: Props) => {
return (
<div
className={twMerge(
"container mx-auto flex h-full items-center justify-center",
containerClassName
)}
>
<div className={twMerge("rounded-md bg-mineshaft-800 p-16 text-bunker-300", className)}>
<div className="flex items-end space-x-12">
<div>
<FontAwesomeIcon icon={faLock} size="6x" />
</div>
<div>
<div className="mb-2 text-4xl font-medium">Access Restricted</div>
{children || (
<div className="text-sm">
Your role has limited permissions, please <br /> contact your administrator to gain
access
</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import { FunctionComponent, ReactNode } from "react";
import { AbilityTuple, MongoAbility } from "@casl/ability";
import { Can } from "@casl/react";
import { ProjectPermissionSet, useProjectPermission } from "@app/context/ProjectPermissionContext";
import { Tooltip } from "../v2/Tooltip";
type Props<T extends AbilityTuple> = {
label?: ReactNode;
// this prop is used when there exist already a tooltip as helper text for users
// so when permission is allowed same tooltip will be reused to show helpertext
renderTooltip?: boolean;
allowedLabel?: string;
children: ReactNode | ((isAllowed: boolean, ability: T) => ReactNode);
passThrough?: boolean;
I: T[0];
a: T[1];
ability?: MongoAbility<T>;
};
export const ProjectPermissionCan: FunctionComponent<Props<ProjectPermissionSet>> = ({
label = "Access restricted",
children,
passThrough = true,
renderTooltip,
allowedLabel,
...props
}) => {
const { permission } = useProjectPermission();
return (
<Can {...props} passThrough={passThrough} ability={props?.ability || permission}>
{(isAllowed, ability) => {
// akhilmhdh: This is set as type due to error in casl react type.
const finalChild =
typeof children === "function" ? children(isAllowed, ability as any) : children;
if (!isAllowed && passThrough) {
return <Tooltip content={label}>{finalChild}</Tooltip>;
}
if (isAllowed && renderTooltip) {
return <Tooltip content={allowedLabel}>{finalChild}</Tooltip>;
}
if (!isAllowed) return null;
return finalChild;
}}
</Can>
);
};

View File

@@ -0,0 +1,4 @@
export { GlobPermissionInfo } from "./GlobPermissionInfo";
export { OrgPermissionCan } from "./OrgPermissionCan";
export { PermissionDeniedBanner } from "./PermissionDeniedBanner";
export { ProjectPermissionCan } from "./ProjectPermissionCan";

View File

@@ -0,0 +1,149 @@
/* eslint-disable react/jsx-props-no-spreading */
import { useState } from "react";
import ReactCodeInput from "react-code-input";
import { useTranslation } from "react-i18next";
import { useSendVerificationEmail } from "@app/hooks/api";
import Error from "../basic/Error";
import { Button } from "../v2";
// The style for the verification code input
const props = {
inputStyle: {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield",
width: "55px",
borderRadius: "5px",
fontSize: "24px",
height: "55px",
paddingLeft: "7",
backgroundColor: "#0d1117",
color: "white",
border: "1px solid #2d2f33",
textAlign: "center",
outlineColor: "#8ca542",
borderColor: "#2d2f33"
}
} as const;
const propsPhone = {
inputStyle: {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield",
width: "40px",
borderRadius: "5px",
fontSize: "24px",
height: "40px",
paddingLeft: "7",
backgroundColor: "#0d1117",
color: "white",
border: "1px solid #2d2f33",
textAlign: "center",
outlineColor: "#8ca542",
borderColor: "#2d2f33"
}
} as const;
interface CodeInputStepProps {
email: string;
incrementStep: () => void;
setCode: (value: string) => void;
codeError: boolean;
isCodeInputCheckLoading: boolean;
}
/**
* This is the second step of sign up where users need to verify their email
* @param {object} obj
* @param {string} obj.email - user's email to which we just sent a verification email
* @param {function} obj.incrementStep - goes to the next step of signup
* @param {function} obj.setCode - state updating function that set the current value of the emai verification code
* @param {boolean} obj.codeError - whether the code was inputted wrong or now
* @returns
*/
export default function CodeInputStep({
email,
incrementStep,
setCode,
codeError,
isCodeInputCheckLoading
}: CodeInputStepProps): JSX.Element {
const { mutateAsync } = useSendVerificationEmail();
const [isLoading, setIsLoading] = useState(false);
const [isResendingVerificationEmail, setIsResendingVerificationEmail] = useState(false);
const { t } = useTranslation();
const resendVerificationEmail = async () => {
setIsResendingVerificationEmail(true);
setIsLoading(true);
await mutateAsync({ email });
setTimeout(() => {
setIsLoading(false);
setIsResendingVerificationEmail(false);
}, 2000);
};
return (
<div className="mx-auto h-full w-full pb-4 md:px-8">
<p className="text-md flex justify-center text-bunker-200">{t("signup.step2-message")}</p>
<p className="text-md my-1 flex justify-center font-semibold text-bunker-200">{email} </p>
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setCode}
{...props}
className="mb-2 mt-6"
/>
</div>
<div className="mx-auto mt-4 block w-max md:hidden">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setCode}
{...propsPhone}
className="mb-2 mt-2"
/>
</div>
{codeError && <Error text={t("signup.step2-code-error")} />}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-l w-full py-1 text-lg">
<Button
type="submit"
onClick={incrementStep}
size="sm"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isCodeInputCheckLoading}
>
{" "}
{String(t("signup.verify"))}{" "}
</Button>
</div>
</div>
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
<div className="flex flex-row items-baseline gap-1 text-sm">
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
<div className="text-md mt-2 flex flex-row text-bunker-400">
<button disabled={isLoading} onClick={resendVerificationEmail} type="button">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{isResendingVerificationEmail
? t("signup.step2-resend-progress")
: t("signup.step2-resend-submit")}
</span>
</button>
</div>
</div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { useTranslation } from "react-i18next";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useToggle } from "@app/hooks";
import { generateUserBackupKey } from "@app/lib/crypto";
import { createNotification } from "../notifications";
import { generateBackupPDFAsync } from "../utilities/generateBackupPDF";
import { Button } from "../v2";
interface DownloadBackupPDFStepProps {
incrementStep: () => void;
email: string;
password: string;
name: string;
}
/**
* This is the step of the signup flow where the user downloads the backup pdf
* @param {object} obj
* @param {function} obj.incrementStep - function that moves the user on to the next stage of signup
* @param {string} obj.email - user's email
* @param {string} obj.password - user's password
* @param {string} obj.name - user's name
* @returns
*/
export default function DonwloadBackupPDFStep({
incrementStep,
email,
password,
name
}: DownloadBackupPDFStepProps): JSX.Element {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useToggle();
const handleBackupKeyGenerate = async () => {
try {
setIsLoading.on();
const generatedKey = await generateUserBackupKey(email, password);
await generateBackupPDFAsync({
generatedKey,
personalEmail: email,
personalName: name
});
incrementStep();
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to generate backup key"
});
} finally {
setIsLoading.off();
}
};
return (
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
<p className="flex flex-col items-center justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
<FontAwesomeIcon
icon={faWarning}
className="mb-6 ml-2 mr-3 pt-1 text-6xl text-bunker-200"
/>
{t("signup.step4-message")}
</p>
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
<span className="mb-2">
{t("signup.step4-description1")} {t("signup.step4-description3")}
</span>
</div>
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
<div className="text-l w-full py-1 text-lg">
<Button
onClick={handleBackupKeyGenerate}
size="sm"
isFullWidth
isLoading={isLoading}
isDisabled={isLoading}
className="h-12"
colorSchema="primary"
variant="outline_bg"
>
Download PDF
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "@tanstack/react-router";
import axios from "axios";
import { createNotification } from "@app/components/notifications";
import { useSendVerificationEmail } from "@app/hooks/api";
import { Button, Input } from "../v2";
interface DownloadBackupPDFStepProps {
incrementStep: () => void;
email: string;
setEmail: (value: string) => void;
}
/**
* This is the first step of the sign up process - users need to enter their email
* @param {object} obj
* @param {string} obj.email - email of a user signing up
* @param {function} obj.setEmail - funciton that manages the state of the email variable
* @param {function} obj.incrementStep - function to go to the next step of the signup flow
* @returns
*/
export default function EnterEmailStep({
email,
setEmail,
incrementStep
}: DownloadBackupPDFStepProps): JSX.Element {
const { mutateAsync, isLoading } = useSendVerificationEmail();
const [emailError, setEmailError] = useState(false);
const { t } = useTranslation();
/**
* Verifies if the entered email "looks" correct
*/
const emailCheck = async () => {
let emailCheckBool = false;
if (!email) {
setEmailError(true);
emailCheckBool = true;
} else if (!email.includes("@") || !email.includes(".") || !/[a-z]/.test(email)) {
setEmailError(true);
emailCheckBool = true;
} else {
setEmailError(false);
}
// If everything is correct, go to the next step
if (!emailCheckBool) {
try {
await mutateAsync({ email: email.toLowerCase() });
setEmail(email.toLowerCase());
incrementStep();
} catch (e) {
if (axios.isAxiosError(e)) {
const { message = "Something went wrong" } = e.response?.data as { message: string };
createNotification({
type: "error",
text: message
});
}
}
}
};
return (
<div>
<div className="mx-auto w-full md:px-6">
<p className="flex justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-xl font-medium text-transparent">
{t("signup.step1-start")}
</p>
<div className="m-auto mt-8 flex w-1/4 min-w-[20rem] flex-col items-center justify-center rounded-lg lg:w-1/6">
<Input
placeholder="Enter your email address..."
onChange={(e) => setEmail(e.target.value)}
value={email}
isRequired
autoComplete="username"
className="h-12"
/>
{emailError && (
<p className="ml-1.5 mt-1.5 w-full text-left text-xs text-red-600">
Please enter a valid email.
</p>
)}
</div>
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-1/6">
<div className="text-l w-full py-1 text-lg">
<Button
type="submit"
onClick={emailCheck}
size="sm"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={isLoading}
>
{" "}
{String(t("signup.step1-submit"))}{" "}
</Button>
</div>
</div>
</div>
<div className="mx-auto mb-48 mt-2 flex w-full max-w-md flex-col items-center justify-center pt-2 md:mb-16 md:pb-2">
<Link to="/login">
<button type="button" className="w-max pb-3 duration-200 hover:opacity-90">
<span className="cursor-pointer text-sm text-mineshaft-400 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{t("signup.already-have-account")}
</span>
</button>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useTranslation } from "react-i18next";
import { Link } from "@tanstack/react-router";
import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faEnvelope } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { RegionSelect } from "@app/components/navigation/RegionSelect";
import { useServerConfig } from "@app/context";
import { LoginMethod } from "@app/hooks/api/admin/types";
import { Button } from "../v2";
export default function InitialSignupStep({
setIsSignupWithEmail
}: {
setIsSignupWithEmail: (value: boolean) => void;
}) {
const { t } = useTranslation();
const { config } = useServerConfig();
const shouldDisplaySignupMethod = (method: LoginMethod) =>
!config.enabledLoginMethods || config.enabledLoginMethods.includes(method);
return (
<div className="mx-auto flex w-full flex-col items-center justify-center">
<h1 className="mb-8 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
{t("signup.initial-title")}
</h1>
<RegionSelect />
{shouldDisplaySignupMethod(LoginMethod.GOOGLE) && (
<div className="w-1/4 min-w-[20rem] rounded-md lg:w-1/6">
<Button
colorSchema="primary"
variant="solid"
onClick={() => {
window.open("/api/v1/sso/redirect/google");
window.close();
}}
leftIcon={<FontAwesomeIcon icon={faGoogle} className="mr-2" />}
className="mx-0 h-12 w-full"
>
{t("signup.continue-with-google")}
</Button>
</div>
)}
{shouldDisplaySignupMethod(LoginMethod.GITHUB) && (
<div className="mt-4 w-1/4 min-w-[20rem] rounded-md lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
window.open("/api/v1/sso/redirect/github");
window.close();
}}
leftIcon={<FontAwesomeIcon icon={faGithub} className="mr-2" />}
className="mx-0 h-12 w-full"
>
Continue with GitHub
</Button>
</div>
)}
{shouldDisplaySignupMethod(LoginMethod.GITLAB) && (
<div className="mt-4 w-1/4 min-w-[20rem] rounded-md lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
window.open("/api/v1/sso/redirect/gitlab");
window.close();
}}
leftIcon={<FontAwesomeIcon icon={faGitlab} className="mr-2" />}
className="mx-0 h-12 w-full"
>
Continue with GitLab
</Button>
</div>
)}
{shouldDisplaySignupMethod(LoginMethod.EMAIL) && (
<div className="mt-4 w-1/4 min-w-[20rem] rounded-md text-center lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
setIsSignupWithEmail(true);
}}
leftIcon={<FontAwesomeIcon icon={faEnvelope} className="mr-2" />}
className="mx-0 h-12 w-full"
>
Continue with Email
</Button>
</div>
)}
<div className="mt-6 w-1/4 min-w-[20rem] px-8 text-center text-xs text-bunker-400 lg:w-1/6">
{t("signup.create-policy")}
</div>
<div className="mt-2 flex flex-row text-xs text-bunker-400">
<Link to="/login">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{t("signup.already-have-account")}
</span>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { useAddUsersToOrg } from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { usePopUp } from "@app/hooks/usePopUp";
import { Button, EmailServiceSetupModal } from "../v2";
/**
* This is the last step of the signup flow. People can optionally invite their teammates here.
*/
export default function TeamInviteStep(): JSX.Element {
const { t } = useTranslation();
const router = useRouter();
const [emails, setEmails] = useState("");
const { data: serverDetails } = useFetchServerStatus();
const { mutateAsync } = useAddUsersToOrg();
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
// Redirect user to the getting started page
const redirectToHome = async () => {
router.push(`/org/${localStorage.getItem("orgData.id")}/overview`);
};
const inviteUsers = async ({ emails: inviteEmails }: { emails: string }) => {
inviteEmails
.split(",")
.map((email) => email.trim())
.map(async (email) => {
mutateAsync({
inviteeEmails: [email],
organizationId: String(localStorage.getItem("orgData.id")),
organizationRoleSlug: "member"
});
});
await redirectToHome();
};
return (
<div className="min-w-lg mx-auto mb-64 h-full w-max px-8 pb-4 md:mb-32">
<p className="flex justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-2xl font-semibold text-transparent">
{t("signup.step5-invite-team")}
</p>
<p className="mb-6 mt-4 flex justify-center text-center text-bunker-400 md:mx-8">
{t("signup.step5-subtitle")}
</p>
<div className="mx-auto mb-6 w-max rounded-xl border border-mineshaft-500 bg-mineshaft-800 px-8 pt-6 pb-4 drop-shadow-xl">
<div>
<div className="pl-1 pb-1 text-sm font-medium text-bunker-300">
<span>Emails</span>
</div>
<textarea
className="h-20 w-full min-w-[30rem] rounded-md border border-mineshaft-500 bg-mineshaft-900/70 py-1 px-2 text-sm text-bunker-300 outline-none ring-primary-800 ring-opacity-70 placeholder:text-bunker-400 focus:ring-2"
value={emails}
onChange={(e) => setEmails(e.target.value)}
placeholder="email@example.com, email2@example.com..."
/>
</div>
<div className="mx-auto mt-2 flex w-full flex-row items-end justify-end text-sm md:mt-4 md:mb-2 md:min-w-[30rem] md:max-w-md">
<Button
onClick={() => {
if (serverDetails?.emailConfigured) {
inviteUsers({ emails });
} else {
handlePopUpOpen("setUpEmail");
}
}}
size="sm"
// isFullWidth
className="h-10"
colorSchema="primary"
variant="solid"
>
{" "}
{t("signup.step5-send-invites") ?? ""}{" "}
</Button>
</div>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
</div>
<div className="min-w-28 mx-auto mt-4 mb-2 flex max-h-24 min-w-[20rem] max-w-max flex-row items-center justify-center px-4 text-lg md:p-2">
<Button
onClick={redirectToHome}
size="sm"
isFullWidth
className="h-12"
colorSchema="secondary"
variant="outline"
>
{" "}
{t("signup.step5-skip") ?? "Skip"}{" "}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,336 @@
import crypto from "crypto";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import jsrp from "jsrp";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import ProjectService from "@app/services/ProjectService";
import InputField from "../basic/InputField";
import checkPassword from "../utilities/checks/password/checkPassword";
import Aes256Gcm from "../utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "../utilities/cryptography/crypto";
import { saveTokenToLocalStorage } from "../utilities/saveTokenToLocalStorage";
import SecurityClient from "../utilities/SecurityClient";
import { Button, Input } from "../v2";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
interface UserInfoStepProps {
incrementStep: () => void;
email: string;
password: string;
setPassword: (value: string) => void;
name: string;
setName: (value: string) => void;
organizationName: string;
setOrganizationName: (value: string) => void;
attributionSource: string;
setAttributionSource: (value: string) => void;
providerAuthToken?: string;
}
type Errors = {
tooShort?: string;
tooLong?: string;
noLetterChar?: string;
noNumOrSpecialChar?: string;
repeatedChar?: string;
escapeChar?: string;
lowEntropy?: string;
breached?: string;
};
/**
* This is the step of the sign up flow where people provife their name/surname and password
* @param {object} obj
* @param {string} obj.verificationToken - the token which we use to verify the legitness of a user
* @param {string} obj.incrementStep - a function to move to the next signup step
* @param {string} obj.email - email of a user who is signing up
* @param {string} obj.password - user's password
* @param {string} obj.setPassword - function managing the state of user's password
* @param {string} obj.firstName - user's first name
* @param {string} obj.setFirstName - function managing the state of user's first name
* @param {string} obj.lastName - user's lastName
* @param {string} obj.setLastName - function managing the state of user's last name
*/
export default function UserInfoStep({
incrementStep,
email,
password,
setPassword,
name,
setName,
organizationName,
setOrganizationName,
attributionSource,
setAttributionSource,
providerAuthToken
}: UserInfoStepProps): JSX.Element {
const [nameError, setNameError] = useState(false);
const [organizationNameError, setOrganizationNameError] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const { mutateAsync: selectOrganization } = useSelectOrganization();
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
// Verifies if the information that the users entered (name, workspace)
// is there, and if the password matches the criteria.
const signupErrorCheck = async () => {
setIsLoading(true);
let errorCheck = false;
if (!name) {
setNameError(true);
errorCheck = true;
} else {
setNameError(false);
}
if (!organizationName) {
setOrganizationNameError(true);
errorCheck = true;
} else {
setOrganizationNameError(false);
}
errorCheck = await checkPassword({
password,
setErrors
});
if (!errorCheck) {
// Generate a random pair of a public and a private key
const pair = nacl.box.keyPair();
const secretKeyUint8Array = pair.secretKey;
const publicKeyUint8Array = pair.publicKey;
const privateKey = encodeBase64(secretKeyUint8Array);
const publicKey = encodeBase64(publicKeyUint8Array);
localStorage.setItem("PRIVATE_KEY", privateKey);
client.init(
{
username: email,
password
},
async () => {
client.createVerifier(async (err: any, result: { salt: string; verifier: string }) => {
try {
// TODO: moduralize into KeyService
const derivedKey = await deriveArgonKey({
password,
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);
// create encrypted private key by encrypting the private
// key with the symmetric key [key]
const {
ciphertext: encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
} = Aes256Gcm.encrypt({
text: privateKey,
secret: key
});
// create the protected key by encrypting the symmetric key
// [key] with the derived key
const {
ciphertext: protectedKey,
iv: protectedKeyIV,
tag: protectedKeyTag
} = Aes256Gcm.encrypt({
text: key.toString("hex"),
secret: Buffer.from(derivedKey.hash)
});
const response = await completeAccountSignup({
email,
password,
firstName: name.split(" ")[0],
lastName: name.split(" ").slice(1).join(" "),
protectedKey,
protectedKeyIV,
protectedKeyTag,
publicKey,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
providerAuthToken,
salt: result.salt,
verifier: result.verifier,
organizationName,
attributionSource
});
// unset signup JWT token and set JWT token
SecurityClient.setSignupToken("");
SecurityClient.setToken(response.token);
SecurityClient.setProviderAuthToken("");
if (response.organizationId) {
await selectOrganization({ organizationId: response.organizationId });
}
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
privateKey
});
const userOrgs = await fetchOrganizations();
const orgId = userOrgs[0]?.id;
const project = await ProjectService.initProject({
projectName: "Example Project"
});
localStorage.setItem("orgData.id", orgId);
localStorage.setItem("projectData.id", project.id);
incrementStep();
} catch (error) {
setIsLoading(false);
console.error(error);
}
});
}
);
} else {
setIsLoading(false);
}
};
return (
<div className="mx-auto mb-36 h-full w-max rounded-xl md:mb-16 md:px-8">
<p className="text-medium mx-8 mb-6 flex justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-xl font-bold text-transparent md:mx-16">
{t("signup.step3-message")}
</p>
<div className="mx-auto mb-36 h-full w-max rounded-xl py-6 md:mb-16 md:border md:border-mineshaft-600 md:bg-mineshaft-800 md:px-8">
<div className="relative z-0 flex w-1/4 w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Your Name
</p>
<Input
placeholder="Jane Doe"
onChange={(e) => setName(e.target.value)}
value={name}
isRequired
autoComplete="given-name"
className="h-12"
/>
{nameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">
Please, specify your name
</p>
)}
</div>
<div className="relative z-0 flex w-1/4 w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Organization Name
</p>
<Input
placeholder="Infisical"
onChange={(e) => setOrganizationName(e.target.value)}
value={organizationName}
maxLength={64}
isRequired
className="h-12"
/>
{organizationNameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">
Please, specify your organization name
</p>
)}
</div>
<div className="relative z-0 flex w-1/4 w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Where did you hear about us? <span className="font-light">(optional)</span>
</p>
<Input
placeholder=""
onChange={(e) => setAttributionSource(e.target.value)}
value={attributionSource}
className="h-12"
/>
</div>
<div className="mt-2 flex max-h-60 w-1/4 w-full min-w-[20rem] flex-col items-center justify-center rounded-lg py-2 lg:w-1/6">
<InputField
label={t("section.password.password")}
onChangeHandler={async (pass: string) => {
setPassword(pass);
await checkPassword({
password: pass,
setErrors
});
}}
type="password"
value={password}
isRequired
error={Object.keys(errors).length > 0}
autoComplete="new-password"
id="new-password"
/>
{Object.keys(errors).length > 0 && (
<div className="mt-4 flex w-full flex-col items-start rounded-md bg-white/5 px-2 py-2">
<div className="mb-2 text-sm text-gray-400">
{t("section.password.validate-base")}
</div>
{Object.keys(errors).map((key) => {
if (errors[key as keyof Errors]) {
return (
<div className="items-top ml-1 flex flex-row justify-start" key={key}>
<div>
<FontAwesomeIcon
icon={faXmark}
className="text-md ml-0.5 mr-2.5 text-red"
/>
</div>
<p className="text-sm text-gray-400">{errors[key as keyof Errors]}</p>
</div>
);
}
return null;
})}
</div>
)}
</div>
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-l w-full py-1 text-lg">
<Button
type="submit"
onClick={signupErrorCheck}
size="sm"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{" "}
{String(t("signup.signup"))}{" "}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalClose,
ModalContent,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateWsTag } from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
export const secretTagsColors = [
{
id: 1,
hex: "#bec2c8",
rgba: "rgb(128,128,128, 0.8)",
name: "Grey"
},
{
id: 2,
hex: "#95a2b3",
rgba: "rgb(0,0,255, 0.8)",
name: "blue"
},
{
id: 3,
hex: "#5e6ad2",
rgba: "rgb(128,0,128, 0.8)",
name: "Purple"
},
{
id: 4,
hex: "#26b5ce",
rgba: "rgb(0,128,128, 0.8)",
name: "Teal"
},
{
id: 5,
hex: "#4cb782",
rgba: "rgb(0,128,0, 0.8)",
name: "Green"
},
{
id: 6,
hex: "#f2c94c",
rgba: "rgb(255,255,0, 0.8)",
name: "Yellow"
},
{
id: 7,
hex: "#f2994a",
rgba: "rgb(128,128,0, 0.8)",
name: "Orange"
},
{
id: 8,
hex: "#f7c8c1",
rgba: "rgb(128,0,0, 0.8)",
name: "Pink"
},
{
id: 9,
hex: "#eb5757",
rgba: "rgb(255,0,0, 0.8)",
name: "Red"
}
];
const isValidHexColor = (hexColor: string) => {
const hexColorPattern = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
return hexColorPattern.test(hexColor);
};
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
};
const createTagSchema = z.object({
slug: slugSchema({ min: 1, field: "Tag Slug" }),
color: z.string().trim()
});
type FormData = z.infer<typeof createTagSchema>;
type TagColor = {
id: number;
hex: string;
rgba: string;
name: string;
};
export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => {
const {
control,
reset,
watch,
setValue,
formState: { isSubmitting },
handleSubmit
} = useForm<FormData>({
resolver: zodResolver(createTagSchema),
defaultValues: {
color: secretTagsColors[0].hex
}
});
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { mutateAsync: createWsTag } = useCreateWsTag();
const [showHexInput, setShowHexInput] = useState<boolean>(false);
const selectedTagColor = watch("color", secretTagsColors[0].hex);
useEffect(() => {
if (!isOpen) reset();
}, [isOpen]);
const onFormSubmit = async ({ slug, color }: FormData) => {
try {
await createWsTag({
workspaceID: workspaceId,
tagColor: color,
tagSlug: slug
});
onToggle(false);
reset();
createNotification({
text: "Successfully created a tag",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create a tag",
type: "error"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
title="Create tag"
subTitle="Specify your tag name, and the slug will be created automatically."
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="slug"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl label="Tag Slug" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Type your tag slug" />
</FormControl>
)}
/>
<div className="mt-2">
<div className="mb-0.5 ml-1 block text-sm font-normal text-mineshaft-400">
Tag Color
</div>
<div className="flex space-x-2">
<div className="flex items-center justify-center rounded border border-mineshaft-500 bg-mineshaft-900 p-2">
<div
className="h-6 w-6 rounded-full"
style={{ background: `${selectedTagColor}` }}
/>
</div>
<div className="flex flex-grow items-center rounded border-mineshaft-500 bg-mineshaft-900 px-1 pr-2">
{!showHexInput ? (
<div className="inline-flex items-center gap-3 pl-3">
{secretTagsColors.map(($tagColor: TagColor) => {
return (
<div key={`tag-color-${$tagColor.id}`}>
<Tooltip content={`${$tagColor.name}`}>
<div
className="flex h-[26px] w-[26px] items-center justify-center rounded-full border-2 border-transparent bg-[#bec2c8] p-2 hover:border-black hover:shadow-lg hover:ring-2 hover:ring-offset-2"
key={`tag-${$tagColor.id}`}
style={{ backgroundColor: `${$tagColor.hex}` }}
onClick={() => setValue("color", $tagColor.hex)}
tabIndex={0}
role="button"
onKeyDown={() => {}}
>
{$tagColor.hex === selectedTagColor && (
<FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
)}
</div>
</Tooltip>
</div>
);
})}
</div>
) : (
<div className="tags-hex-wrapper flex flex-grow items-center px-2">
<div className="relative flex items-center rounded-md">
{isValidHexColor(selectedTagColor) && (
<div
className="flex h-7 w-7 items-center justify-center rounded-full"
style={{ background: `${selectedTagColor}` }}
>
<FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
</div>
)}
{!isValidHexColor(selectedTagColor) && (
<div className="bg-blue h-7 w-7 rounded-full border border-dashed border-mineshaft-500" />
)}
</div>
<div className="flex-grow">
<Input
variant="plain"
value={selectedTagColor}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue("color", e.target.value)
}
/>
</div>
</div>
)}
<div className="mx-4 h-8 border border-mineshaft-500" />
<div className="flex h-7 w-7 items-center justify-center">
<div
className={`flex h-7 w-7 cursor-pointer items-center justify-center rounded-sm border border-mineshaft-500 bg-mineshaft-900 bg-transparent p-2 hover:ring-2 hover:ring-offset-1 ${
showHexInput ? "tags-conic-bg rounded-full" : ""
}`}
onClick={() => setShowHexInput((prev) => !prev)}
style={{ border: "1px solid rgba(220, 216, 254, 0.376)" }}
tabIndex={0}
role="button"
onKeyDown={() => {}}
>
{!showHexInput && <span>#</span>}
</div>
</div>
</div>
</div>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

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

View File

@@ -0,0 +1,38 @@
import { getAuthToken, setAuthToken, setMfaTempToken, setSignupTempToken } from "@app/reactQuery";
export const PROVIDER_AUTH_TOKEN_KEY = "infisical__provider-auth-token";
// depreciated: go for apiRequest module in config/api
export default class SecurityClient {
static setProviderAuthToken(tokenStr: string) {
localStorage.setItem(PROVIDER_AUTH_TOKEN_KEY, tokenStr || "");
}
static getProviderAuthToken() {
return localStorage.getItem(PROVIDER_AUTH_TOKEN_KEY);
}
static setSignupToken(tokenStr: string) {
setSignupTempToken(tokenStr);
}
static setMfaToken(tokenStr: string) {
setMfaTempToken(tokenStr);
}
static setToken(tokenStr: string) {
setAuthToken(tokenStr);
}
static async fetchCall(resource: RequestInfo, options?: RequestInit | undefined) {
const req = new Request(resource, options);
const token = getAuthToken();
if (token) {
req.headers.set("Authorization", `Bearer ${token}`);
}
return fetch(req);
}
}

View File

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

View File

@@ -0,0 +1,147 @@
/* eslint-disable prefer-destructuring */
import jsrp from "jsrp";
import { login1, login2 } from "@app/hooks/api/auth/queries";
import KeyService from "@app/services/KeyService";
import Telemetry from "./telemetry/Telemetry";
import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage";
import SecurityClient from "./SecurityClient";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
export interface IsCliLoginSuccessful {
mfaEnabled: boolean;
loginResponse?: {
email: string;
privateKey: string;
JTWToken: string;
};
success: boolean;
}
/**
* Return whether or not login is successful for user with email [email]
* and password [password]
* @param {string} email - email of user to log in
* @param {string} password - password of user to log in
*/
const attemptLogin = async ({
email,
password,
providerAuthToken,
captchaToken
}: {
email: string;
password: string;
providerAuthToken?: string;
captchaToken?: string;
}): Promise<IsCliLoginSuccessful> => {
const telemetry = new Telemetry().getInstance();
return new Promise((resolve, reject) => {
client.init(
{
username: email,
password
},
async () => {
try {
const clientPublicKey = client.getPublicKey();
const { serverPublicKey, salt } = await login1({
email,
clientPublicKey,
providerAuthToken
});
client.setSalt(salt);
client.setServerPublicKey(serverPublicKey);
const clientProof = client.getProof(); // called M1
const {
mfaEnabled,
encryptionVersion,
protectedKey,
protectedKeyIV,
protectedKeyTag,
token,
publicKey,
encryptedPrivateKey,
iv,
tag
} = await login2({
email,
password,
clientProof,
providerAuthToken,
captchaToken
});
if (mfaEnabled) {
// case: MFA is enabled
// set temporary (MFA) JWT token
SecurityClient.setMfaToken(token);
resolve({
mfaEnabled,
success: true
});
} else if (
!mfaEnabled &&
encryptionVersion &&
encryptedPrivateKey &&
iv &&
tag &&
token
) {
// case: MFA is not enabled
// unset provider auth token in case it was used
SecurityClient.setProviderAuthToken("");
// set JWT token
SecurityClient.setToken(token);
const privateKey = await KeyService.decryptPrivateKey({
encryptionVersion,
encryptedPrivateKey,
iv,
tag,
password,
salt,
protectedKey,
protectedKeyIV,
protectedKeyTag
});
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv,
tag,
privateKey
});
if (email) {
telemetry.identify(email, email);
telemetry.capture("User Logged In");
}
resolve({
mfaEnabled: false,
loginResponse: {
email,
privateKey,
JTWToken: token
},
success: true
});
}
} catch (err) {
reject(err);
}
}
);
});
};
export default attemptLogin;

View File

@@ -0,0 +1,109 @@
/* eslint-disable prefer-destructuring */
import jsrp from "jsrp";
import { login1, verifyMfaToken } from "@app/hooks/api/auth/queries";
import KeyService from "@app/services/KeyService";
import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage";
import SecurityClient from "./SecurityClient";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
interface IsMfaLoginSuccessful {
success: boolean;
loginResponse: {
privateKey: string;
JTWToken: string;
};
}
/**
* Return whether or not MFA-login is successful for user with email [email]
* and MFA token [mfaToken]
* @param {Object} obj
* @param {String} obj.email - email of user
* @param {String} obj.mfaToken - MFA code/token
*/
const attemptLoginMfa = async ({
email,
password,
providerAuthToken,
mfaToken
}: {
email: string;
password: string;
providerAuthToken?: string;
mfaToken: string;
}): Promise<IsMfaLoginSuccessful> => {
return new Promise((resolve, reject) => {
client.init(
{
username: email,
password
},
async () => {
try {
const clientPublicKey = client.getPublicKey();
const { salt } = await login1({
email,
clientPublicKey,
providerAuthToken
});
const {
encryptionVersion,
protectedKey,
protectedKeyIV,
protectedKeyTag,
token,
publicKey,
encryptedPrivateKey,
iv,
tag
} = await verifyMfaToken({
email,
mfaCode: mfaToken
});
// unset temporary (MFA) JWT token and set JWT token
SecurityClient.setMfaToken("");
SecurityClient.setToken(token);
SecurityClient.setProviderAuthToken("");
const privateKey = await KeyService.decryptPrivateKey({
encryptionVersion,
encryptedPrivateKey,
iv,
tag,
password,
salt,
protectedKey,
protectedKeyIV,
protectedKeyTag
});
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv,
tag,
privateKey
});
resolve({
success: true,
loginResponse: {
privateKey,
JTWToken: token
}
});
} catch (err) {
reject(err);
}
}
);
});
};
export default attemptLoginMfa;

View File

@@ -0,0 +1,122 @@
/* eslint-disable prefer-destructuring */
import jsrp from "jsrp";
import { login1, login2 } from "@app/hooks/api/auth/queries";
import KeyService from "@app/services/KeyService";
import Telemetry from "./telemetry/Telemetry";
import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage";
import SecurityClient from "./SecurityClient";
interface IsLoginSuccessful {
mfaEnabled: boolean;
success: boolean;
}
/**
* Return whether or not login is successful for user with email [email]
* and password [password]
* @param {string} email - email of user to log in
* @param {string} password - password of user to log in
*/
const attemptLogin = async ({
email,
password,
providerAuthToken,
captchaToken
}: {
email: string;
password: string;
providerAuthToken?: string;
captchaToken?: string;
}): Promise<IsLoginSuccessful> => {
const telemetry = new Telemetry().getInstance();
// eslint-disable-next-line new-cap
const client = new jsrp.client();
await new Promise((resolve) => {
client.init({ username: email, password }, () => resolve(null));
});
const clientPublicKey = client.getPublicKey();
const { serverPublicKey, salt } = await login1({
email,
clientPublicKey,
providerAuthToken
});
client.setSalt(salt);
client.setServerPublicKey(serverPublicKey);
const clientProof = client.getProof(); // called M1
const {
mfaEnabled,
encryptionVersion,
protectedKey,
protectedKeyIV,
protectedKeyTag,
token,
publicKey,
encryptedPrivateKey,
iv,
tag
} = await login2({
captchaToken,
email,
password,
clientProof,
providerAuthToken
});
if (mfaEnabled) {
// case: MFA is enabled
// set temporary (MFA) JWT token
SecurityClient.setMfaToken(token);
return {
mfaEnabled,
success: true
};
}
if (!mfaEnabled && encryptionVersion && encryptedPrivateKey && iv && tag && token) {
// case: MFA is not enabled
// unset provider auth token in case it was used
SecurityClient.setProviderAuthToken("");
// set JWT token
SecurityClient.setToken(token);
const privateKey = await KeyService.decryptPrivateKey({
encryptionVersion,
encryptedPrivateKey,
iv,
tag,
password,
salt,
protectedKey,
protectedKeyIV,
protectedKeyTag
});
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv,
tag,
privateKey
});
if (email) {
telemetry.identify(email, email);
telemetry.capture("User Logged In");
}
return {
mfaEnabled: false,
success: true
};
}
return { success: false, mfaEnabled: false };
};
export default attemptLogin;

View File

@@ -0,0 +1,95 @@
/* eslint-disable prefer-destructuring */
import jsrp from "jsrp";
import { login1, verifyMfaToken } from "@app/hooks/api/auth/queries";
import KeyService from "@app/services/KeyService";
import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage";
import SecurityClient from "./SecurityClient";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
/**
* Return whether or not MFA-login is successful for user with email [email]
* and MFA token [mfaToken]
* @param {Object} obj
* @param {String} obj.email - email of user
* @param {String} obj.mfaToken - MFA code/token
*/
const attemptLoginMfa = async ({
email,
password,
providerAuthToken,
mfaToken
}: {
email: string;
password: string;
providerAuthToken?: string;
mfaToken: string;
}): Promise<Boolean> => {
return new Promise((resolve, reject) => {
client.init(
{
username: email,
password
},
async () => {
try {
const clientPublicKey = client.getPublicKey();
const { salt } = await login1({
email,
clientPublicKey,
providerAuthToken
});
const {
encryptionVersion,
protectedKey,
protectedKeyIV,
protectedKeyTag,
token,
publicKey,
encryptedPrivateKey,
iv,
tag
} = await verifyMfaToken({
email,
mfaCode: mfaToken
});
// unset temporary (MFA) JWT token and set JWT token
SecurityClient.setMfaToken("");
SecurityClient.setToken(token);
SecurityClient.setProviderAuthToken("");
const privateKey = await KeyService.decryptPrivateKey({
encryptionVersion,
encryptedPrivateKey,
iv,
tag,
password,
salt,
protectedKey,
protectedKeyIV,
protectedKeyTag
});
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv,
tag,
privateKey
});
resolve(true);
} catch (err) {
reject(err);
}
}
);
});
};
export default attemptLoginMfa;

View File

@@ -0,0 +1,53 @@
import { fetchOrgUsers, fetchUserAction } from "@app/hooks/api/users/queries";
interface OnboardingCheckProps {
orgId: string;
setTotalOnboardingActionsDone?: (value: number) => void;
setHasUserClickedSlack?: (value: boolean) => void;
setHasUserClickedIntro?: (value: boolean) => void;
setHasUserPushedSecrets?: (value: boolean) => void;
setUsersInOrg?: (value: boolean) => void;
}
/**
* This function checks which onboarding steps a user has already finished.
*/
const onboardingCheck = async ({
orgId,
setTotalOnboardingActionsDone,
setHasUserClickedSlack,
setHasUserClickedIntro,
setHasUserPushedSecrets,
setUsersInOrg
}: OnboardingCheckProps) => {
let countActions = 0;
const userActionSlack = await fetchUserAction("slack_cta_clicked");
if (userActionSlack) {
countActions += 1;
}
if (setHasUserClickedSlack) setHasUserClickedSlack(!!userActionSlack);
const userActionSecrets = await fetchUserAction("first_time_secrets_pushed");
if (userActionSecrets) {
countActions += 1;
}
if (setHasUserPushedSecrets) setHasUserPushedSecrets(!!userActionSecrets);
const userActionIntro = await fetchUserAction("intro_cta_clicked");
if (userActionIntro) {
countActions += 1;
}
if (setHasUserClickedIntro) setHasUserClickedIntro(!!userActionIntro);
const orgUsers = await fetchOrgUsers(orgId || "");
if (orgUsers.length > 1) {
countActions += 1;
}
if (setUsersInOrg) setUsersInOrg(orgUsers.length > 1);
if (setTotalOnboardingActionsDone) setTotalOnboardingActionsDone(countActions);
};
export default onboardingCheck;

View File

@@ -0,0 +1,93 @@
import { checkIsPasswordBreached } from "./checkIsPasswordBreached";
import {
escapeCharRegex,
letterCharRegex,
lowEntropyRegexes,
numAndSpecialCharRegex,
repeatedCharRegex
} from "./passwordRegexes";
interface PasswordCheckProps {
password: string;
setPasswordErrorTooShort: (value: boolean) => void;
setPasswordErrorTooLong: (value: boolean) => void;
setPasswordErrorNoLetterChar: (value: boolean) => void;
setPasswordErrorNoNumOrSpecialChar: (value: boolean) => void;
setPasswordErrorRepeatedChar: (value: boolean) => void;
setPasswordErrorEscapeChar: (value: boolean) => void;
setPasswordErrorLowEntropy: (value: boolean) => void;
setPasswordErrorBreached: (value: boolean) => void;
}
const passwordCheck = async ({
password,
setPasswordErrorTooShort,
setPasswordErrorTooLong,
setPasswordErrorNoLetterChar,
setPasswordErrorNoNumOrSpecialChar,
setPasswordErrorRepeatedChar,
setPasswordErrorEscapeChar,
setPasswordErrorLowEntropy,
setPasswordErrorBreached
}: PasswordCheckProps) => {
let errorCheck = false;
const tests = [
{
name: "tooShort",
validator: (pwd: string) => pwd.length >= 14,
setError: setPasswordErrorTooShort
},
{
name: "tooLong",
validator: (pwd: string) => pwd.length < 101,
setError: setPasswordErrorTooLong
},
{
name: "noLetterChar",
validator: (pwd: string) => letterCharRegex.test(pwd),
setError: setPasswordErrorNoLetterChar
},
{
name: "noNumOrSpecialChar",
validator: (pwd: string) => numAndSpecialCharRegex.test(pwd),
setError: setPasswordErrorNoNumOrSpecialChar
},
{
name: "repeatedChar",
validator: (pwd: string) => !repeatedCharRegex.test(pwd),
setError: setPasswordErrorRepeatedChar
},
{
name: "escapeChar",
validator: (pwd: string) => !escapeCharRegex.test(pwd),
setError: setPasswordErrorEscapeChar
},
{
name: "lowEntropy",
validator: (pwd: string) => !lowEntropyRegexes.some((regex) => regex.test(pwd)),
setError: setPasswordErrorLowEntropy
}
];
const isBreached = await checkIsPasswordBreached(password);
if (isBreached) {
errorCheck = true;
setPasswordErrorBreached(true);
} else {
setPasswordErrorBreached(false);
}
tests.forEach((test) => {
if (!test.validator(password)) {
errorCheck = true;
test.setError(true);
} else {
test.setError(false);
}
});
return errorCheck;
};
export default passwordCheck;

View File

@@ -0,0 +1,113 @@
import axios from "axios";
// SHA-1 hash the password using the SubtleCrypto API
async function hashPassword(passwordBytes: ArrayBuffer): Promise<ArrayBuffer> {
const buffer = await window.crypto.subtle.digest("SHA-1", passwordBytes);
return buffer;
}
// Convert the hashed password buffer to a hexadecimal string
function bufferToHex(buffer: ArrayBuffer): string {
const byteArray = new Uint8Array(buffer);
const hexParts: string[] = [];
byteArray.forEach((byte) => {
const hex = byte.toString(16).padStart(2, "0");
hexParts.push(hex);
});
return hexParts.join("");
}
// see API details here: https://haveibeenpwned.com/API/v3#SearchingPwnedPasswordsByRange
// in short, the pending password is hashed (SHA-1), the first 5 chars are sliced and compared against a ranged hash table
// this hash table is formed from the 5 char hash prefix (ie. 00000-FFFFF) so 16^5 results
// returns a hash table of 800-1000 results
// padding has been added to prevent MitM attacker determining which hash table was called by the response size
// the last 35 chars of the password hash are compared client-side against the table
// if there is a match, that password has been involved in a password breach (ie. pwnd) and should NOT be accepted
// the database consists of ~700 mln breached passwords and is continuously updated, including with law enforcement ingestion
// https://www.troyhunt.com/open-source-pwned-passwords-with-fbi-feed-and-225m-new-nca-passwords-is-now-live/
// The HIBP API follows NIST guidance (pg.14) https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b.pdf
// "When processing requests to establish and change memorized secrets, verifiers SHALL compare
// the prospective secrets against a list that contains values known to be commonly-used, expected,
// or compromised. For example, the list MAY include, but is not limited to:
// • Passwords obtained from previous breach corpuses.
// • Dictionary words.
// • Repetitive or sequential characters (e.g. ‘aaaaaa’, ‘1234abcd’).
// • Context-specific words, such as the name of the service, the username, and derivatives
// thereof."
export const checkIsPasswordBreached = async (password: string): Promise<boolean> => {
const HAVE_I_BEEN_PWNED_API_URL = "https://api.pwnedpasswords.com";
const maxRetryAttempts = 3;
let encodedPwd: Uint8Array | undefined;
let hashedPwdBuffer: ArrayBuffer | undefined;
try {
// Convert the password to a Uint8Array (UTF-8 encoded bytes)
const textEncoder = new TextEncoder();
encodedPwd = textEncoder.encode(password);
// Hash the password and convert it to a useful format for the HIBP API
hashedPwdBuffer = await hashPassword(encodedPwd!.buffer);
const hashedPwd = bufferToHex(hashedPwdBuffer).toUpperCase();
// ONLY send the first 5 hash chars (over HTTPS)
const hashedPwdToSend = hashedPwd.slice(0, 5);
const safeHashedPwdToSend = encodeURIComponent(hashedPwdToSend); // Ensure URL safety
const rangedHashTableUri = `${HAVE_I_BEEN_PWNED_API_URL}/range/${safeHashedPwdToSend}`;
let response;
let retryAttempt = 0;
/* eslint-disable no-await-in-loop */
while (retryAttempt < maxRetryAttempts) {
try {
response = await axios.get(rangedHashTableUri, {
headers: {
"Add-Padding": "true", // see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/
"Content-Type": "text/plain"
}
});
if (response.status === 200) {
// now we get back one of 16^5 hash prefix tables with random padding
const responseData = response.data.toUpperCase();
// check the last 35 hash chars to see if there's a match
const isBreachedPassword: boolean = responseData.includes(hashedPwd.slice(5, 40));
return isBreachedPassword;
}
retryAttempt += 1;
} catch (err) {
if (!axios.isAxiosError(err)) {
throw err;
}
retryAttempt += 1;
}
}
console.error(
`Received a non-200 response (${
response ? response.status : "unknown"
}) from the Pwnd Passwords API`
);
return false;
} catch (err: any) {
console.error("An unexpected error has occurred:", err.message);
return false;
} finally {
// Clear the UTF-8 encoded password from memory
if (encodedPwd) {
const zeroEncodedPwdBuffer = new Uint8Array(encodedPwd.length);
encodedPwd.set(zeroEncodedPwdBuffer);
}
// Clear the hashed password buffer from memory
if (hashedPwdBuffer) {
const zeroHashedPwdBuffer = new Uint8Array(hashedPwdBuffer);
zeroHashedPwdBuffer.fill(0);
}
}
};

View File

@@ -0,0 +1,103 @@
import { checkIsPasswordBreached } from "./checkIsPasswordBreached";
import {
escapeCharRegex,
letterCharRegex,
lowEntropyRegexes,
numAndSpecialCharRegex,
repeatedCharRegex
} from "./passwordRegexes";
type Errors = {
tooShort?: string;
tooLong?: string;
noLetterChar?: string;
noNumOrSpecialChar?: string;
repeatedChar?: string;
escapeChar?: string;
lowEntropy?: string;
breached?: string;
};
interface CheckPasswordParams {
password: string;
setErrors: (value: Errors) => void;
}
/**
* Validate that the password [password]:
* - Contains at least 14 characters
* - Contains at most 100 characters
* - Contains at least 1 letter character (many languages supported) (case insensitive)
* - Contains at least 1 number (0-9) or special character (emojis included)
* - Does not contain 3 repeat, consecutive characters
* - Does not contain any escape characters/sequences
* - Does not contain PII and/or low entropy data (eg. email address, URL, phone number, DoB, SSN, driver's license, passport)
* - Is not in a database of breached passwords
*
* The function returns whether or not the password [password]
* passes the minimum requirements above. It sets errors on
* an erorr object via [setErrors].
*
* @param {Object} obj
* @param {String} obj.password - the password to check
* @param {Function} obj.setErrors - set state function to set error object
*/
const checkPassword = async ({ password, setErrors }: CheckPasswordParams): Promise<boolean> => {
const errors: Errors = {};
const tests = [
{
name: "tooShort",
validator: (pwd: string) => pwd.length >= 14,
errorText: "at least 14 characters"
},
{
name: "tooLong",
validator: (pwd: string) => pwd.length < 101,
errorText: "at most 100 characters"
},
{
name: "noLetterChar",
validator: (pwd: string) => letterCharRegex.test(pwd),
errorText: "at least 1 letter character"
},
{
name: "noNumOrSpecialChar",
validator: (pwd: string) => numAndSpecialCharRegex.test(pwd),
errorText: "at least 1 number or special character"
},
{
name: "repeatedChar",
validator: (pwd: string) => !repeatedCharRegex.test(pwd),
errorText: "at most 3 repeated, consecutive characters"
},
{
name: "escapeChar",
validator: (pwd: string) => !escapeCharRegex.test(pwd),
errorText: "No escape characters allowed."
},
{
name: "lowEntropy",
validator: (pwd: string) => !lowEntropyRegexes.some((regex) => regex.test(pwd)),
errorText: "Password contains personal info."
}
];
const isBreached = await checkIsPasswordBreached(password);
if (isBreached) {
errors.breached = "Password was found in a data breach.";
}
tests.forEach((test) => {
if (test.validator && !test.validator(password)) {
errors[test.name as keyof Errors] = test.errorText;
}
});
setErrors(errors);
return Object.keys(errors).length > 0;
};
export default checkPassword;

View File

@@ -0,0 +1,37 @@
// This regex covers letters (case insensitive) for the top 50 most spoken languages
/* eslint-disable no-misleading-character-class */
export const letterCharRegex =
/[A-Za-z\u00C0-\u00D6\u00D8-\u00DE\u00DF-\u00F6\u00F8-\u00FF\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u0600-\u06FF\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F\u05B0-\u05FF\u0980-\u09FF\u1F00-\u1FFF\u0130\u015E\u011E\u00C7\u00FC\u00FB\u00EB\u00E7]/u;
// This regex covers digits, special characters, symbols, and emojis.
export const numAndSpecialCharRegex = /[\d!@#$%^&*(),.?":{}|<>]|[^\p{L}\p{N}\s]/u;
// This regex covers 3 repeated consecutive chars (incl. spaces)
export const repeatedCharRegex = /(.)\1\1\1|\s{4,}/;
// This regex covers the escape sequences as a precaution
export const escapeCharRegex = /[\n\t\r\\]/;
// This regex covers some PII and/or low entropy data
export const lowEntropyRegexes = [
// Email address
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/,
// URL (incl. subdomains, paths, top-level domains & query params)
/^(?:(?:https?|ftp):\/\/)?(?:\w+\.)?[a-zA-Z0-9.-]+\.(?:com|org|net|edu)(?:\/\S*)?(?:\?\S*)?$/,
// Date in various formats
/(\b\d{1,4}[-/.]?\d{1,2}[-/.]?\d{1,4}\b)|(\b\d{1,4}[-/.]?\w{3}[-/.]?\d{1,4}\b)/,
// Phone numbers (generalized)
/(?:\+(?:[1-9]\d{0,2})\s?)?(?:\(\d{1,4}\)\s?)?(?:\d[-.\s]?){5,}\d/,
// Passport numbers (generalized)
/\b(?:[A-Z0-9]{6,9}|[A-Z0-9]{8,9}|[A-Z0-9]{9}|[A-Z0-9]{10,11})\b/,
// Driver's license numbers (generalized)
/\b(?:[A-Z0-9]{7,10}|[A-Z0-9]{10,11}|[A-Z0-9]{7,10})\b/,
// US social security number
/\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/
];

View File

@@ -0,0 +1,11 @@
// this is temporary util function. create error handling logic for localStorage and delete this.
export const tempLocalStorage = (key: string) => {
const value = localStorage.getItem(key);
if (value === null || value === "") {
console.warn("No value found in localStorage for key");
return "";
}
return value;
};

View File

@@ -0,0 +1,7 @@
const ENV = process.env.NEXT_PUBLIC_ENV! || "development"; // investigate
const POSTHOG_API_KEY = process.env.NEXT_PUBLIC_POSTHOG_API_KEY!;
const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST! || "https://app.posthog.com";
const INTERCOMid = process.env.NEXT_PUBLIC_INTERCOMid!;
const CAPTCHA_SITE_KEY = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY!;
export { CAPTCHA_SITE_KEY, ENV, INTERCOMid, POSTHOG_API_KEY, POSTHOG_HOST };

View File

@@ -0,0 +1,77 @@
/**
* @fileoverview Provides easy encryption/decryption methods using AES 256 GCM.
*/
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
const BLOCK_SIZE_BYTES = 16; // 128 bit
interface EncryptProps {
text: string;
secret: string | Buffer;
}
interface DecryptProps {
ciphertext: string;
iv: string;
tag: string;
secret: string | Buffer;
}
interface EncryptOutputProps {
ciphertext: string;
iv: string;
tag: string;
}
/**
* Provides easy encryption/decryption methods using AES 256 GCM.
*/
class Aes256Gcm {
/**
* No need to run the constructor. The class only has static methods.
*/
/**
* Encrypts text with AES 256 GCM.
* @param {object} obj
* @param {string} obj.text - Cleartext to encode.
* @param {string} obj.secret - Shared secret key, must be 32 bytes.
* @returns {object}
*/
// { ciphertext: string; iv: string; tag: string; }
static encrypt({ text, secret }: EncryptProps): EncryptOutputProps {
const iv = crypto.randomBytes(BLOCK_SIZE_BYTES);
const cipher = crypto.createCipheriv(ALGORITHM, secret, iv);
let ciphertext = cipher.update(text, "utf8", "base64");
ciphertext += cipher.final("base64");
return {
ciphertext,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64")
};
}
/**
* Decrypts AES 256 CGM encrypted text.
* @param {object} obj
* @param {string} obj.ciphertext - Base64-encoded ciphertext.
* @param {string} obj.iv - The base64-encoded initialization vector.
* @param {string} obj.tag - The base64-encoded authentication tag generated by getAuthTag().
* @param {string} obj.secret - Shared secret key, must be 32 bytes.
* @returns {string}
*/
static decrypt({ ciphertext, iv, tag, secret }: DecryptProps): string {
const decipher = crypto.createDecipheriv(ALGORITHM, secret, Buffer.from(iv, "base64"));
decipher.setAuthTag(Buffer.from(tag, "base64"));
let cleartext = decipher.update(ciphertext, "base64", "utf8");
cleartext += decipher.final("utf8");
return cleartext;
}
}
export default Aes256Gcm;

View File

@@ -0,0 +1,235 @@
import argon2 from "argon2-browser";
import aes from "./aes-256-gcm";
const nacl = require("tweetnacl");
nacl.util = require("tweetnacl-util");
/**
* Return new base64, NaCl, public-private key pair.
* @returns {Object} obj
* @returns {String} obj.publicKey - base64, NaCl, public key
* @returns {String} obj.privateKey - base64, NaCl, private key
*/
const generateKeyPair = () => {
const pair = nacl.box.keyPair();
return {
publicKey: nacl.util.encodeBase64(pair.publicKey),
privateKey: nacl.util.encodeBase64(pair.secretKey)
};
};
type EncryptAsymmetricProps = {
plaintext: string;
publicKey: string;
privateKey: string;
};
/**
* Verify that private key [privateKey] is the one that corresponds to
* the public key [publicKey]
* @param {Object}
* @param {String} - base64-encoded Nacl private key
* @param {String} - base64-encoded Nacl public key
*/
const verifyPrivateKey = ({ privateKey, publicKey }: { privateKey: string; publicKey: string }) => {
const derivedPublicKey = nacl.util.encodeBase64(
nacl.box.keyPair.fromSecretKey(nacl.util.decodeBase64(privateKey)).publicKey
);
if (derivedPublicKey !== publicKey) {
throw new Error("Failed to verify private key");
}
};
/**
* Derive a key from password [password] and salt [salt] using Argon2id
* @param {Object} obj
* @param {String} obj.password - password to derive key from
* @param {String} obj.salt - salt to derive key from
* @param {Number} obj.mem - used memory, in KiB
* @param {Number} obj.time - number of iterations
* @param {Number} obj.parallelism - desired parallelism
* @param {Number} obj.hashLen - desired hash length (i.e. byte-length of derived key)
* @returns
*/
const deriveArgonKey = async ({
password,
salt,
mem,
time,
parallelism,
hashLen
}: {
password: string;
salt: string;
mem: number;
time: number;
parallelism: number;
hashLen: number;
}) => {
let derivedKey;
try {
derivedKey = await argon2.hash({
pass: password,
salt,
type: argon2.ArgonType.Argon2id,
mem,
time,
parallelism,
hashLen
});
} catch (err) {
console.error(err);
}
return derivedKey;
};
/**
* Return assymmetrically encrypted [plaintext] using [publicKey] where
* [publicKey] likely belongs to the recipient.
* @param {Object} obj
* @param {String} obj.plaintext - plaintext to encrypt
* @param {String} obj.publicKey - public key of the recipient
* @param {String} obj.privateKey - private key of the sender (current user)
* @returns {Object} obj
* @returns {String} ciphertext - base64-encoded ciphertext
* @returns {String} nonce - base64-encoded nonce
*/
const encryptAssymmetric = ({
plaintext,
publicKey,
privateKey
}: EncryptAsymmetricProps): {
ciphertext: string;
nonce: string;
} => {
const nonce = nacl.randomBytes(24);
const ciphertext = nacl.box(
nacl.util.decodeUTF8(plaintext),
nonce,
nacl.util.decodeBase64(publicKey),
nacl.util.decodeBase64(privateKey)
);
return {
ciphertext: nacl.util.encodeBase64(ciphertext),
nonce: nacl.util.encodeBase64(nonce)
};
};
type DecryptAsymmetricProps = {
ciphertext: string;
nonce: string;
publicKey: string;
privateKey: string;
};
/**
* Return assymmetrically decrypted [ciphertext] using [privateKey] where
* [privateKey] likely belongs to the recipient.
* @param {Object} obj
* @param {String} obj.ciphertext - ciphertext to decrypt
* @param {String} obj.nonce - nonce
* @param {String} obj.publicKey - base64-encoded public key of the sender
* @param {String} obj.privateKey - base64-encoded private key of the receiver (current user)
*/
const decryptAssymmetric = ({
ciphertext,
nonce,
publicKey,
privateKey
}: DecryptAsymmetricProps): string => {
const plaintext = nacl.box.open(
nacl.util.decodeBase64(ciphertext),
nacl.util.decodeBase64(nonce),
nacl.util.decodeBase64(publicKey),
nacl.util.decodeBase64(privateKey)
);
return nacl.util.encodeUTF8(plaintext);
};
type EncryptSymmetricProps = {
plaintext: string;
key: string;
};
type EncryptSymmetricReturn = {
ciphertext: string;
iv: string;
tag: string;
};
/**
* Return symmetrically encrypted [plaintext] using [key].
*/
const encryptSymmetric = ({ plaintext, key }: EncryptSymmetricProps): EncryptSymmetricReturn => {
let ciphertext;
let iv;
let tag;
try {
const obj = aes.encrypt({ text: plaintext, secret: key });
ciphertext = obj.ciphertext;
iv = obj.iv;
tag = obj.tag;
} catch (err) {
console.log("Failed to perform encryption");
console.log(err);
process.exit(1);
}
return {
ciphertext,
iv,
tag
};
};
type DecryptSymmetricProps = {
ciphertext: string;
iv: string;
tag: string;
key: string;
};
/**
* Return symmetrically decypted [ciphertext] using [iv], [tag],
* and [key].
* @param {Object} obj
* @param {String} obj.ciphertext - ciphertext to decrypt
* @param {String} obj.iv - iv
* @param {String} obj.tag - tag
* @param {String} obj.key - 32-byte hex key
*
*/
const decryptSymmetric = ({ ciphertext, iv, tag, key }: DecryptSymmetricProps): string => {
if (!ciphertext) return "";
let plaintext;
try {
plaintext = aes.decrypt({ ciphertext, iv, tag, secret: key });
} catch (err) {
console.log("Failed to decrypt with the following parameters", {
ciphertext,
iv,
tag,
key
});
console.log("Failed to perform decryption", err);
process.exit(1);
}
return plaintext;
};
export {
decryptAssymmetric,
decryptSymmetric,
deriveArgonKey,
encryptAssymmetric,
encryptSymmetric,
generateKeyPair,
verifyPrivateKey
};

View File

@@ -0,0 +1,114 @@
/* eslint-disable new-cap */
import crypto from "crypto";
import jsrp from "jsrp";
import { issueBackupPrivateKey, srp1 } from "@app/hooks/api/auth/queries";
import generateBackupPDF from "../generateBackupPDF";
import Aes256Gcm from "./aes-256-gcm";
const clientPassword = new jsrp.client();
const clientKey = new jsrp.client();
interface BackupKeyProps {
email: string;
password: string;
personalName: string;
setBackupKeyError: (value: boolean) => void;
setBackupKeyIssued: (value: boolean) => void;
}
/**
* This function issue a backup key for a user
* @param {obkect} obj
* @param {string} obj.email - email of a user issuing a backup key
* @param {string} obj.password - password of a user issuing a backup key
* @param {string} obj.personalName - name of a user issuing a backup key
* @param {function} obj.setBackupKeyError - state function that turns true if there is an erorr with a backup key
* @param {function} obj.setBackupKeyIssued - state function that turns true if a backup key was issued correctly
* @returns
*/
const issueBackupKey = async ({
email,
password,
personalName,
setBackupKeyError,
setBackupKeyIssued
}: BackupKeyProps) => {
try {
setBackupKeyError(false);
setBackupKeyIssued(false);
clientPassword.init(
{
username: email,
password
},
async () => {
const clientPublicKey = clientPassword.getPublicKey();
let serverPublicKey;
let salt;
try {
const res = await srp1({
clientPublicKey
});
serverPublicKey = res.serverPublicKey;
salt = res.salt;
} catch (err) {
setBackupKeyError(true);
console.log("Wrong current password", err, 1);
}
clientPassword.setSalt(salt as string);
clientPassword.setServerPublicKey(serverPublicKey as string);
const clientProof = clientPassword.getProof(); // called M1
const generatedKey = crypto.randomBytes(16).toString("hex");
clientKey.init(
{
username: email,
password: generatedKey
},
async () => {
clientKey.createVerifier(
async (err: any, result: { salt: string; verifier: string }) => {
const { ciphertext, iv, tag } = Aes256Gcm.encrypt({
text: String(localStorage.getItem("PRIVATE_KEY")),
secret: generatedKey
});
try {
await issueBackupPrivateKey({
encryptedPrivateKey: ciphertext,
iv,
tag,
salt: result.salt,
verifier: result.verifier,
clientProof
});
generateBackupPDF({
personalName,
personalEmail: email,
generatedKey
});
setBackupKeyIssued(true);
} catch {
setBackupKeyError(true);
}
}
);
}
);
}
);
} catch (error) {
setBackupKeyError(true);
console.log("Failed to issue a backup key");
}
return true;
};
export default issueBackupKey;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,63 @@
/* eslint-disable prefer-template */
/* eslint-disable prefer-rest-params */
/* eslint-disable @typescript-eslint/no-unused-expressions */
/* eslint-disable no-nested-ternary */
/* eslint-disable no-unexpected-multiline */
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable vars-on-top */
/* eslint-disable no-var */
/* eslint-disable func-names */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { INTERCOMid as APPid } from "@app/components/utilities/config";
// Loads Intercom with the snippet
// This must be run before boot, it initializes window.Intercom
// prettier-ignore
export const load = () => {
(function(){
var w=window;
var ic=w.Intercom;
if(typeof ic==="function"){
ic("reattach_activator");
ic("update",w.intercomSettings);
} else {
var d=document;
var i=function() {
i.c(arguments);
};
i.q=[];
i.c=function(args) {
i.q.push(args);
};
w.Intercom=i;
var l=function() {
var s=d.createElement("script");
s.type="text/javascript";
s.async=true;
s.src="https://widget.intercom.io/widget/" + APPid;
var x=d.getElementsByTagName("script")[0];
x.parentNode.insertBefore(s, x);
};
if (document.readyState==="complete") {
l();
} else if (w.attachEvent) {
w.attachEvent("onload",l);
} else {
w.addEventListener("load",l,false);
}
}
})();
}
// Initializes Intercom
export const boot = (options = {}) => {
window && window.Intercom && window.Intercom("boot", { appid: APPid, ...options });
};
export const update = () => {
window && window.Intercom && window.Intercom("update");
};

View File

@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { useUser } from "@app/context";
import { boot as bootIntercom, load as loadIntercom, update as updateIntercom } from "./intercom";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const IntercomProvider = ({ children }: { children: any }) => {
const { user } = useUser();
const router = useRouter();
if (typeof window !== "undefined") {
loadIntercom();
bootIntercom({
name: `${user?.firstName || ""} ${user?.lastName || ""}`,
email: user?.email || "",
created_at: Math.floor((new Date(user?.createdAt)?.getTime() || 0) / 1000)
});
}
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleRouteChange = (url: string) => {
if (typeof window !== "undefined") {
updateIntercom();
}
};
router.events.on("routeChangeStart", handleRouteChange);
// If the component is unmounted, unsubscribe
// from the event with the `off` method:
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [router.events]);
return children;
};

View File

@@ -0,0 +1,5 @@
export const isValidHexColor = (hexColor: string) => {
const hexColorPattern = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
return hexColorPattern.test(hexColor);
};

View File

@@ -0,0 +1,79 @@
const LINE =
/(?:^|^)\s*(?:export\s+)?([\w.:-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
/**
* Return text that is the buffer parsed
* @param {ArrayBuffer} src - source buffer
* @returns {String} text - text of buffer
*/
export function parseDotEnv(src: ArrayBuffer | string) {
const object: {
[key: string]: { value: string; comments: string[] };
} = {};
// Convert buffer to string
let lines = src.toString();
// Convert line breaks to same format
lines = lines.replace(/\r\n?/gm, "\n");
let comments: string[] = [];
lines
.split("\n")
.map((line) => {
// collect comments of each env variable
if (line.startsWith("#")) {
comments.push(line.replace("#", "").trim());
} else if (line) {
let match;
let item: [string, string, string[]] | [] = [];
// eslint-disable-next-line no-cond-assign
while ((match = LINE.exec(line)) !== null) {
const key = match[1];
// Default undefined or null to empty string
let value = match[2] || "";
// Remove whitespace
value = value.trim();
// Check if double quoted
const maybeQuote = value[0];
// Remove surrounding quotes
value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
// Expand newlines if double quoted
if (maybeQuote === '"') {
value = value.replace(/\\n/g, "\n");
value = value.replace(/\\r/g, "\r");
}
item = [key, value, comments];
}
comments = [];
return item;
}
return [];
})
.filter((line) => line.length > 1)
.forEach((line) => {
const [key, value, cmnts] = line;
object[key as string] = { value, comments: cmnts };
});
return object;
}
export const parseJson = (src: ArrayBuffer | string) => {
const file = src.toString();
const formatedData: Record<string, string> = JSON.parse(file);
const env: Record<string, { value: string; comments: string[] }> = {};
Object.keys(formatedData).forEach((key) => {
if (typeof formatedData[key] === "string") {
env[key] = { value: formatedData[key], comments: [] };
}
});
return env;
};

View File

@@ -0,0 +1,13 @@
/**
* This function returns a random identifier.
* @returns
*/
const guidGenerator = () => {
const S4 = () => {
// eslint-disable-next-line no-bitwise
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
};
return `${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`;
};
export default guidGenerator;

View File

@@ -0,0 +1,67 @@
interface Props {
protectedKey?: string;
protectedKeyIV?: string;
protectedKeyTag?: string;
publicKey?: string;
encryptedPrivateKey?: string;
iv?: string;
tag?: string;
privateKey?: string;
}
export const saveTokenToLocalStorage = ({
protectedKey,
protectedKeyIV,
protectedKeyTag,
publicKey,
encryptedPrivateKey,
iv,
tag,
privateKey
}: Props) => {
try {
if (protectedKey) {
localStorage.removeItem("protectedKey");
localStorage.setItem("protectedKey", protectedKey);
}
if (protectedKeyIV) {
localStorage.removeItem("protectedKeyIV");
localStorage.setItem("protectedKeyIV", protectedKeyIV);
}
if (protectedKeyTag) {
localStorage.removeItem("protectedKeyTag");
localStorage.setItem("protectedKeyTag", protectedKeyTag);
}
if (publicKey) {
localStorage.removeItem("publicKey");
localStorage.setItem("publicKey", publicKey);
}
if (encryptedPrivateKey) {
localStorage.removeItem("encryptedPrivateKey");
localStorage.setItem("encryptedPrivateKey", encryptedPrivateKey);
}
if (iv) {
localStorage.removeItem("iv");
localStorage.setItem("iv", iv);
}
if (tag) {
localStorage.removeItem("tag");
localStorage.setItem("tag", tag);
}
if (privateKey) {
localStorage.removeItem("PRIVATE_KEY");
localStorage.setItem("PRIVATE_KEY", privateKey);
}
} catch (err) {
if (err instanceof Error) {
throw new Error(`Unable to send the tokens in local storage:${err.message}`);
}
}
};

View File

@@ -0,0 +1,34 @@
import { SecretDataProps } from "public/data/frequentInterfaces";
import { SecretType } from "@app/hooks/api/types";
/**
* This function downloads the secrets as a .env file
* @param {object} obj
* @param {SecretDataProps[]} obj.data - secrets that we want to check for overrides
* @returns
*/
const checkOverrides = async ({ data }: { data: SecretDataProps[] }) => {
let secrets: SecretDataProps[] = data!.map((secret) => Object.create(secret));
const overridenSecrets = data!.filter((secret) =>
secret.valueOverride === undefined || secret?.value !== secret?.valueOverride
? SecretType.Shared
: SecretType.Personal
);
if (overridenSecrets.length) {
overridenSecrets.forEach((secret) => {
const index = secrets!.findIndex(
(_secret) =>
_secret.key === secret.key &&
(secret.valueOverride === undefined || secret?.value !== secret?.valueOverride)
);
secrets![index].value = secret.value;
});
secrets = secrets!.filter(
(secret) => secret.valueOverride === undefined || secret?.value !== secret?.valueOverride
);
}
return secrets;
};
export default checkOverrides;

View File

@@ -0,0 +1,37 @@
import { SecretDataProps } from "public/data/frequentInterfaces";
import checkOverrides from "./checkOverrides";
/**
* This function downloads the secrets as a .env file
* @param {object} obj
* @param {SecretDataProps[]} obj.data - secrets that we want to download
* @param {string} obj.env - the environment which we're downloading (used for naming the file)
*/
const downloadDotEnv = async ({ data, env }: { data: SecretDataProps[]; env: string }) => {
if (!data) return;
const secrets = await checkOverrides({ data });
const file = secrets!
.map(
(item: SecretDataProps) =>
`${
item.comment
? `${item.comment
.split("\n")
.map((comment) => "# ".concat(comment))
.join("\n")}\n`
: ""
}${[item.key, item.value].join("=")}`
)
.join("\n");
const blob = new Blob([file]);
const fileDownloadUrl = URL.createObjectURL(blob);
const alink = document.createElement("a");
alink.href = fileDownloadUrl;
alink.download = `${env}.env`;
alink.click();
};
export default downloadDotEnv;

View File

@@ -0,0 +1,43 @@
// import YAML from 'yaml';
// import { YAMLSeq } from 'yaml/types';
import { SecretDataProps } from "public/data/frequentInterfaces";
// import { envMapping } from "../../../public/data/frequentConstants";
// import checkOverrides from './checkOverrides';
/**
* This function downloads the secrets as a .yml file
* @param {object} obj
* @param {SecretDataProps[]} obj.data - secrets that we want to download
* @param {string} obj.env - used for naming the file
* @returns
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const downloadYaml = async ({ data, env }: { data: SecretDataProps[]; env: string }) => {
// if (!data) return;
// const doc = new YAML.Document();
// doc.contents = new YAMLSeq();
// const secrets = await checkOverrides({ data });
// secrets.forEach((secret) => {
// const pair = YAML.createNode({ [secret.key]: secret.value });
// pair.commentBefore = secret.comment
// .split('\n')
// .map((line) => (line ? ' '.concat(line) : ''))
// .join('\n');
// doc.add(pair);
// });
// const file = doc
// .toString()
// .split('\n')
// .map((line) => (line.startsWith('-') ? line.replace('- ', '') : line))
// .join('\n');
// const blob = new Blob([file]);
// const fileDownloadUrl = URL.createObjectURL(blob);
// const alink = document.createElement('a');
// alink.href = fileDownloadUrl;
// alink.download = envMapping[env] + '.yml';
// alink.click();
};
export default downloadYaml;

View File

@@ -0,0 +1,39 @@
import { SecretDataProps } from "public/data/frequentInterfaces";
/**
* Encypt secrets before pushing the to the DB
* @param {object} obj
* @param {object} obj.secretsToEncrypt - secrets that we want to encrypt
* @param {object} obj.workspaceId - the id of a project in which we are encrypting secrets
* @returns
*/
const encryptSecrets = async ({
secretsToEncrypt,
env
}: {
secretsToEncrypt: SecretDataProps[];
workspaceId: string;
env: string;
}) => {
let secrets;
try {
secrets = secretsToEncrypt.map((secret) => {
const result = {
id: secret.id,
createdAt: "",
environment: env,
secretKey: secret.key,
secretValue: secret.value,
secretComment: secret.comment,
tags: secret.tags
};
return result;
});
} catch (error) {
console.log("Error while encrypting secrets");
}
return secrets;
};
export default encryptSecrets;

View File

@@ -0,0 +1,50 @@
/* eslint-disable */
import { PostHog } from "posthog-js";
import { initPostHog } from "@app/components/analytics/posthog";
import { ENV } from "@app/components/utilities/config";
declare let TELEMETRY_CAPTURING_ENABLED: any;
class Capturer {
api: PostHog;
constructor() {
this.api = initPostHog()!;
}
capture(item: string) {
if (ENV === "production" && TELEMETRY_CAPTURING_ENABLED === true) {
try {
this.api.capture(item);
} catch (error) {
console.error("PostHog", error);
}
}
}
identify(id: string, email?: string) {
if (ENV === "production" && TELEMETRY_CAPTURING_ENABLED === true) {
try {
this.api.identify(id, {
email: email
});
} catch (error) {
console.error("PostHog", error);
}
}
}
}
export default class Telemetry {
static instance: Capturer;
constructor() {
if (!Telemetry.instance) {
Telemetry.instance = new Capturer();
}
}
getInstance() {
return Telemetry.instance;
}
}

View File

@@ -0,0 +1,74 @@
import { forwardRef } from "react";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { twMerge } from "tailwind-merge";
export const AccordionItem = forwardRef<HTMLDivElement, AccordionPrimitive.AccordionItemProps>(
({ children, className, ...props }, forwardedRef) => (
<AccordionPrimitive.Item
className={twMerge(
"mt-px overflow-hidden border-transparent transition-all first:mt-0 data-[state=open]:border-l data-[state=open]:border-primary",
className
)}
{...props}
ref={forwardedRef}
>
{children}
</AccordionPrimitive.Item>
)
);
AccordionItem.displayName = "AccordionItem";
export const AccordionTrigger = forwardRef<
HTMLButtonElement,
AccordionPrimitive.AccordionTriggerProps
>(({ children, className, ...props }, forwardedRef) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
className={twMerge(
"group flex h-11 flex-1 items-center justify-between py-2 px-4 outline-none hover:text-primary data-[state=open]:text-primary",
className
)}
{...props}
ref={forwardedRef}
>
{children}
<FontAwesomeIcon
icon={faChevronDown}
className="text-sm transition-transform duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)] group-data-[state=open]:rotate-180"
aria-hidden
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = "AccordionTrigger";
export const AccordionContent = forwardRef<
HTMLDivElement,
AccordionPrimitive.AccordionContentProps
>(({ children, className, ...props }, forwardedRef) => (
<AccordionPrimitive.Content
className={twMerge(
"overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp",
className
)}
{...props}
ref={forwardedRef}
>
<div className="py-2 px-4 text-sm">{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = "AccordionContent";
// ref: https://www.radix-ui.com/primitives/docs/components/accordion#root
export const Accordion = ({
children,
...props
}: AccordionPrimitive.AccordionSingleProps | AccordionPrimitive.AccordionMultipleProps) => (
<AccordionPrimitive.Root {...props} className={twMerge("w-80 text-bunker-300", props.className)}>
{children}
</AccordionPrimitive.Root>
);

View File

@@ -0,0 +1 @@
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./Accordion";

View File

@@ -0,0 +1,88 @@
import { forwardRef } from "react";
import {
faExclamationCircle,
faExclamationTriangle,
faInfoCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type VariantProps, cva } from "cva";
import { twMerge } from "tailwind-merge";
const alertVariants = cva(
"w-full bg-mineshaft-800 rounded-lg border border-bunker-400 px-4 py-3 text-sm flex items-center gap-x-4",
{
variants: {
variant: {
default: "",
danger: "text-red border-red",
warning: "text-yellow border-yellow"
}
},
defaultVariants: {
variant: "default"
}
}
);
type AlertProps = {
title?: string;
hideTitle?: boolean;
icon?: React.ReactNode;
};
const variantTitleMap = {
default: "Info",
danger: "Danger",
warning: "Warning"
};
const variantIconMap = {
default: faInfoCircle,
danger: faExclamationCircle,
warning: faExclamationTriangle
};
const Alert = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> & AlertProps
>(({ className, variant, title, icon, hideTitle = false, children, ...props }, ref) => {
const defaultTitle = title ?? variantTitleMap[variant ?? "default"];
return (
<div
ref={ref}
role="alert"
className={twMerge(alertVariants({ variant }), className)}
{...props}
>
<div>
{typeof icon !== "undefined" ? (
<>{icon} </>
) : (
<FontAwesomeIcon
className="text-lg text-primary"
icon={variantIconMap[variant ?? "default"]}
/>
)}
</div>
<div className="flex flex-col gap-y-1">
{hideTitle ? null : (
<h5 className="font-medium leading-6 tracking-tight" {...props}>
{defaultTitle}
</h5>
)}
{children}
</div>
</div>
);
});
Alert.displayName = "Alert";
const AlertDescription = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={twMerge("text-sm", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertDescription };

View File

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

View File

@@ -0,0 +1,32 @@
import { cva, VariantProps } from "cva";
import { twMerge } from "tailwind-merge";
interface IProps {
children: React.ReactNode;
className?: string;
}
const badgeVariants = cva(
[
"inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-xs text-yellow opacity-80 hover:opacity-100"
],
{
variants: {
variant: {
primary: "bg-yellow/20 text-yellow",
danger: "bg-red/20 text-red",
success: "bg-green/20 text-green"
}
}
}
);
export type BadgeProps = VariantProps<typeof badgeVariants> & IProps;
export const Badge = ({ children, className, variant }: BadgeProps) => {
return (
<div className={twMerge(badgeVariants({ variant: variant || "primary" }), className)}>
{children}
</div>
);
};

View File

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

View File

@@ -0,0 +1,234 @@
import { ButtonHTMLAttributes, forwardRef, ReactNode } from "react";
import { cva, VariantProps } from "cva";
import { twMerge } from "tailwind-merge";
type Props = {
children: ReactNode;
isDisabled?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
// loading state
isLoading?: boolean;
};
// refactor(akhilmhdh): both color and size variants are together need to split it
// colorSchema should handle all color class names
// variant should handle how the button padding and other types should be set
const buttonVariants = cva(
[
"button",
"transition-all",
"font-inter font-medium",
"cursor-pointer",
"inline-flex items-center justify-center",
"relative",
"whitespace-nowrap"
],
{
variants: {
colorSchema: {
primary: ["bg-primary", "text-black", "border-primary bg-opacity-90 hover:bg-opacity-100"],
secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-opacity-80"],
danger: ["!bg-red", "!text-white", "!border-red hover:!bg-opacity-90"],
gray: ["bg-bunker-500", "text-bunker-200"]
},
variant: {
solid: "",
outline: ["bg-transparent", "border-2", "border-solid"],
plain: "",
selected: "",
outline_bg: "",
// a constant color not in use on hover or click goes colorSchema color
star: "text-bunker-200 bg-mineshaft-700 border-mineshaft-600",
link: "text-primary !p-0 bg-transparent outline-none border-none"
},
isDisabled: {
true: "bg-mineshaft-700 border border-mineshaft-600 text-white opacity-50 cursor-not-allowed",
false: "border"
},
isFullWidth: {
true: "w-full",
false: ""
},
isRounded: {
true: "rounded-md",
false: ""
},
size: {
xs: ["text-xs", "py-1", "px-2"],
sm: ["text-sm", "py-2", "px-4"],
md: ["text-md", "py-2", "px-5"],
lg: ["text-lg", "py-2", "px-6"]
}
},
compoundVariants: [
{
colorSchema: "primary",
variant: "star",
className:
"bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary hover:text-black hover:border-primary-400 duration-100"
},
{
colorSchema: "primary",
variant: "solid",
className: "text-black bg-primary-500 bg-opacity-90 hover:bg-primary-500 hover:text-black"
},
{
colorSchema: "primary",
variant: "selected",
className: "bg-primary/10 border border-primary/50 text-bunker-200"
},
{
colorSchema: "primary",
variant: "outline_bg",
className:
"bg-mineshaft-600 border border-mineshaft-500 hover:bg-primary/[0.1] hover:border-primary/40 text-bunker-200"
},
{
colorSchema: "secondary",
variant: "star",
className:
"bg-mineshaft-700 border border-mineshaft-600 hover:bg-mineshaft hover:text-white"
},
{
colorSchema: "danger",
variant: "star",
className: "hover:bg-red hover:text-white"
},
{
colorSchema: "primary",
variant: "outline",
className: "text-primary hover:bg-primary hover:text-black"
},
{
colorSchema: "secondary",
variant: "outline",
className: "border-mineshaft-700 hover:border-mineshaft-500"
},
{
colorSchema: "danger",
variant: "outline",
className: "text-red hover:bg-red hover:text-black"
},
{
colorSchema: "danger",
variant: "outline_bg",
className:
"bg-mineshaft-600 border border-red-500 hover:bg-red/[0.1] hover:border-red/40 text-red-500"
},
{
colorSchema: "primary",
variant: "plain",
className: "text-primary"
},
{
colorSchema: "gray",
variant: "plain",
className: "bg-transparent text-bunker-200"
},
{
colorSchema: "secondary",
variant: "plain",
className: "text-mineshaft-300 hover:text-mineshaft-200 border-none"
},
{
colorSchema: "danger",
variant: "plain",
className: "text-red"
},
{
colorSchema: ["danger", "primary", "secondary"],
variant: ["plain"],
className: "bg-transparent py-1 px-1"
}
]
}
);
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> &
Props;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
isDisabled = false,
className = "",
size = "sm",
variant = "solid",
isFullWidth,
isRounded = true,
leftIcon,
rightIcon,
isLoading,
colorSchema = "primary",
...props
},
ref
): JSX.Element => {
const loadingToggleClass = isLoading ? "opacity-0" : "opacity-100";
return (
<button
ref={ref}
aria-disabled={isDisabled}
type="button"
className={twMerge(
buttonVariants({
colorSchema,
size,
variant,
isRounded,
isDisabled,
isFullWidth,
className
})
)}
disabled={isDisabled}
{...props}
>
{isLoading && (
<img
src="/images/loading/loading.gif"
width={36}
alt="loading animation"
className="absolute rounded-xl opacity-80"
/>
)}
{leftIcon && (
<div
className={twMerge(
"inline-flex shrink-0 cursor-pointer items-center justify-center transition-all",
loadingToggleClass,
size === "xs" ? "mr-1" : "mr-2"
)}
>
{leftIcon}
</div>
)}
<span
className={twMerge(
"transition-all",
isFullWidth ? "w-full" : "w-min",
loadingToggleClass
)}
>
{children}
</span>
{rightIcon && (
<div
className={twMerge(
"inline-flex shrink-0 cursor-pointer items-center justify-center transition-all",
loadingToggleClass
)}
>
{rightIcon}
</div>
)}
</button>
);
}
);
Button.displayName = "Button";

View File

@@ -0,0 +1,2 @@
export type { ButtonProps } from "./Button";
export { Button } from "./Button";

View File

@@ -0,0 +1,77 @@
import { CSSProperties, forwardRef, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export type CardTitleProps = {
children: ReactNode;
subTitle?: ReactNode;
className?: string;
};
export const CardTitle = ({ children, className, subTitle }: CardTitleProps) => (
<div
className={twMerge(
"mb-5 break-words border-b border-mineshaft-600 px-6 py-4 font-sans text-lg font-normal",
className
)}
>
{children}
{subTitle && <p className="pt-2 text-sm font-normal text-gray-400">{subTitle}</p>}
</div>
);
export type CardFooterProps = {
children: ReactNode;
className?: string;
};
export const CardFooter = ({ children, className }: CardFooterProps) => (
<div className={twMerge("p-4 pt-0", className)}>{children}</div>
);
export type CardBodyProps = {
children: ReactNode;
className?: string;
style?: CSSProperties;
};
export const CardBody = ({ children, className, style }: CardBodyProps) => (
<div className={twMerge("px-6 pb-6 pt-0", className)} style={style}>
{children}
</div>
);
export type CardProps = {
children: ReactNode;
className?: string;
isFullHeight?: boolean;
isRounded?: boolean;
isPlain?: boolean;
isHoverable?: boolean;
style?: CSSProperties;
};
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{ children, isFullHeight, isRounded, isHoverable, isPlain, className, style },
ref
): JSX.Element => {
return (
<div
ref={ref}
className={twMerge(
"flex w-full flex-col bg-mineshaft-800 font-inter text-gray-200 shadow-md",
isFullHeight && "h-full",
isRounded && "rounded-md",
isPlain && "shadow-none",
isHoverable && "hover:shadow-xl",
className
)}
style={style}
>
{children}
</div>
);
}
);
Card.displayName = "Card";

View File

@@ -0,0 +1,2 @@
export type { CardBodyProps, CardFooterProps, CardProps, CardTitleProps } from "./Card";
export { Card, CardBody, CardFooter, CardTitle } from "./Card";

View File

@@ -0,0 +1,68 @@
import { ReactNode } from "react";
import { faCheck, faMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { twMerge } from "tailwind-merge";
export type CheckboxProps = Omit<
CheckboxPrimitive.CheckboxProps,
"checked" | "disabled" | "required"
> & {
children?: ReactNode;
id: string;
isDisabled?: boolean;
isChecked?: boolean;
isRequired?: boolean;
checkIndicatorBg?: string | undefined;
isError?: boolean;
isIndeterminate?: boolean;
containerClassName?: string;
};
export const Checkbox = ({
children,
className,
id,
isChecked,
isDisabled,
isRequired,
checkIndicatorBg,
isError,
isIndeterminate,
containerClassName,
...props
}: CheckboxProps): JSX.Element => {
return (
<div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}>
<CheckboxPrimitive.Root
className={twMerge(
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-mineshaft-400 bg-mineshaft-600 shadow transition-all hover:bg-mineshaft-500",
isDisabled && "bg-bunker-400 hover:bg-bunker-400",
isChecked && "bg-primary hover:bg-primary",
Boolean(children) && "mr-3",
className
)}
required={isRequired}
checked={isChecked}
disabled={isDisabled}
{...props}
id={id}
>
<CheckboxPrimitive.Indicator className={`${checkIndicatorBg || "text-bunker-800"}`}>
{isIndeterminate ? (
<FontAwesomeIcon icon={faMinus} size="sm" />
) : (
<FontAwesomeIcon icon={faCheck} size="sm" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<label
className={twMerge("truncate whitespace-nowrap text-sm", isError && "text-red-400")}
htmlFor={id}
>
{children}
{isRequired && <span className="pl-1 text-red">*</span>}
</label>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export type { CheckboxProps } from "./Checkbox";
export { Checkbox } from "./Checkbox";

View File

@@ -0,0 +1,58 @@
// this will show a loading animation with text below
// if you pass array it will say it one by one giving user clear instruction on what's happening
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
type Props = {
text?: string | string[];
frequency?: number;
className?: string;
};
export const ContentLoader = ({ text, frequency = 2000, className }: Props) => {
const [pos, setPos] = useState(0);
const isTextArray = Array.isArray(text);
useEffect(() => {
let interval: NodeJS.Timeout;
if (isTextArray) {
interval = setInterval(() => {
setPos((state) => (state + 1) % text.length);
}, frequency);
}
return () => clearInterval(interval);
}, []);
return (
<div
className={twMerge(
"container relative mx-auto flex h-screen w-full flex-col items-center justify-center space-y-8 px-8 text-mineshaft-50 dark:[color-scheme:dark]",
className
)}
>
<img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="loading animation"
decoding="async"
loading="lazy"
/>
{text && isTextArray && (
<AnimatePresence exitBeforeEnter>
<motion.div
className="text-primary"
key={`content-loader-${pos}`}
initial={{ opacity: 0, translateY: 20 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: -20 }}
>
{text[pos]}
</motion.div>
</AnimatePresence>
)}
{text && !isTextArray && <div className="text-xs text-primary">{text}</div>}
</div>
);
};

View File

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

View File

@@ -0,0 +1,55 @@
import { faCheck, faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useTimedReset } from "@app/hooks";
import { IconButton } from "../IconButton";
import { Tooltip } from "../Tooltip";
export type CopyButtonProps = {
value: string;
size?: "xs" | "sm" | "md" | "lg";
variant?: "solid" | "outline" | "plain" | "star" | "outline_bg";
color?: string;
name?: string;
icon?: IconDefinition;
};
export const CopyButton = ({
value,
size = "sm",
variant = "solid",
color,
name,
icon = faCopy
}: CopyButtonProps) => {
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
initialState: name ? `Copy ${name}` : "Copy to clipboard"
});
async function handleCopyText() {
setCopyText("Copied");
navigator.clipboard.writeText(value);
}
return (
<div>
<Tooltip content={copyText} size={size === "xs" || size === "sm" ? "sm" : "md"}>
<IconButton
ariaLabel={copyText}
variant={variant}
className={twMerge("group relative", color)}
size={size}
onClick={() => {
handleCopyText();
}}
>
<FontAwesomeIcon icon={isCopying ? faCheck : icon} />
</IconButton>
</Tooltip>
</div>
);
};
CopyButton.displayName = "CopyButton";

View File

@@ -0,0 +1,2 @@
export type { CopyButtonProps } from "./CopyButton";
export { CopyButton } from "./CopyButton";

View File

@@ -0,0 +1,68 @@
import { GroupBase } from "react-select";
import ReactSelectCreatable, { CreatableProps } from "react-select/creatable";
import { twMerge } from "tailwind-merge";
import { ClearIndicator, DropdownIndicator, MultiValueRemove, Option } from "../Select/components";
export const CreatableSelect = <T,>({
isMulti,
closeMenuOnSelect,
...props
}: CreatableProps<T, boolean, GroupBase<T>>) => {
return (
<ReactSelectCreatable
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect ?? !isMulti}
hideSelectedOptions={false}
unstyled
styles={{
input: (base) => ({
...base,
"input:focus": {
boxShadow: "none"
}
}),
multiValueLabel: (base) => ({
...base,
whiteSpace: "normal",
overflow: "visible"
}),
control: (base) => ({
...base,
transition: "none"
})
}}
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
classNames={{
container: () => "w-full font-inter",
control: ({ isFocused }) =>
twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer"
),
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5",
input: () => "pl-1 py-0.5",
valueContainer: () => `p-1 max-h-[14rem] ${isMulti ? "!overflow-y-scroll" : ""} gap-1`,
singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm",
multiValueRemove: () => "hover:text-red text-bunker-400",
indicatorsContainer: () => "p-1 gap-1",
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
indicatorSeparator: () => "bg-bunker-400",
dropdownIndicator: () => "text-bunker-200 p-1",
menu: () =>
"mt-2 border text-sm text-mineshaft-200 bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) =>
twMerge(
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
isSelected && "text-mineshaft-200",
"hover:cursor-pointer text-xs px-3 py-2"
),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
}}
{...props}
/>
);
};

View File

@@ -0,0 +1 @@
export * from "./CreatableSelect";

View File

@@ -0,0 +1,84 @@
import { ChangeEventHandler, useState } from "react";
import { DayPicker, DayPickerProps } from "react-day-picker";
import { faCalendar } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverContentProps, PopoverProps } from "@radix-ui/react-popover";
import { format, setHours, setMinutes } from "date-fns";
import { Button } from "../Button";
import { Input } from "../Input";
import { Popover, PopoverContent, PopoverTrigger } from "../Popoverv2";
export type DatePickerProps = Omit<DayPickerProps, "selected"> & {
value?: Date;
onChange: (date?: Date) => void;
popUpProps: PopoverProps;
popUpContentProps: PopoverContentProps;
dateFormat?: "PPP" | "PP" | "P"; // extend as needed
};
// Doc: https://react-day-picker.js.org/
export const DatePicker = ({
value,
onChange,
popUpProps,
popUpContentProps,
dateFormat = "PPP",
...props
}: DatePickerProps) => {
const [timeValue, setTimeValue] = useState<string>(value ? format(value, "HH:mm") : "00:00");
const handleTimeChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const time = e.target.value;
if (time) {
setTimeValue(time);
if (value) {
const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10));
const newSelectedDate = setHours(setMinutes(value, minutes), hours);
onChange(newSelectedDate);
}
}
};
const handleDaySelect = (date: Date | undefined) => {
if (!timeValue || !date) {
onChange(date);
return;
}
const [hours, minutes] = timeValue.split(":").map((str) => parseInt(str, 10));
const newDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, minutes);
onChange(newDate);
};
return (
<Popover {...popUpProps}>
<PopoverTrigger asChild>
<Button variant="outline_bg" leftIcon={<FontAwesomeIcon icon={faCalendar} />}>
{value ? format(value, dateFormat) : "Pick a date and time"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit p-2" {...popUpContentProps}>
<div className="mx-4 my-4">
<Input
type="time"
value={timeValue}
onChange={handleTimeChange}
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</div>
<DayPicker
{...props}
mode="single"
selected={value}
onSelect={handleDaySelect}
modifiersStyles={{
selected: {
background: "#cad62d"
}
}}
/>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,2 @@
export type { DatePickerProps } from "./DatePicker";
export { DatePicker } from "./DatePicker";

View File

@@ -0,0 +1,107 @@
import { ReactNode, useEffect, useState } from "react";
import { useToggle } from "@app/hooks";
import { Button } from "../Button";
import { FormControl } from "../FormControl";
import { Input } from "../Input";
import { Modal, ModalClose, ModalContent } from "../Modal";
type Props = {
isOpen?: boolean;
onClose?: () => void;
onChange?: (isOpen: boolean) => void;
deleteKey: string;
title: string;
subTitle?: string;
onDeleteApproved: () => Promise<void>;
buttonText?: string;
children?: ReactNode;
};
export const DeleteActionModal = ({
isOpen,
onClose,
onChange,
deleteKey,
onDeleteApproved,
title,
subTitle = "This action is irreversible.",
buttonText = "Delete",
children
}: Props): JSX.Element => {
const [inputData, setInputData] = useState("");
const [isLoading, setIsLoading] = useToggle();
useEffect(() => {
setInputData("");
}, [isOpen]);
const onDelete = async () => {
setIsLoading.on();
try {
await onDeleteApproved();
} catch {
setIsLoading.off();
} finally {
setIsLoading.off();
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(isOpenState) => {
setInputData("");
if (onChange) onChange(isOpenState);
}}
>
<ModalContent
title={title}
subTitle={subTitle}
footerContent={
<div className="mx-2 flex items-center">
<Button
className="mr-4"
colorSchema="danger"
isDisabled={!(deleteKey === inputData) || isLoading}
onClick={onDelete}
isLoading={isLoading}
>
{buttonText}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
Cancel
</Button>
</ModalClose>{" "}
</div>
}
onClose={onClose}
>
<form
onSubmit={(evt) => {
evt.preventDefault();
if (deleteKey === inputData) onDelete();
}}
>
<FormControl
label={
<div className="break-words pb-2 text-sm">
Type <span className="font-bold">{deleteKey}</span> to perform this action
</div>
}
className="mb-0"
>
<Input
value={inputData}
onChange={(e) => setInputData(e.target.value)}
placeholder={`Type ${deleteKey} here`}
/>
</FormControl>
{children}
</form>
</ModalContent>
</Modal>
);
};

View File

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

View File

@@ -0,0 +1,13 @@
import { twMerge } from "tailwind-merge";
interface IProps {
className?: string;
}
export const Divider = ({ className }: IProps): JSX.Element => {
return (
<div className={twMerge("flex items-center px-2 opacity-50", className)}>
<div aria-hidden="true" className="h-5 w-full grow border border-t border-mineshaft-200" />
</div>
);
};

View File

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

View File

@@ -0,0 +1,84 @@
import { forwardRef, ReactNode } from "react";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, VariantProps } from "cva";
import { twMerge } from "tailwind-merge";
import { Card, CardBody, CardFooter, CardTitle } from "../Card";
import { IconButton } from "../IconButton";
export type DrawerContentProps = DialogPrimitive.DialogContentProps & {
title?: ReactNode;
subTitle?: ReactNode;
footerContent?: ReactNode;
onClose?: () => void;
} & VariantProps<typeof drawerContentVariation>;
const drawerContentVariation = cva(
"fixed ease-in-out duration-300 z-[90] border border-mineshaft-600 drop-shadow-2xl",
{
variants: {
direction: {
right: [
"right-0 top-0",
"h-full w-96",
"data-[state=open]:animate-drawerRightIn data-[state=closed]:animate-drawerRightOut"
]
}
}
}
);
export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
(
{ children, title, subTitle, className, footerContent, direction = "right", onClose, ...props },
forwardedRef
) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-20 h-full w-full"
style={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }}
/>
<DialogPrimitive.Content
{...props}
ref={forwardedRef}
className={twMerge(drawerContentVariation({ direction, className }))}
>
<Card isRounded={false} className="dark h-full w-full">
{title && (
<CardTitle subTitle={subTitle} className="px-4">
{title}
</CardTitle>
)}
<CardBody className="flex-grow overflow-y-auto overflow-x-hidden px-4 dark:[color-scheme:dark]">
{children}
</CardBody>
{footerContent && <CardFooter>{footerContent}</CardFooter>}{" "}
<DialogPrimitive.Close aria-label="Close" asChild onClick={onClose}>
<IconButton
variant="plain"
ariaLabel="close"
className="absolute top-4 right-6 rounded text-bunker-400 hover:text-bunker-50"
>
<FontAwesomeIcon icon={faTimes} size="lg" className="cursor-pointer" />
</IconButton>
</DialogPrimitive.Close>
</Card>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
);
DrawerContent.displayName = "ModalContent";
export type DrawerProps = Omit<DialogPrimitive.DialogProps, "open"> & { isOpen?: boolean };
export const Drawer = ({ isOpen, ...props }: DrawerProps) => (
<DialogPrimitive.Root open={isOpen} {...props} />
);
export const DrawerTrigger = DialogPrimitive.Trigger;
export type DrawerTriggerProps = DialogPrimitive.DialogTriggerProps;
export const DrawerClose = DialogPrimitive.Close;
export type DrawerCloseProps = DialogPrimitive.DialogCloseProps;

View File

@@ -0,0 +1 @@
export { Drawer, DrawerClose, DrawerContent, DrawerTrigger } from "./Drawer";

View File

@@ -0,0 +1,170 @@
import { ComponentPropsWithRef, ElementType, forwardRef, ReactNode, Ref } from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { twMerge } from "tailwind-merge";
// Main menu or parent container
export type DropdownMenuProps = DropdownMenuPrimitive.DropdownMenuProps;
export const DropdownMenu = DropdownMenuPrimitive.Root;
export type DropdownSubMenuProps = DropdownMenuPrimitive.DropdownMenuSubProps;
export const DropdownSubMenu = DropdownMenuPrimitive.Sub;
// trigger
export type DropdownMenuTriggerProps = DropdownMenuPrimitive.DropdownMenuTriggerProps;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
// item container
export type DropdownMenuContentProps = DropdownMenuPrimitive.MenuContentProps;
export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
({ children, className, ...props }, forwardedRef) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
sideOffset={10}
{...props}
ref={forwardedRef}
className={twMerge(
"z-30 min-w-[220px] overflow-y-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 text-bunker-300 shadow will-change-auto data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade",
className
)}
>
{children}
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
);
}
);
DropdownMenuContent.displayName = "DropdownMenuContent";
// item container
export type DropdownSubMenuContentProps = DropdownMenuPrimitive.MenuSubContentProps;
export const DropdownSubMenuContent = forwardRef<HTMLDivElement, DropdownSubMenuContentProps>(
({ children, className, ...props }, forwardedRef) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
sideOffset={2}
{...props}
ref={forwardedRef}
className={twMerge(
"z-30 min-w-[220px] rounded-md border border-mineshaft-600 bg-mineshaft-900 text-bunker-300 shadow will-change-auto data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade",
className
)}
>
{children}
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPrimitive.Portal>
);
}
);
DropdownSubMenuContent.displayName = "DropdownMenuContent";
// item label component
export type DropdownLabelProps = DropdownMenuPrimitive.MenuLabelProps;
export const DropdownMenuLabel = ({ className, ...props }: DropdownLabelProps) => (
<DropdownMenuPrimitive.Label
{...props}
className={twMerge("px-4 pb-1 pt-2 text-xs text-bunker-400", className)}
/>
);
// dropdown items
export type DropdownMenuItemProps<T extends ElementType> =
DropdownMenuPrimitive.MenuContentProps & {
icon?: ReactNode;
as?: T;
inputRef?: Ref<T>;
iconPos?: "left" | "right";
};
export const DropdownMenuItem = <T extends ElementType = "button">({
children,
inputRef,
className,
icon,
as: Item = "button",
iconPos = "left",
isDisabled = false,
...props
}: DropdownMenuItemProps<T> & ComponentPropsWithRef<T> & { isDisabled?: boolean }) => (
<DropdownMenuPrimitive.Item
{...props}
className={twMerge(
"block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
className,
isDisabled ? "pointer-events-none opacity-50" : ""
)}
>
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>
{icon && iconPos === "left" && <span className="mr-2 flex items-center">{icon}</span>}
<span className="flex-grow text-left">{children}</span>
{icon && iconPos === "right" && <span className="ml-2 flex items-center">{icon}</span>}
</Item>
</DropdownMenuPrimitive.Item>
);
// trigger
export type DropdownSubMenuTriggerProps<T extends ElementType> =
DropdownMenuPrimitive.DropdownMenuSubTriggerProps & {
icon?: ReactNode;
as?: T;
inputRef?: Ref<T>;
iconPos?: "left" | "right";
};
export const DropdownSubMenuTrigger = <T extends ElementType = "button">({
children,
inputRef,
className,
icon,
as: Item = "button",
iconPos = "left",
...props
}: DropdownMenuItemProps<T> & ComponentPropsWithRef<T>) => (
<DropdownMenuPrimitive.SubTrigger
{...props}
className={twMerge(
"block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
className
)}
>
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>
{icon && iconPos === "left" && <span className="mr-2 flex items-center">{icon}</span>}
<span className="flex-grow text-left">{children}</span>
{icon && iconPos === "right" && <span className="ml-2 flex items-center">{icon}</span>}
</Item>
</DropdownMenuPrimitive.SubTrigger>
);
// grouping items into 1
export type DropdownMenuGroupProps = DropdownMenuPrimitive.DropdownMenuGroupProps;
export const DropdownMenuGroup = forwardRef<HTMLDivElement, DropdownMenuGroupProps>(
({ ...props }, ref) => (
<DropdownMenuPrimitive.Group
{...props}
className={twMerge("py-2 pl-3 text-xs", props.className)}
ref={ref}
/>
)
);
DropdownMenuGroup.displayName = "DropdownMenuGroup";
// Divider
export const DropdownMenuSeparator = forwardRef<
HTMLDivElement,
DropdownMenuPrimitive.DropdownMenuSeparatorProps
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
{...props}
className={twMerge("m-1 h-[1px] bg-gray-700", className)}
/>
));
DropdownMenuSeparator.displayName = "DropdownMenuSeperator";
DropdownMenuSeparator.displayName = "DropdownMenuSeperator";

View File

@@ -0,0 +1,23 @@
export type {
DropdownLabelProps,
DropdownMenuContentProps,
DropdownMenuGroupProps,
DropdownMenuItemProps,
DropdownMenuProps,
DropdownMenuTriggerProps,
DropdownSubMenuContentProps,
DropdownSubMenuProps,
DropdownSubMenuTriggerProps
} from "./Dropdown";
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger
} from "./Dropdown";

View File

@@ -0,0 +1,22 @@
import { Button } from "../Button";
import { Modal, ModalContent } from "../Modal";
type Props = {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
};
export const EmailServiceSetupModal = ({ isOpen, onOpenChange }: Props): JSX.Element => (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Email service not configured">
<p className="mb-4 text-bunker-300">
The administrators of this Infisical instance have not yet set up an email service provider
required to perform this action
</p>
<a href="https://infisical.com/docs/self-hosting/configuration/email">
<Button className="mr-4">Learn more</Button>
</a>
</ModalContent>
</Modal>
);

View File

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

View File

@@ -0,0 +1,34 @@
import { ReactNode } from "react";
import { SizeProp } from "@fortawesome/fontawesome-svg-core";
import { faCubesStacked, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
type Props = {
title: ReactNode;
className?: string;
children?: ReactNode;
icon?: IconDefinition;
iconSize?: SizeProp;
};
export const EmptyState = ({
title,
className,
children,
icon = faCubesStacked,
iconSize = "2x"
}: Props) => (
<div
className={twMerge(
"flex w-full flex-col items-center bg-mineshaft-800 px-2 pt-4 text-bunker-300",
className
)}
>
<FontAwesomeIcon icon={icon} size={iconSize} />
<div className="flex flex-col items-center py-4">
<div className="text-sm text-bunker-300">{title}</div>
<div>{children}</div>
</div>
</div>
);

View File

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

View File

@@ -0,0 +1,81 @@
import Select, { Props } from "react-select";
import { twMerge } from "tailwind-merge";
import { ClearIndicator, DropdownIndicator, MultiValueRemove, Option } from "../Select/components";
export const FilterableSelect = <T,>({
isMulti,
closeMenuOnSelect,
tabSelectsValue = false,
...props
}: Props<T>) => (
<Select
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect ?? !isMulti}
hideSelectedOptions={false}
unstyled
styles={{
input: (base) => ({
...base,
"input:focus": {
boxShadow: "none"
}
}),
multiValueLabel: (base) => ({
...base,
whiteSpace: "normal",
overflow: "visible"
}),
control: (base) => ({
...base,
transition: "none"
})
}}
tabSelectsValue={tabSelectsValue}
components={{
DropdownIndicator,
ClearIndicator,
MultiValueRemove,
Option,
...props.components
}}
classNames={{
container: ({ isDisabled }) =>
twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
control: ({ isFocused, isDisabled }) =>
twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
`border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${
isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer"
} `
),
placeholder: () =>
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1",
valueContainer: () =>
`px-1 max-h-[8.2rem] ${
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
} gap-1`,
singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm",
multiValueRemove: () => "hover:text-red text-bunker-400",
indicatorsContainer: () => "p-1 gap-1",
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
indicatorSeparator: () => "bg-bunker-400",
dropdownIndicator: () => "text-bunker-200 p-1",
menuList: () => "flex flex-col gap-1",
menu: () =>
"my-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) =>
twMerge(
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
isSelected && "text-mineshaft-200",
"hover:cursor-pointer rounded text-xs px-3 py-2"
),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
}}
{...props}
/>
);

View File

@@ -0,0 +1 @@
export * from "./FilterableSelect";

View File

@@ -0,0 +1,19 @@
import { forwardRef, HTMLAttributes } from "react";
type Props = {
symbolName: string;
} & HTMLAttributes<HTMLDivElement>;
export const FontAwesomeSymbol = forwardRef<HTMLDivElement, Props>(
({ symbolName, ...props }, ref) => {
return (
<div ref={ref} {...props}>
<svg className="w-inherit h-inherit">
<use href={`#${symbolName}`} />
</svg>
</div>
);
}
);
FontAwesomeSymbol.displayName = "FontAwesomeSymbol";

View File

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

View File

@@ -0,0 +1,123 @@
import { cloneElement, ReactElement, ReactNode } from "react";
import { faExclamationTriangle, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as Label from "@radix-ui/react-label";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "../Tooltip";
export type FormLabelProps = {
id?: string;
isRequired?: boolean;
isOptional?: boolean;
label?: ReactNode;
icon?: ReactNode;
className?: string;
tooltipClassName?: string;
tooltipText?: ReactNode;
};
export const FormLabel = ({
id,
label,
isRequired,
icon,
className,
isOptional,
tooltipClassName,
tooltipText
}: FormLabelProps) => (
<Label.Root
className={twMerge(
"mb-0.5 flex items-center text-sm font-normal text-mineshaft-400",
className
)}
htmlFor={id}
>
{label}
{isRequired && <span className="ml-1 text-red">*</span>}
{isOptional && <span className="ml-1 text-xs italic text-gray-500">- Optional</span>}
{icon && !tooltipText && (
<span className="ml-2 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
{icon}
</span>
)}
{tooltipText && (
<Tooltip content={tooltipText} className={tooltipClassName}>
<FontAwesomeIcon icon={faQuestionCircle} size="1x" className="ml-2" />
</Tooltip>
)}
</Label.Root>
);
export type FormHelperTextProps = {
isError?: boolean;
text?: ReactNode;
};
export const FormHelperText = ({ isError, text }: FormHelperTextProps) => (
<div
className={twMerge(
"mt-2 flex items-center font-inter text-xs text-mineshaft-300 opacity-90",
isError && "text-red-600"
)}
>
{isError && (
<span>
<FontAwesomeIcon icon={faExclamationTriangle} size="sm" className="mr-1" />
</span>
)}
<span>{text}</span>
</div>
);
export type FormControlProps = {
id?: string;
isRequired?: boolean;
isOptional?: boolean;
isError?: boolean;
label?: ReactNode;
helperText?: ReactNode;
errorText?: ReactNode;
children: JSX.Element;
className?: string;
icon?: ReactNode;
tooltipText?: ReactElement | string;
tooltipClassName?: string;
};
export const FormControl = ({
children,
isRequired,
isOptional,
label,
helperText,
errorText,
id,
isError,
icon,
className,
tooltipText,
tooltipClassName
}: FormControlProps): JSX.Element => {
return (
<div className={twMerge("mb-4", className)}>
{typeof label === "string" ? (
<FormLabel
label={label}
isOptional={isOptional}
isRequired={isRequired}
id={id}
icon={icon}
tooltipText={tooltipText}
tooltipClassName={tooltipClassName}
/>
) : (
label
)}
{cloneElement(children, { isRequired, "data-required": isRequired, isError })}
{!isError && helperText && <FormHelperText isError={isError} text={helperText} />}
{isError && errorText && <FormHelperText isError={isError} text={errorText} />}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export type { FormControlProps, FormHelperTextProps, FormLabelProps } from "./FormControl";
export { FormControl, FormHelperText, FormLabel } from "./FormControl";

View File

@@ -0,0 +1,36 @@
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as HoverCard from "@radix-ui/react-hover-card";
type Props = {
text: string;
icon: IconProp;
color: string;
};
export type HoverCardProps = Props;
export const HoverObject = ({ text, icon, color }: Props): JSX.Element => (
<HoverCard.Root openDelay={50}>
<HoverCard.Trigger asChild>
<a className="ImageTrigger z-20">
<FontAwesomeIcon icon={icon} className={`text-${color}`} />
</a>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content className="HoverCardContent z-[300]" sideOffset={5}>
<div className="rounded-md border border-mineshaft-600 bg-bunker-700 p-2 text-bunker-300 drop-shadow-xl">
<div style={{ display: "flex", flexDirection: "column", gap: 15 }}>
<div>
<div className="Text bold">{text}</div>
</div>
</div>
</div>
<HoverCard.Arrow className="border-mineshaft-600" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
HoverObject.displayName = "HoverCard";

View File

@@ -0,0 +1,2 @@
export type { HoverCardProps } from "./HoverCard";
export { HoverObject } from "./HoverCard";

View File

@@ -0,0 +1,26 @@
import { ReactNode } from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { twMerge } from "tailwind-merge";
export const HoverCardTrigger = HoverCardPrimitive.Trigger;
export const HoverCard = HoverCardPrimitive.Root;
export type HoverCardContentProps = {
children?: ReactNode;
} & HoverCardPrimitive.HoverCardContentProps;
export const HoverCardContent = ({ children, className, ...props }: HoverCardContentProps) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
{...props}
className={twMerge(
"relative w-64 rounded-md bg-mineshaft-600 fill-mineshaft-600 p-4 pt-6 font-inter text-gray-200 shadow-md",
className
)}
>
{children}
<HoverCardPrimitive.Arrow className="fill-inherit stroke-inherit" />
</HoverCardPrimitive.Content>
</HoverCardPrimitive.Portal>
);

View File

@@ -0,0 +1 @@
export { HoverCard, HoverCardContent, HoverCardTrigger } from "./HoverCardv2";

View File

@@ -0,0 +1,149 @@
import { ButtonHTMLAttributes, forwardRef, ReactNode } from "react";
import { cva, VariantProps } from "cva";
import { twMerge } from "tailwind-merge";
type Props = {
children: ReactNode;
// This is kept as required because by accessibility convention and eslint
// when button doesn't have text an aria-label needs to be passed
ariaLabel: string;
isDisabled?: boolean;
};
const iconButtonVariants = cva(
[
"button",
"transition-all",
"font-inter font-medium user-select-none",
"cursor-pointer",
"inline-flex items-center justify-center",
"relative"
],
{
variants: {
colorSchema: {
primary: ["bg-primary", "text-black", "border-primary hover:opacity-80"],
secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-bunker-400"],
danger: ["bg-[#973939]", "text-white", "border-red"]
},
variant: {
solid: "",
outline: ["bg-transparent", "border-2", "border-solid"],
plain: "",
star: "text-bunker-200 bg-mineshaft-500",
outline_bg: ""
},
isDisabled: {
true: "bg-opacity-70 cursor-not-allowed",
false: ""
},
isRounded: {
true: "rounded-md",
false: ""
},
size: {
xs: ["text-xs", "rounded-sm", "py-1.5", "px-2"],
sm: ["text-sm", "py-3", "px-3"],
md: ["text-md", "py-4", "px-4"],
lg: ["text-lg", "py-6", "px-6"]
}
},
compoundVariants: [
{
colorSchema: "primary",
variant: "star",
className: "hover:bg-primary hover:text-black"
},
{
colorSchema: "primary",
variant: "outline_bg",
className:
"bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary/[0.15] hover:border-primary/60 text-bunker-200 hover:text-bunker-100 duration-100"
},
{
colorSchema: "danger",
variant: "star",
className: "hover:bg-red hover:text-white"
},
{
colorSchema: "primary",
variant: "outline",
className: "text-primary hover:bg-primary hover:text-black"
},
{
colorSchema: "secondary",
variant: "outline",
className: "hover:bg-mineshaft"
},
{
colorSchema: "danger",
variant: "outline",
className: "text-red hover:bg-red hover:text-black"
},
{
colorSchema: "primary",
variant: "plain",
className: "hover:text-primary"
},
{
colorSchema: "secondary",
variant: "plain",
className: "text-mineshaft"
},
{
colorSchema: "danger",
variant: "plain",
className: "hover:text-red"
},
{
colorSchema: ["danger", "primary", "secondary"],
variant: ["plain"],
className: "bg-transparent py-1 px-1 text-bunker-300"
}
]
}
);
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> &
VariantProps<typeof iconButtonVariants> &
Props;
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(
{
children,
ariaLabel,
isDisabled = false,
className,
size = "sm",
variant = "solid",
isRounded = true,
colorSchema = "primary",
...props
},
ref
): JSX.Element => (
<button
ref={ref}
aria-disabled={isDisabled}
type="button"
aria-label={ariaLabel}
className={twMerge(
iconButtonVariants({
className,
colorSchema,
size,
variant,
isRounded,
isDisabled
})
)}
disabled={isDisabled}
{...props}
>
{children}
</button>
)
);
IconButton.displayName = "IconButton";

View File

@@ -0,0 +1,2 @@
export type { IconButtonProps } from "./IconButton";
export { IconButton } from "./IconButton";

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