feat: changed to virtual route for auth and personal settings

This commit is contained in:
=
2024-12-24 20:09:50 +05:30
parent dd5c494bdb
commit 3ebbaefc2a
80 changed files with 5047 additions and 7 deletions

View File

@@ -0,0 +1,15 @@
import { FileRouteTypes } from "@app/routeTree.gen";
const setRoute = <TFull extends FileRouteTypes["fullPaths"], TId extends FileRouteTypes["id"]>(
full: TFull,
id: TId
) => ({ full, id }) as const;
export const ROUTE_PATHS = Object.freeze({
ProviderSuccessPage: setRoute(
"/login/provider/success",
"/_restrict-login-signup/login/provider/success"
),
SignUpSsoPage: setRoute("/signup/sso", "/_restrict-login-signup/signup/sso/"),
PasswordResetPage: setRoute("/password-reset", "/_restrict-login-signup/password-reset")
});

View File

@@ -0,0 +1,90 @@
import { Helmet } from "react-helmet";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton, SecretInput } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { useTimedReset } from "@app/hooks";
const getTerminalCliToken = () => {
const cliTerminalTokenInfo = sessionStorage.getItem(SessionStorageKeys.CLI_TERMINAL_TOKEN);
if (!cliTerminalTokenInfo) return;
const { expiry, data } = JSON.parse(cliTerminalTokenInfo);
if (new Date() > new Date(expiry)) {
sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN);
return;
}
// eslint-disable-next-line
return data as string;
};
export const CliRedirectPage = () => {
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false
});
const cliToken = getTerminalCliToken();
const copyUrlToClipboard = () => {
if (cliToken) {
navigator.clipboard.writeText(cliToken);
setIsUrlCopied(true);
sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN);
}
};
return (
<div className="flex flex-col justify-between bg-bunker-800 md:h-screen">
<Helmet>
<title>Infisical CLI | Login Successful!</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="flex h-screen w-screen flex-col items-center justify-center space-y-4 text-gray-200">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
{cliToken ? (
<>
<div className="pb-4">
<p className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-3xl font-medium text-transparent">
Unable to reach CLI
</p>
<p className="text-light mb-1 text-center text-lg text-mineshaft-400">
Your login was successful but, Infisical couldn&apos;t automatically push your login
token to the CLI.
</p>
<p className="text-light mb-1 text-center text-lg text-mineshaft-400">
Please copy the token below and manually provide it to your CLI.
</p>
</div>
<div className="dark relative flex max-h-36 max-w-xl flex-col items-center space-y-4 overflow-y-auto rounded-md border border-mineshaft-700 bg-mineshaft-900 p-3">
<SecretInput value={cliToken as string} />
<div className="mx-1 flex">
<IconButton
variant="outline_bg"
colorSchema="primary"
ariaLabel="copy to clipboard"
onClick={copyUrlToClipboard}
className="flex items-center rounded py-2"
>
<FontAwesomeIcon className="pr-2" icon={isUrlCopied ? faCheck : faCopy} /> Copy to
clipboard
</IconButton>
</div>
</div>
</>
) : (
<>
<p className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-3xl font-medium text-transparent">
Head back to your terminal
</p>
<p className="text-light mb-1 text-lg text-mineshaft-400">
You&apos;ve successfully logged in to the Infisical CLI
</p>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { CliRedirectPage } from "./CliRedirectPage";
export const Route = createFileRoute("/_authenticate/cli-redirect")({
component: CliRedirectPage
});

View File

@@ -0,0 +1,20 @@
import { Helmet } from "react-helmet";
export const EmailNotVerifiedPage = () => {
return (
<div className="flex flex-col justify-between bg-bunker-800 md:h-screen">
<Helmet>
<title>Request a New Invite</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="flex h-screen w-screen flex-col items-center justify-center text-gray-200">
<p className="text-6xl">Oops.</p>
<p className="mb-1 mt-2 text-xl">Your email was not verified. </p>
<p className="text-xl">Please try again.</p>
<p className="text-md mt-8 max-w-sm text-center text-gray-600">
Note: If it still doesn&apos;t work, please reach out to us at support@infisical.com
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { EmailNotVerifiedPage } from "./EmailNotVerifiedPage";
export const Route = createFileRoute("/_restrict-login-signup/email-not-verified")({
component: EmailNotVerifiedPage
});

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { Button, Input } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { loginLDAPRedirect } from "@app/hooks/api/auth/queries";
export const LoginLdapPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search);
const passedOrgSlug = queryParams.get("organizationSlug");
const passedUsername = queryParams.get("username");
const [organizationSlug, setOrganizationSlug] = useState(
config.defaultAuthOrgSlug || passedOrgSlug || ""
);
const [username, setUsername] = useState(passedUsername || "");
const [password, setPassword] = useState("");
const handleSubmission = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { nextUrl } = await loginLDAPRedirect({
organizationSlug,
username,
password
});
if (!nextUrl) {
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
return;
}
createNotification({
text: "Successfully logged in",
type: "success"
});
window.open(nextUrl);
window.close();
} catch {
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
}
// TODO: add callback port support
// const callbackPort = queryParams.get("callback_port");
// window.open(`/api/v1/ldap/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
// window.close();
};
return (
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
<Link to="/">
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<div className="mx-auto w-full max-w-md md:px-6">
<p className="mx-auto mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
What&apos;s your LDAP Login?
</p>
<form onSubmit={handleSubmission}>
{!config.defaultAuthOrgSlug && !passedOrgSlug && (
<div className="relative mx-auto flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={organizationSlug}
onChange={(e) => setOrganizationSlug(e.target.value)}
type="text"
placeholder="Enter your organization slug..."
isRequired
autoComplete="email"
id="email"
className="h-12"
/>
</div>
</div>
)}
<div className="relative mx-auto mt-2 flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
type="text"
placeholder="Enter your LDAP username..."
isRequired
autoComplete="email"
id="email"
className="h-12"
isDisabled={passedUsername !== null}
/>
</div>
</div>
<div className="relative mx-auto mt-2 flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your LDAP password..."
isRequired
autoComplete="current-password"
id="current-password"
className="select:-webkit-autofill:focus h-10"
/>
</div>
</div>
<div className="mx-auto mt-4 flex w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
<Button
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
className="h-14"
>
{t("login.login")}
</Button>
</div>
</form>
<div className="mt-4 flex flex-row items-center justify-center">
<button
onClick={() => {
navigate({ to: "/login" });
}}
type="button"
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
>
{t("login.other-option")}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { LoginLdapPage } from "./LoginLDAPPage";
export const Route = createFileRoute("/_restrict-login-signup/login/ldap/")({
component: LoginLdapPage
});

View File

@@ -0,0 +1,51 @@
import { NavigateFn, useNavigate } from "@tanstack/react-router";
import { useServerConfig } from "@app/context";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { queryClient } from "@app/hooks/api/reactQuery";
import { userKeys } from "@app/hooks/api/users";
export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: string) => {
const userOrgs = await fetchOrganizations();
const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced);
if (organizationId) {
localStorage.setItem("orgData.id", organizationId);
navigate({ to: `/org/${organizationId}/overview` });
return;
}
if (nonAuthEnforcedOrgs.length > 0) {
// user is part of at least 1 non-auth enforced org
const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id;
localStorage.setItem("orgData.id", userOrg);
navigate({ to: `/org/${userOrg}/overview` });
} else {
// user is not part of any non-auth enforced orgs
localStorage.removeItem("orgData.id");
navigate({ to: "/organization/none" });
}
};
export const useNavigateToSelectOrganization = () => {
const { config } = useServerConfig();
const navigate = useNavigate();
const navigateToSelectOrganization = async (cliCallbackPort?: string) => {
let redirectTo = "/login/select-organization?";
if (config.defaultAuthOrgId) {
redirectTo += `org_id=${config.defaultAuthOrgId}&`;
} else {
queryClient.invalidateQueries({ queryKey: userKeys.getUser });
}
if (cliCallbackPort) {
redirectTo += `callback_port=${cliCallbackPort}`;
}
navigate({ to: redirectTo });
};
return { navigateToSelectOrganization };
};

View File

@@ -0,0 +1,77 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { Link } from "@tanstack/react-router";
import { InitialStep, SSOStep } from "./components";
export const LoginPage = () => {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// TODO(rbr): move this to beforeload
// const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
//
// const queryParams = new URLSearchParams(window.location.search);
//
// useEffect(() => {
// // TODO(akhilmhdh): workspace will be controlled by a workspace context
// const handleRedirects = async () => {
// try {
// const callbackPort = queryParams?.get("callback_port");
// // case: a callback port is set, meaning it's a cli login request: redirect to select org with callback port
// if (callbackPort) {
// navigateToSelectOrganization(callbackPort);
// } else {
// // case: no callback port, meaning it's a regular login request: redirect to select org
// navigateToSelectOrganization();
// }
// } catch (error) {
// console.log("Error - Not logged in yet");
// }
// };
// if (isLoggedIn()) {
// handleRedirects();
// }
// }, []);
const renderView = () => {
switch (step) {
case 0:
return (
<InitialStep
setStep={setStep}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
/>
);
case 2:
return <SSOStep setStep={setStep} type="SAML" />;
case 3:
return <SSOStep setStep={setStep} type="OIDC" />;
default:
return <div />;
}
};
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
<Link to="/">
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<div className="pb-28">{renderView()}</div>;
</div>
);
};

View File

@@ -0,0 +1,408 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Link, useNavigate } from "@tanstack/react-router";
import Error from "@app/components/basic/Error";
import { RegionSelect } from "@app/components/navigation/RegionSelect";
import { createNotification } from "@app/components/notifications";
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
import attemptLogin from "@app/components/utilities/attemptLogin";
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
import { Button, IconButton, Input, Tooltip } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useFetchServerStatus } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { useNavigateToSelectOrganization } from "../../Login.utils";
type Props = {
setStep: (step: number) => void;
email: string;
setEmail: (email: string) => void;
password: string;
setPassword: (email: string) => void;
};
export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search);
const [captchaToken, setCaptchaToken] = useState("");
const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false);
const captchaRef = useRef<HCaptcha>(null);
const { data: serverDetails } = useFetchServerStatus();
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
const redirectToSaml = (orgSlug: string) => {
const callbackPort = queryParams.get("callback_port");
const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${orgSlug}${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`;
navigate({ to: redirectUrl });
};
const redirectToOidc = (orgSlug: string) => {
const callbackPort = queryParams.get("callback_port");
const redirectUrl = `/api/v1/sso/oidc/login?orgSlug=${orgSlug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
navigate({ to: redirectUrl });
};
useEffect(() => {
if (serverDetails?.samlDefaultOrgSlug) redirectToSaml(serverDetails.samlDefaultOrgSlug);
}, [serverDetails?.samlDefaultOrgSlug]);
const handleSaml = () => {
if (config.defaultAuthOrgSlug) {
redirectToSaml(config.defaultAuthOrgSlug);
} else {
setStep(2);
}
};
const handleOidc = () => {
if (config.defaultAuthOrgSlug) {
redirectToOidc(config.defaultAuthOrgSlug);
} else {
setStep(3);
}
};
const shouldDisplayLoginMethod = (method: LoginMethod) =>
!config.enabledLoginMethods || config.enabledLoginMethods.includes(method);
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
if (!email || !password) {
return;
}
setIsLoading(true);
if (queryParams && queryParams.get("callback_port")) {
const callbackPort = queryParams.get("callback_port");
// attemptCliLogin
const isCliLoginSuccessful = await attemptCliLogin({
email: email.toLowerCase(),
password,
captchaToken
});
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
navigateToSelectOrganization(callbackPort!);
} else {
setLoginError(true);
createNotification({
text: "CLI login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
}
} else {
const isLoginSuccessful = await attemptLogin({
email: email.toLowerCase(),
password,
captchaToken
});
if (isLoginSuccessful && isLoginSuccessful.success) {
// case: login was successful
navigateToSelectOrganization();
createNotification({
text: "Successfully logged in",
type: "success"
});
}
}
} catch (err: any) {
console.error(err);
if (err.response.data.error === "User Locked") {
createNotification({
title: err.response.data.error,
text: err.response.data.message,
type: "error"
});
setIsLoading(false);
return;
}
if (err.response.data.error === "Captcha Required") {
setShouldShowCaptcha(true);
setIsLoading(false);
return;
}
setLoginError(true);
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
}
if (captchaRef.current) {
captchaRef.current.resetCaptcha();
}
setCaptchaToken("");
setIsLoading(false);
};
if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod) {
return (
<form
onSubmit={handleLogin}
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">
Login to Infisical
</h1>
<RegionSelect />
{config.defaultAuthOrgAuthMethod === AuthMethod.SAML && (
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleSaml}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with SAML
</Button>
</div>
)}
{config.defaultAuthOrgAuthMethod === AuthMethod.OIDC && (
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleOidc}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with OIDC
</Button>
</div>
)}
</form>
);
}
return (
<form
onSubmit={handleLogin}
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">
Login to Infisical
</h1>
<RegionSelect />
{shouldDisplayLoginMethod(LoginMethod.SAML) && (
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleSaml}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with SAML
</Button>
</div>
)}
{shouldDisplayLoginMethod(LoginMethod.OIDC) && (
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleOidc}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with OIDC
</Button>
</div>
)}
{shouldDisplayLoginMethod(LoginMethod.LDAP) && (
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
navigate({ to: "/login/ldap" });
}}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with LDAP
</Button>
</div>
)}
<div className="mt-2 flex w-1/4 min-w-[21.2rem] gap-2 md:min-w-[20.1rem] lg:w-1/6">
{shouldDisplayLoginMethod(LoginMethod.GOOGLE) && (
<Tooltip position="bottom" content={t("login.continue-with-google")}>
<IconButton
ariaLabel={t("login.continue-with-google")}
colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open(
`/api/v1/sso/redirect/google${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
>
<FontAwesomeIcon icon={faGoogle} />
</IconButton>
</Tooltip>
)}
{shouldDisplayLoginMethod(LoginMethod.GITHUB) && (
<Tooltip position="bottom" content="Continue with GitHub">
<IconButton
ariaLabel="Login continue with GitHub"
colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open(
`/api/v1/sso/redirect/github${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
>
<FontAwesomeIcon icon={faGithub} />
</IconButton>
</Tooltip>
)}
{shouldDisplayLoginMethod(LoginMethod.GITLAB) && (
<Tooltip position="bottom" content="Continue with GitLab">
<IconButton
ariaLabel="Login continue with GitLab"
colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open(
`/api/v1/sso/redirect/gitlab${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
>
<FontAwesomeIcon icon={faGitlab} />
</IconButton>
</Tooltip>
)}
</div>
{(!config.enabledLoginMethods ||
(shouldDisplayLoginMethod(LoginMethod.EMAIL) && config.enabledLoginMethods.length > 1)) && (
<div className="my-4 flex w-1/4 min-w-[20rem] flex-row items-center py-2 lg:w-1/6">
<div className="w-full border-t border-mineshaft-400/60" />
<span className="mx-2 text-xs text-mineshaft-200">or</span>
<div className="w-full border-t border-mineshaft-400/60" />
</div>
)}
{shouldDisplayLoginMethod(LoginMethod.EMAIL) && (
<>
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
placeholder="Enter your email..."
isRequired
autoComplete="username"
className="h-10"
/>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your password..."
isRequired
autoComplete="current-password"
id="current-password"
className="select:-webkit-autofill:focus h-10"
/>
</div>
{shouldShowCaptcha && (
<div className="mt-4">
<HCaptcha
theme="dark"
sitekey={CAPTCHA_SITE_KEY}
onVerify={(token) => setCaptchaToken(token)}
ref={captchaRef}
/>
</div>
)}
<div className="mt-4 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
disabled={shouldShowCaptcha && captchaToken === ""}
type="submit"
size="sm"
isFullWidth
className="h-10"
colorSchema="primary"
variant="solid"
isLoading={isLoading}
>
{" "}
Continue with Email{" "}
</Button>
</div>
</>
)}
{!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />}
{config.allowSignUp &&
(shouldDisplayLoginMethod(LoginMethod.EMAIL) ||
shouldDisplayLoginMethod(LoginMethod.GOOGLE) ||
shouldDisplayLoginMethod(LoginMethod.GITHUB) ||
shouldDisplayLoginMethod(LoginMethod.GITLAB)) ? (
<div className="mt-6 flex flex-row text-sm text-bunker-400">
<Link href="/signup">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
Don&apos;t have an account yet? {t("login.create-account")}
</span>
</Link>
</div>
) : (
<div className="mt-4" />
)}
{shouldDisplayLoginMethod(LoginMethod.EMAIL) && (
<div className="mt-2 flex flex-row text-sm text-bunker-400">
<Link href="/verify-email">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
Forgot password? Recover your account
</span>
</Link>
</div>
)}
</form>
);
};

View File

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

View File

@@ -0,0 +1,379 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
import attemptLogin from "@app/components/utilities/attemptLogin";
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { useToggle } from "@app/hooks";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils";
type Props = {
providerAuthToken: string;
email: string;
password: string;
setPassword: (password: string) => void;
};
export const PasswordStep = ({ providerAuthToken, email, password, setPassword }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const { mutateAsync: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
const { callbackPort, organizationId, hasExchangedPrivateKey } = jwtDecode(
providerAuthToken
) as any;
const handleExchange = async () => {
try {
setIsLoading(true);
const oauthLogin = await oauthTokenExchange({
email,
providerAuthToken
});
// attemptCliLogin
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
// unset provider auth token in case it was used
SecurityClient.setProviderAuthToken("");
// set JWT token
SecurityClient.setToken(oauthLogin.token);
const privateKey = await fetchMyPrivateKey();
localStorage.setItem("PRIVATE_KEY", privateKey);
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId });
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
setMfaSuccessCallback(() => finishWithOrgWorkflow);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
return;
}
if (callbackPort) {
console.log("organization id was present. new JWT token to be used in CLI:", token);
const instance = axios.create();
const payload = {
privateKey,
email,
JTWToken: token
};
await instance.post(cliUrl, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
return;
}
await navigateUserToOrg(navigate, organizationId);
};
await finishWithOrgWorkflow();
}
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page
else {
const userOrgs = await fetchOrganizations();
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(navigate);
}
}
} catch (err: any) {
setIsLoading(false);
console.error(err);
if (err.response.data.error === "User Locked") {
createNotification({
title: err.response.data.error,
text: err.response.data.message,
type: "error"
});
return;
}
createNotification({
text: "Login unsuccessful. Double-check your master password and try again.",
type: "error"
});
}
};
useEffect(() => {
if (hasExchangedPrivateKey) {
handleExchange();
}
}, []);
const [captchaToken, setCaptchaToken] = useState("");
const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false);
const captchaRef = useRef<HCaptcha>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsLoading(true);
if (callbackPort) {
// attemptCliLogin
const isCliLoginSuccessful = await attemptCliLogin({
email,
password,
providerAuthToken,
captchaToken
});
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => finishWithOrgWorkflow);
return;
}
console.log("organization id was present. new JWT token to be used in CLI:", token);
const instance = axios.create();
const payload = {
...isCliLoginSuccessful.loginResponse,
JTWToken: token
};
await instance.post(cliUrl, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
};
await finishWithOrgWorkflow();
return;
}
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page
const userOrgs = await fetchOrganizations();
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(navigate);
}
}
} else {
const loginAttempt = await attemptLogin({
email,
password,
providerAuthToken,
captchaToken
});
if (loginAttempt && loginAttempt.success) {
// case: login was successful
setIsLoading(false);
createNotification({
text: "Successfully logged in",
type: "success"
});
// case: organization ID is present from the provider auth token -- navigate directly to the org
if (organizationId) {
await navigateUserToOrg(navigate, organizationId);
}
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page
else {
const userOrgs = await fetchOrganizations();
if (userOrgs.length > 0) {
navigateToSelectOrganization();
} else {
await navigateUserToOrg(navigate);
}
}
}
}
} catch (err: any) {
setIsLoading(false);
console.error(err);
if (err.response.data.error === "User Locked") {
createNotification({
title: err.response.data.error,
text: err.response.data.message,
type: "error"
});
return;
}
if (err.response.data.error === "Captcha Required") {
setShouldShowCaptcha(true);
return;
}
createNotification({
text: "Login unsuccessful. Double-check your master password and try again.",
type: "error"
});
}
if (captchaRef.current) {
captchaRef.current.resetCaptcha();
}
setCaptchaToken("");
};
if (shouldShowMfa) {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Mfa
email={email}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()}
/>
</div>
);
}
if (hasExchangedPrivateKey) {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Spinner />
<p className="text-white opacity-80">Loading, please wait</p>
</div>
);
}
return (
<form onSubmit={handleLogin} className="mx-auto h-full w-full max-w-md px-6 pt-8">
<div className="mb-8">
<p className="mx-auto mb-4 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
What&apos;s your Infisical password?
</p>
</div>
<div className="relative mx-auto flex max-h-24 w-full min-w-[22rem] items-center justify-center rounded-lg md:max-h-28 lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your password..."
isRequired
autoComplete="current-password"
id="current-password"
className="h-12"
/>
</div>
</div>
{shouldShowCaptcha && (
<div className="mx-auto mt-4 flex w-full min-w-[22rem] items-center justify-center lg:w-1/6">
<HCaptcha
theme="dark"
sitekey={CAPTCHA_SITE_KEY}
onVerify={(token) => setCaptchaToken(token)}
ref={captchaRef}
/>
</div>
)}
<div className="mx-auto mt-4 flex w-full min-w-[22rem] items-center justify-center rounded-md text-center lg:w-1/6">
<Button
disabled={shouldShowCaptcha && captchaToken === ""}
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
isLoading={isLoading}
className="h-14"
>
{t("login.login")}
</Button>
</div>
<div className="mx-auto mt-4 flex w-max flex-col items-center text-xs text-bunker-400">
<span className="max-w-sm px-4 text-center duration-200">
Infisical Master Password serves as a decryption mechanism so that even Google is not able
to access your secrets.
</span>
<Link href="/verify-email">
<span className="mt-2 cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{t("login.forgot-password")}
</span>
</Link>
</div>
<div className="flex flex-row items-center justify-center">
<button
onClick={() => {
navigate({ to: "/login" });
}}
type="button"
className="mt-2 cursor-pointer text-xs text-bunker-400 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
>
{t("login.other-option")}
</button>
</div>
</form>
);
};

View File

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

View File

@@ -0,0 +1,82 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Input } from "@app/components/v2";
type Props = {
setStep: (step: number) => void;
type: "SAML" | "OIDC";
};
export const SSOStep = ({ setStep, type }: Props) => {
const [ssoIdentifier, setSSOIdentifier] = useState("");
const { t } = useTranslation();
const queryParams = new URLSearchParams(window.location.search);
const handleSubmission = (e: React.FormEvent) => {
e.preventDefault();
const callbackPort = queryParams.get("callback_port");
if (type === "SAML") {
window.open(
`/api/v1/sso/redirect/saml2/organizations/${ssoIdentifier}${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
} else {
window.open(
`/api/v1/sso/oidc/login?orgSlug=${ssoIdentifier}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`
);
}
window.close();
};
return (
<div className="mx-auto w-full max-w-md md:px-6">
<p className="mx-auto mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
What&apos;s your organization slug?
</p>
<form onSubmit={handleSubmission}>
<div className="relative mx-auto flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={ssoIdentifier}
onChange={(e) => setSSOIdentifier(e.target.value)}
type="text"
placeholder="acme-123"
isRequired
autoComplete="email"
id="email"
className="h-12"
/>
</div>
</div>
<div className="mx-auto mt-4 flex w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
<Button
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
className="h-14"
>
Continue with {type}
</Button>
</div>
</form>
<div className="mt-4 flex flex-row items-center justify-center">
<button
onClick={() => {
setStep(0);
}}
type="button"
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
>
{t("login.other-option")}
</button>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,5 @@
export { InitialStep } from "./InitialStep";
export { SSOStep } from "./SSOStep";
// SSO-specific step
export { PasswordStep } from "./PasswordStep";

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { LoginPage } from "./LoginPage";
export const Route = createFileRoute("/_restrict-login-signup/login/")({
component: LoginPage
});

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { Link, useSearch } from "@tanstack/react-router";
import { jwtDecode } from "jwt-decode";
import { PasswordStep } from "../LoginPage/components";
export const LoginSsoPage = () => {
const { t } = useTranslation();
const search = useSearch({ from: "/_restrict-login-signup/login/sso/" });
const token = search.token as string;
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
const { username, isUserCompleted } = jwtDecode(token) as any;
useEffect(() => {
if (isUserCompleted) {
setStep(1);
}
}, []);
const renderView = () => {
switch (step) {
case 0:
return <div />;
case 1:
return (
<PasswordStep
providerAuthToken={token}
email={username}
password={password}
setPassword={setPassword}
/>
);
default:
return <div />;
}
};
return (
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
<Link href="/">
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<div>{renderView()}</div>;
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { LoginSsoPage } from "./LoginSsoPage";
const LoginSSOQueryParamsSchema = z.object({
token: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/login/sso/")({
component: LoginSsoPage,
validateSearch: zodValidator(LoginSSOQueryParamsSchema)
});

View File

@@ -0,0 +1,391 @@
import crypto from "crypto";
import { FormEvent, useState } from "react";
import { faCheck, faX } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import jsrp from "jsrp";
import InputField from "@app/components/basic/InputField";
import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useResetPassword, useVerifyPasswordResetCode } from "@app/hooks/api";
import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
export const PasswordResetPage = () => {
const [verificationToken, setVerificationToken] = useState("");
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [backupKey, setBackupKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [newPassword, setNewPassword] = useState("");
const [backupKeyError, setBackupKeyError] = useState(false);
const [passwordErrorTooShort, setPasswordErrorTooShort] = useState(false);
const [passwordErrorTooLong, setPasswordErrorTooLong] = useState(false);
const [passwordErrorNoLetterChar, setPasswordErrorNoLetterChar] = useState(false);
const [passwordErrorNoNumOrSpecialChar, setPasswordErrorNoNumOrSpecialChar] = useState(false);
const [passwordErrorRepeatedChar, setPasswordErrorRepeatedChar] = useState(false);
const [passwordErrorEscapeChar, setPasswordErrorEscapeChar] = useState(false);
const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false);
const [passwordErrorBreached, setPasswordErrorBreached] = useState(false);
const navigate = useNavigate();
const search = useSearch({ from: ROUTE_PATHS.PasswordResetPage.id });
const {
mutateAsync: verifyPasswordResetCodeMutateAsync,
isPending: isVerifyPasswordResetLoading
} = useVerifyPasswordResetCode();
const { mutateAsync: resetPasswordMutateAsync } = useResetPassword();
const parsedUrl = search;
const token = parsedUrl.token as string;
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
// Decrypt the private key with a backup key
const getEncryptedKeyHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
const result = await getBackupEncryptedPrivateKey({ verificationToken });
setPrivateKey(
Aes256Gcm.decrypt({
ciphertext: result.encryptedPrivateKey,
iv: result.iv,
tag: result.tag,
secret: backupKey
})
);
setStep(3);
} catch (err) {
console.error(err);
setBackupKeyError(true);
}
};
// If everything is correct, reset the password
const resetPasswordHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const errorCheck = await passwordCheck({
password: newPassword,
setPasswordErrorTooShort,
setPasswordErrorTooLong,
setPasswordErrorNoLetterChar,
setPasswordErrorNoNumOrSpecialChar,
setPasswordErrorRepeatedChar,
setPasswordErrorEscapeChar,
setPasswordErrorLowEntropy,
setPasswordErrorBreached
});
if (!errorCheck) {
client.init(
{
username: email,
password: newPassword
},
async () => {
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
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);
// 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)
});
await resetPasswordMutateAsync({
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
salt: result.salt,
verifier: result.verifier,
verificationToken
});
navigate({ to: "/login" });
setLoading(false);
});
}
);
}
};
// Click a button to confirm email
const stepConfirmEmail = (
<div className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 py-6 drop-shadow-xl md:max-w-lg md:px-6">
<p className="mb-8 flex justify-center bg-gradient-to-br from-sky-400 to-primary bg-clip-text text-center text-4xl font-semibold text-transparent">
Confirm your email
</p>
<img src="/images/envelope.svg" height={262} width={410} alt="verify email" />
<div className="mx-auto mb-2 mt-4 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
<Button
onClick={async () => {
try {
const response = await verifyPasswordResetCodeMutateAsync({
email,
code: token
});
setVerificationToken(response.token);
setStep(2);
} catch (err) {
console.log("ERROR", err);
navigate({ to: "/email-not-verified" });
}
}}
isLoading={isVerifyPasswordResetLoading}
size="lg"
>
Confirm Email
</Button>
</div>
</div>
);
// Input backup key
const stepInputBackupKey = (
<form
onSubmit={getEncryptedKeyHandler}
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
>
<p className="mx-auto mb-4 flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
Enter your backup key
</p>
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
You can find it in your emergency kit. You had to download the emergency kit during
signup.
</p>
</div>
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
<InputField
label="Backup Key"
onChangeHandler={setBackupKey}
type="password"
value={backupKey}
placeholder=""
isRequired
error={backupKeyError}
errorText="Something is wrong with the backup key"
/>
</div>
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
<Button type="submit" size="lg">
Submit Backup Key
</Button>
</div>
</div>
</form>
);
// Enter new password
const stepEnterNewPassword = (
<form
onSubmit={resetPasswordHandler}
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
>
<p className="mx-auto flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
Enter new password
</p>
<div className="mt-1 flex flex-row items-center justify-center md:mx-2 md:pb-4">
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
Make sure you save it somewhere safe.
</p>
</div>
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
<InputField
label="New Password"
onChangeHandler={(password) => {
setNewPassword(password);
passwordCheck({
password,
setPasswordErrorTooShort,
setPasswordErrorTooLong,
setPasswordErrorNoLetterChar,
setPasswordErrorNoNumOrSpecialChar,
setPasswordErrorRepeatedChar,
setPasswordErrorEscapeChar,
setPasswordErrorLowEntropy,
setPasswordErrorBreached
});
}}
type="password"
value={newPassword}
isRequired
error={
passwordErrorTooShort &&
passwordErrorTooLong &&
passwordErrorNoLetterChar &&
passwordErrorNoNumOrSpecialChar &&
passwordErrorRepeatedChar &&
passwordErrorEscapeChar &&
passwordErrorLowEntropy &&
passwordErrorBreached
}
autoComplete="new-password"
id="new-password"
/>
</div>
{passwordErrorTooShort ||
passwordErrorTooLong ||
passwordErrorNoLetterChar ||
passwordErrorNoNumOrSpecialChar ||
passwordErrorRepeatedChar ||
passwordErrorEscapeChar ||
passwordErrorLowEntropy ||
passwordErrorBreached ? (
<div className="mx-2 mb-2 mt-3 flex w-full max-w-md flex-col items-start rounded-md bg-white/5 px-2 py-2">
<div className="mb-1 text-sm text-gray-400">Password should contain:</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorTooShort ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div className={`${passwordErrorTooShort ? "text-gray-400" : "text-gray-600"} text-sm`}>
at least 14 characters
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorTooLong ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div className={`${passwordErrorTooLong ? "text-gray-400" : "text-gray-600"} text-sm`}>
at most 100 characters
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorNoLetterChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorNoLetterChar ? "text-gray-400" : "text-gray-600"} text-sm`}
>
at least 1 letter character
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorNoNumOrSpecialChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${
passwordErrorNoNumOrSpecialChar ? "text-gray-400" : "text-gray-600"
} text-sm`}
>
at least 1 number or special character
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorRepeatedChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorRepeatedChar ? "text-gray-400" : "text-gray-600"} text-sm`}
>
at most 3 repeated, consecutive characters
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorEscapeChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorEscapeChar ? "text-gray-400" : "text-gray-600"} text-sm`}
>
No escape characters allowed.
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorLowEntropy ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorLowEntropy ? "text-gray-400" : "text-gray-600"} text-sm`}
>
Password contains personal info.
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorBreached ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div className={`${passwordErrorBreached ? "text-gray-400" : "text-gray-600"} text-sm`}>
Password was found in a data breach.
</div>
</div>
</div>
) : (
<div className="py-2" />
)}
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
<Button type="submit" onClick={() => setLoading(true)} size="lg" isLoading={loading}>
Submit New Password
</Button>
</div>
</div>
</form>
);
return (
<div className="flex h-screen w-full flex-col items-center justify-center bg-bunker-800">
{step === 1 && stepConfirmEmail}
{step === 2 && stepInputBackupKey}
{step === 3 && stepEnterNewPassword}
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { PasswordResetPage } from "./PasswordResetPage";
const PasswordResetPageQueryParamsSchema = z.object({
token: z.string(),
to: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/password-reset")({
component: PasswordResetPage,
validateSearch: zodValidator(PasswordResetPageQueryParamsSchema)
});

View File

@@ -0,0 +1,10 @@
import { useEffect } from "react";
export const LoginProviderErrorPage = () => {
useEffect(() => {
window.localStorage.setItem("PROVIDER_AUTH_ERROR", "err");
window.close();
}, []);
return <div />;
};

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { LoginProviderErrorPage } from "./ProviderErrorPage";
export const Route = createFileRoute("/_restrict-login-signup/login/provider/error")({
component: LoginProviderErrorPage
});

View File

@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useSearch } from "@tanstack/react-router";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { ROUTE_PATHS } from "@app/const/routes";
export const LoginProviderSuccessPage = () => {
const search = useSearch({
from: ROUTE_PATHS.ProviderSuccessPage.id
});
useEffect(() => {
SecurityClient.setProviderAuthToken(search.token);
window.close();
}, []);
return <div />;
};

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { LoginProviderSuccessPage } from "./ProviderSuccessPage";
const LoginProviderSuccessQuerySchema = z.object({
token: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/login/provider/success")({
component: LoginProviderSuccessPage,
validateSearch: zodValidator(LoginProviderSuccessQuerySchema)
});

View File

@@ -0,0 +1,32 @@
import { Helmet } from "react-helmet";
/**
* This is the page that shows up when a user's invitation
* to join a project/organization on Infisical has expired
*/
export const RequestNewInvitePage = () => {
return (
<div className="flex flex-col justify-between bg-bunker-700 md:h-screen">
<Helmet>
<title>Request a New Invite</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="mt-8 flex h-screen w-screen flex-col items-center justify-center text-bunker-200">
<p className="text-4xl text-primary-100">Oops, your invite has expired.</p>
<p className="my-4 text-lg">Ask your admin for a new one.</p>
<p className="max-w-xs px-7 text-center text-sm leading-tight text-bunker-400">
<span className="rounded-md bg-primary-500/40 px-1 text-black">Note:</span> If it still
doesn&apos;t work, please reach out to us at support@infisical.com
</p>
<div className="">
<img
src="/images/invitation-expired.svg"
height={500}
width={800}
alt="invitation expired illustration"
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { RequestNewInvitePage } from "./RequestNewInvitePage";
export const Route = createFileRoute("/_restrict-login-signup/requestnewinvite")({
component: RequestNewInvitePage
});

View File

@@ -0,0 +1,278 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
useGetUser,
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
const LoadingScreen = () => {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Spinner />
<p className="text-white opacity-80">Loading, please wait</p>
</div>
);
};
export const SelectOrganizationPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const organizations = useGetOrganizations();
const selectOrg = useSelectOrganization();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
if (organization.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
} else {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
}
window.open(url);
window.close();
return;
}
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
if (callbackPort) {
const privateKey = localStorage.getItem("PRIVATE_KEY");
let error: string | null = null;
if (!privateKey) error = "Private key not found";
if (!user?.email) error = "User email not found";
if (!token) error = "No token found";
if (error) {
createNotification({
text: error,
type: "error"
});
return;
}
const payload = {
JTWToken: token,
email: user?.email,
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
);
const handleCliRedirect = useCallback(() => {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
navigate({ to: "/login" });
}
}, []);
useEffect(() => {
if (callbackPort) {
handleCliRedirect();
}
}, [navigate]);
useEffect(() => {
if (organizations.isPending || !organizations.data) return;
// Case: User has no organizations.
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
if (organizations.data.length === 0) {
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
setIsInitialOrgCheckLoading(false);
} else {
handleSelectOrganization(organizations.data[0]);
}
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
handleSelectOrganization(defaultSelectedOrg);
}
}, [defaultSelectedOrg]);
if (
userLoading ||
!user ||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
) {
return <LoadingScreen />;
}
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link href="/">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-8 space-y-2">
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Choose your organization
</h1>
<div className="space-y-1">
<p className="text-md text-center text-gray-500">
You&lsquo;re currently logged in as <strong>{user.username}</strong>
</p>
<p className="text-md text-center text-gray-500">
Not you?{" "}
<Button variant="link" onClick={handleLogout} className="font-semibold">
Change account
</Button>
</p>
</div>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={() => handleSelectOrganization(org)}
key={org.id}
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
>
<p className="truncate transition-colors">{org.name}</p>
<FontAwesomeIcon
icon={faArrowRight}
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
/>
</div>
))
)}
</div>
</form>
</div>
)}
<div className="pb-28" />
</div>
);
};

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { SelectOrganizationPage } from "./SelectOrgPage";
export const Route = createFileRoute("/_restrict-login-signup/login/select-organization/")({
component: SelectOrganizationPage
});

View File

@@ -0,0 +1,425 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable @typescript-eslint/no-unused-vars */
import crypto from "crypto";
import { useState } from "react";
import { Helmet } from "react-helmet";
import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import jsrp from "jsrp";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import { Mfa } from "@app/components/auth/Mfa";
import InputField from "@app/components/basic/InputField";
import checkPassword from "@app/components/utilities/checks/password/checkPassword";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import {
completeAccountSignupInvite,
useSelectOrganization,
verifySignupInvite
} from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
type Errors = {
tooShort?: string;
tooLong?: string;
noLetterChar?: string;
noNumOrSpecialChar?: string;
repeatedChar?: string;
escapeChar?: string;
lowEntropy?: string;
breached?: string;
};
export const SignupInvitePage = () => {
const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [firstNameError, setFirstNameError] = useState(false);
const [lastNameError, setLastNameError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState(1);
const [, setBackupKeyError] = useState(false);
const [, setBackupKeyIssued] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const navigate = useNavigate();
const search = useSearch({ from: "/_restrict-login-signup/signupinvite" });
const parsedUrl = search;
const token = parsedUrl.token as string;
const organizationId = parsedUrl.organization_id as string;
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
const queryParams = new URLSearchParams(window.location.search);
const metadata = queryParams.get("metadata") || undefined;
const { mutateAsync: selectOrganization } = useSelectOrganization();
// Verifies if the information that the users entered (name, workspace) is there, and if the password matched the criteria.
const signupErrorCheck = async () => {
setIsLoading(true);
let errorCheck = await checkPassword({
password,
setErrors
});
if (!firstName) {
setFirstNameError(true);
errorCheck = true;
} else {
setFirstNameError(false);
}
if (!lastName) {
setLastNameError(true);
errorCheck = true;
} else {
setLastNameError(false);
}
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, result) => {
try {
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 { token: jwtToken } = await completeAccountSignupInvite({
email,
password,
firstName,
lastName,
protectedKey,
protectedKeyIV,
protectedKeyTag,
publicKey,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
salt: result.salt,
verifier: result.verifier,
tokenMetadata: metadata
});
// unset temporary signup JWT token and set JWT token
SecurityClient.setSignupToken("");
SecurityClient.setToken(jwtToken);
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
privateKey
});
const userOrgs = await fetchOrganizations();
const orgId = userOrgs[0].id;
if (!orgId) throw new Error("You are not part of any organization");
const completeSignupFlow = async () => {
const {
token: mfaToken,
isMfaEnabled,
mfaMethod
} = await selectOrganization({
organizationId: orgId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(mfaToken);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => completeSignupFlow);
return;
}
localStorage.setItem("orgData.id", orgId);
setStep(3);
};
await completeSignupFlow();
} catch (error) {
setIsLoading(false);
console.error(error);
}
});
}
);
} else {
setIsLoading(false);
}
};
// Step 4 of the sign up process (download the emergency kit pdf)
const stepConfirmEmail = (
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<p className="mb-6 flex justify-center text-center text-4xl font-semibold text-primary-100">
Confirm your email
</p>
<img src="/images/dragon-signupinvite.svg" height={262} width={410} alt="verify email" />
<div className="mx-auto mb-2 mt-10 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
<Button
onClick={async () => {
try {
const response = await verifySignupInvite({
email,
code: token,
organizationId
});
if (response) {
// user will have temp token if doesn't have an account
// then continue with account setup workflow
if (response?.token) {
SecurityClient.setSignupToken(response.token);
setStep(2);
} else {
const redirectExistingUser = async () => {
const { token: mfaToken, isMfaEnabled } = await selectOrganization({
organizationId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(mfaToken);
toggleShowMfa.on();
setMfaSuccessCallback(() => redirectExistingUser);
return;
}
// user will be redirected to dashboard
// if not logged in gets kicked out to login
await navigateUserToOrg(navigate, organizationId);
};
await redirectExistingUser();
}
}
} catch (err) {
console.error(err);
navigate({ to: "/requestnewinvite" });
}
}}
size="lg"
>
Confirm Email
</Button>
</div>
</div>
);
// Because this is the invite signup - we directly go to the last step of signup (email is already verified)
const main = (
<div className="h-7/12 mx-auto mb-32 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
<p className="mx-8 mb-6 flex justify-center bg-gradient-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16">
Almost there!
</p>
<div className="relative z-0 flex max-h-24 w-full items-center justify-end rounded-lg md:p-2">
<InputField
label="First Name"
onChangeHandler={setFirstName}
type="name"
value={firstName}
isRequired
errorText="Please input your first name."
error={firstNameError}
autoComplete="given-name"
/>
</div>
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:p-2">
<InputField
label="Last Name"
onChangeHandler={setLastName}
type="name"
value={lastName}
isRequired
errorText="Please input your last name."
error={lastNameError}
autoComplete="family-name"
/>
</div>
<div className="mt-2 flex max-h-60 w-full flex-col items-center justify-center rounded-lg md:p-2">
<InputField
label="Password"
onChangeHandler={(pass) => {
setPassword(pass);
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">Password should contain at least:</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 max-h-24 max-w-max flex-col items-center justify-center px-2 py-3 text-lg md:px-4 md:py-5">
<Button
onClick={() => {
signupErrorCheck();
}}
isLoading={isLoading}
size="lg"
>
Sign Up
</Button>
</div>
</div>
);
// Step 4 of the sign up process (download the emergency kit pdf)
const step4 = (
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 pb-6 pt-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<p className="flex justify-center bg-gradient-to-br from-white to-mineshaft-300 bg-clip-text text-center text-4xl font-semibold text-transparent">
Save your Emergency Kit
</p>
<div className="text-md mt-4 flex w-full max-w-md flex-col items-center justify-center rounded-md px-2 text-gray-400 md:mt-8">
<div>
If you get locked out of your account, your Emergency Kit is the only way to sign in.
</div>
<div className="mt-3">We recommend you download it and keep it somewhere safe.</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-row items-center rounded-md bg-white/10 p-2 text-gray-400 md:max-w-md">
<FontAwesomeIcon icon={faWarning} className="ml-2 mr-4 text-4xl" />
It contains your Secret Key which we cannot access or recover for you if you lose it.
</div>
<div className="mx-auto mt-4 flex max-h-24 max-w-max flex-col items-center justify-center px-2 py-3 text-lg md:px-4 md:py-5">
<Button
onClick={async () => {
await issueBackupKey({
email,
password,
personalName: `${firstName} ${lastName}`,
setBackupKeyError,
setBackupKeyIssued
});
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
}}
size="lg"
>
Download PDF
</Button>
</div>
</div>
);
return (
<div className="flex h-screen flex-col items-center justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>Sign Up</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={email}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()}
/>
) : (
<>
<Link to="/">
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
</Link>
{step === 1 ? stepConfirmEmail : step === 2 ? main : step4}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SignupInvitePage } from "./SignUpInvitePage";
const SignupInvitePageQueryParamsSchema = z.object({
token: z.string(),
to: z.string(),
organization_id: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/signupinvite")({
component: SignupInvitePage,
validateSearch: zodValidator(SignupInvitePageQueryParamsSchema)
});

View File

@@ -0,0 +1,171 @@
/* eslint-disable no-nested-ternary */
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
import CodeInputStep from "@app/components/auth/CodeInputStep";
import DownloadBackupPDF from "@app/components/auth/DonwloadBackupPDFStep";
import EnterEmailStep from "@app/components/auth/EnterEmailStep";
import InitialSignupStep from "@app/components/auth/InitialSignupStep";
import TeamInviteStep from "@app/components/auth/TeamInviteStep";
import UserInfoStep from "@app/components/auth/UserInfoStep";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { useServerConfig } from "@app/context";
import { useVerifySignupEmailVerificationCode } from "@app/hooks/api";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { ProjectType } from "@app/hooks/api/workspace/types";
export const SignUpPage = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [attributionSource, setAttributionSource] = useState("");
const [code, setCode] = useState("123456");
const [codeError, setCodeError] = useState(false);
const [step, setStep] = useState(1);
const navigate = useNavigate();
const { data: serverDetails } = useFetchServerStatus();
const [isSignupWithEmail, setIsSignupWithEmail] = useState(false);
const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false);
const { t } = useTranslation();
const { mutateAsync } = useVerifySignupEmailVerificationCode();
const { config } = useServerConfig();
useEffect(() => {
if (!config.allowSignUp) {
navigate({ to: "/login" });
}
}, [config.allowSignUp]);
useEffect(() => {
const tryAuth = async () => {
try {
const userOrgs = await fetchOrganizations();
navigate({
to: `/org/${userOrgs[0].id}/${ProjectType.SecretManager}/overview`
});
} catch {
console.log("Error - Not logged in yet");
}
};
tryAuth();
}, []);
/**
* Goes to the following step (out of 5) of the signup process.
* Step 1 is submitting your email
* Step 2 is Verifying your email with the code that you received
* Step 3 is asking the final info.
* Step 4 is downloading a backup pdf
* Step 5 is inviting users
*/
const incrementStep = async () => {
if (step === 1 || step === 3 || step === 4) {
setStep(step + 1);
} else if (step === 2) {
setIsCodeInputCheckLoading(true);
// Checking if the code matches the email.
try {
const { token } = await mutateAsync({ email, code });
SecurityClient.setSignupToken(token);
setStep(3);
} catch (err) {
console.error(err);
setCodeError(true);
}
setIsCodeInputCheckLoading(false);
}
};
// when email service is not configured, skip step 2 and 5
useEffect(() => {
(async () => {
if (!serverDetails?.emailConfigured && step === 2) {
incrementStep();
}
if (!serverDetails?.emailConfigured && step === 5) {
const userOrgs = await fetchOrganizations();
navigate({
to: `/org/${userOrgs[0].id}/${ProjectType.SecretManager}/overview`
});
}
})();
}, [step]);
const renderView = (registerStep: number) => {
if (isSignupWithEmail && registerStep === 1) {
return <EnterEmailStep email={email} setEmail={setEmail} incrementStep={incrementStep} />;
}
if (!isSignupWithEmail && registerStep === 1) {
return <InitialSignupStep setIsSignupWithEmail={setIsSignupWithEmail} />;
}
if (registerStep === 2) {
return (
<CodeInputStep
email={email}
incrementStep={incrementStep}
setCode={setCode}
codeError={codeError}
isCodeInputCheckLoading={isCodeInputCheckLoading}
/>
);
}
if (registerStep === 3) {
return (
<UserInfoStep
incrementStep={incrementStep}
email={email}
password={password}
setPassword={setPassword}
name={name}
setName={setName}
organizationName={organizationName}
setOrganizationName={setOrganizationName}
attributionSource={attributionSource}
setAttributionSource={setAttributionSource}
providerAuthToken={undefined}
/>
);
}
if (registerStep === 4) {
return (
<DownloadBackupPDF
incrementStep={incrementStep}
email={email}
password={password}
name={name}
/>
);
}
if (serverDetails?.emailConfigured) {
return <TeamInviteStep />;
}
return "";
};
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
<Helmet>
<title>{t("common.head-title", { title: t("signup.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("signup.og-title") as string} />
<meta name="og:description" content={t("signup.og-description") as string} />
</Helmet>
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
<form onSubmit={(e) => e.preventDefault()}>{renderView(step)}</form>
</div>
);
};

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { SignUpPage } from "./SignUpPage";
export const Route = createFileRoute("/_restrict-login-signup/signup/")({
component: SignUpPage
});

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useSearch } from "@tanstack/react-router";
import { jwtDecode } from "jwt-decode";
import { ROUTE_PATHS } from "@app/const/routes";
import { BackupPDFStep } from "./components/BackupPDFStep";
import { EmailConfirmationStep } from "./components/EmailConfirmationStep";
import { UserInfoSSOStep } from "./components/UserInfoSSOStep";
export const SignupSsoPage = () => {
const { t } = useTranslation();
const search = useSearch({ from: ROUTE_PATHS.SignUpSsoPage.id });
const token = search.token as string;
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
const {
username,
email,
organizationName,
organizationSlug,
firstName,
lastName,
authType,
isEmailVerified
} = jwtDecode(token) as any;
useEffect(() => {
if (!isEmailVerified) {
setStep(0);
} else {
setStep(1);
}
}, []);
const renderView = () => {
switch (step) {
case 0:
return (
<EmailConfirmationStep
authType={authType}
username={username}
email={email}
organizationSlug={organizationSlug}
setStep={setStep}
/>
);
case 1:
return (
<UserInfoSSOStep
username={username}
name={`${firstName} ${lastName}`}
providerOrganizationName={organizationName}
password={password}
setPassword={setPassword}
setStep={setStep}
providerAuthToken={token}
/>
);
case 2:
return (
<BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} />
);
default:
return <div />;
}
};
return (
<div className="flex min-h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
<Helmet>
<title>{t("common.head-title", { title: t("signup.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("signup.og-title") as string} />
<meta name="og:description" content={t("signup.og-description") as string} />
</Helmet>
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
<div>{renderView()}</div>;
</div>
);
};

View File

@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
import { Button } from "@app/components/v2";
import { ProjectType } from "@app/hooks/api/workspace/types";
interface DownloadBackupPDFStepProps {
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 const BackupPDFStep = ({ email, password, name }: DownloadBackupPDFStepProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
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 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="ml-2 mr-3 pt-1 text-2xl 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={async () => {
await issueBackupKey({
email,
password,
personalName: name,
setBackupKeyError: () => {},
setBackupKeyIssued: () => {}
});
navigate({
to: `/org/${localStorage.getItem("orgData.id")}/${ProjectType.SecretManager}/overview`
});
}}
size="sm"
isFullWidth
className="h-12"
colorSchema="primary"
variant="outline_bg"
>
{" "}
Download PDF{" "}
</Button>
</div>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,189 @@
// confirm email
// if same email exists, then trigger fn to merge automatically
import { useState } from "react";
import ReactCodeInput from "react-code-input";
import { useNavigate } from "@tanstack/react-router";
import Error from "@app/components/basic/Error";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api";
import { UserAliasType } from "@app/hooks/api/users/types";
type Props = {
authType?: UserAliasType;
username: string;
email: string;
organizationSlug: string;
setStep: (step: number) => void;
};
// 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;
export const EmailConfirmationStep = ({
authType,
username,
email,
organizationSlug,
setStep
}: Props) => {
const navigate = useNavigate();
const [code, setCode] = useState("");
const [codeError, setCodeError] = useState(false);
const [isResendingVerificationEmail] = useState(false);
const [isLoading] = useState(false);
const { mutateAsync: sendEmailVerificationCode } = useSendEmailVerificationCode();
const { mutateAsync: verifyEmailVerificationCode } = useVerifyEmailVerificationCode();
const checkCode = async () => {
try {
await verifyEmailVerificationCode({ username, code });
setCodeError(false);
createNotification({
text: "Successfully verified code",
type: "success"
});
switch (authType) {
case UserAliasType.SAML: {
window.open(`/api/v1/sso/redirect/saml2/organizations/${organizationSlug}`);
window.close();
break;
}
case UserAliasType.LDAP: {
navigate({ to: `/login/ldap?organizationSlug=${organizationSlug}` });
break;
}
case UserAliasType.OIDC: {
navigate({ to: `/api/v1/sso/oidc/login?orgSlug=${organizationSlug}` });
break;
}
default: {
setStep(1);
break;
}
}
} catch {
createNotification({
text: "Failed to verify code",
type: "error"
});
}
setCode("");
};
const resendCode = async () => {
try {
await sendEmailVerificationCode(username);
createNotification({
text: "Successfully resent code",
type: "success"
});
} catch {
createNotification({
text: "Failed to resend code",
type: "error"
});
}
};
return (
<div className="mx-auto h-full w-full pb-4 md:px-8">
<p className="text-md flex justify-center text-bunker-200">
We&apos;ve sent a verification code to {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="Oops. Your code is wrong. Please try again." />}
<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={checkCode}
size="sm"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{" "}
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">Don&apos;t see the code?</span>
<div className="text-md mt-2 flex flex-row text-bunker-400">
<button disabled={isLoading} onClick={resendCode} type="button">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{isResendingVerificationEmail ? "Resending..." : "Resend"}
</span>
</button>
</div>
</div>
<p className="pb-2 text-sm text-bunker-400">Make sure to check your spam inbox.</p>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,324 @@
import crypto from "crypto";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import jsrp from "jsrp";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import { Mfa } from "@app/components/auth/Mfa";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import ProjectService from "@app/services/ProjectService";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
type Props = {
setStep: (step: number) => void;
username: string;
password: string;
setPassword: (value: string) => void;
name: string;
providerOrganizationName: string;
providerAuthToken?: 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 const UserInfoSSOStep = ({
username,
name,
providerOrganizationName,
password,
setPassword,
setStep,
providerAuthToken
}: Props) => {
const [nameError, setNameError] = useState(false);
const [organizationName, setOrganizationName] = useState("");
const [organizationNameError, setOrganizationNameError] = useState(false);
const [attributionSource, setAttributionSource] = useState("");
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const { mutateAsync: selectOrganization } = useSelectOrganization();
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
useEffect(() => {
const randomPassword = crypto.randomBytes(32).toString("hex");
setPassword(randomPassword);
if (providerOrganizationName !== undefined) {
setOrganizationName(providerOrganizationName);
}
}, []);
// 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);
}
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,
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: username,
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("");
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
privateKey
});
const userOrgs = await fetchOrganizations();
const orgId = userOrgs[0]?.id;
const completeSignupFlow = async () => {
try {
const { isMfaEnabled, token, mfaMethod } = await selectOrganization({
organizationId: orgId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => completeSignupFlow);
return;
}
// only create example project if not joining existing org
if (!providerOrganizationName) {
const project = await ProjectService.initProject({
projectName: "Example Project"
});
localStorage.setItem("projectData.id", project.id);
}
localStorage.setItem("orgData.id", orgId);
setStep(2);
} catch (error) {
setIsLoading(false);
console.error(error);
}
};
await completeSignupFlow();
} catch (error) {
setIsLoading(false);
console.error(error);
}
});
}
);
} else {
setIsLoading(false);
}
};
useEffect(() => {
if (password && providerOrganizationName) {
signupErrorCheck();
}
}, [providerOrganizationName, password]);
if (shouldShowMfa) {
return (
<Mfa
hideLogo
email={username}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()}
/>
);
}
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-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"
value={name}
disabled
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>
{providerOrganizationName === undefined && (
<div className="relative z-0 flex 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"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
isRequired
className="h-12"
maxLength={64}
disabled
/>
{organizationNameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">
Please, specify your organization name
</p>
)}
</div>
)}
{providerOrganizationName === undefined && (
<div className="relative z-0 flex 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="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-12"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={isLoading}
>
{" "}
{String(t("signup.signup"))}{" "}
</Button>
</div>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SignupSsoPage } from "./SignUpSsoPage";
const SignupSSOPageQueryParamsSchema = z.object({
token: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/signup/sso/")({
component: SignupSsoPage,
validateSearch: zodValidator(SignupSSOPageQueryParamsSchema)
});

View File

@@ -0,0 +1,111 @@
import { FormEvent, useState } from "react";
import { Helmet } from "react-helmet";
import { Link } from "@tanstack/react-router";
import InputField from "@app/components/basic/InputField";
import { Button, EmailServiceSetupModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useSendPasswordResetEmail } from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
export const VerifyEmailPage = () => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState("");
const [step, setStep] = useState(1);
const { data: serverDetails } = useFetchServerStatus();
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
const { mutateAsync } = useSendPasswordResetEmail();
/**
* This function sends the verification email and forwards a user to the next step.
*/
const sendVerificationEmail = async () => {
if (email) {
await mutateAsync({ email });
setStep(2);
}
};
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
if (serverDetails?.emailConfigured) {
sendVerificationEmail();
} else {
handlePopUpOpen("setUpEmail");
setLoading(false);
}
};
return (
<div className="flex h-screen flex-col justify-start bg-bunker-800 px-6">
<Helmet>
<title>Login</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="Verify your email in Infisical" />
<meta
name="og:description"
content="Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files."
/>
</Helmet>
<Link to="/">
<div className="mb-8 mt-20 flex cursor-pointer justify-center">
<img src="/images/biglogo.png" height={90} width={120} alt="long logo" />
</div>
</Link>
{step === 1 && (
<form
onSubmit={onSubmit}
className="h-7/12 mx-auto w-full max-w-md rounded-xl bg-bunker px-6 py-4 pt-8 drop-shadow-xl"
>
<p className="mx-auto mb-6 flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
Forgot your password?
</p>
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
<p className="flex w-max justify-center text-sm text-gray-400">
You will need your emergency kit. Enter your email to start account recovery.
</p>
</div>
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
<InputField
label="Email"
onChangeHandler={setEmail}
type="email"
value={email}
placeholder=""
isRequired
autoComplete="username"
/>
</div>
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
<Button type="submit" size="lg" onClick={() => {}} isLoading={loading}>
Continue
</Button>
</div>
</div>
</form>
)}
{step === 2 && (
<div className="h-7/12 mx-auto w-full max-w-md rounded-xl bg-bunker px-6 py-4 pt-8 drop-shadow-xl">
<p className="mx-auto mb-6 flex w-max justify-center text-xl font-semibold text-bunker-100 md:text-2xl">
Look for an email in your inbox.
</p>
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
<p className="flex w-max justify-center text-center text-sm text-gray-400">
An email with instructions has been sent to {email}.
</p>
</div>
</div>
)}
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
</div>
);
};

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { VerifyEmailPage } from "./VerifyEmailPage";
export const Route = createFileRoute("/_restrict-login-signup/verify-email")({
component: VerifyEmailPage
});

View File

@@ -0,0 +1,37 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { userKeys } from "@app/hooks/api";
import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { fetchUserDetails } from "@app/hooks/api/users/queries";
export const Route = createFileRoute("/_authenticate")({
beforeLoad: async ({ context }) => {
const data = await context.queryClient
.ensureQueryData({
queryKey: authKeys.getAuthToken,
queryFn: fetchAuthToken
})
.catch(() => {
createNotification({
type: "error",
title: "Access Restricted",
text: " You need to log in to access this page. Please log in to continue."
});
throw redirect({
to: "/login"
});
});
if (!data.organizationId) {
throw redirect({ to: "/login/select-organization" });
}
const user = await context.queryClient.ensureQueryData({
queryKey: userKeys.getUser,
queryFn: fetchUserDetails
});
return { organizationId: data.organizationId as string, isAuthenticated: true, user };
}
});

View File

@@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router";
import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries";
import { fetchUserOrgPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries";
// Route context to fill in organization's data like details, subscription etc
export const Route = createFileRoute("/_authenticate/_ctx-org-details")({
beforeLoad: async ({ context }) => {
const organizationId = context.organizationId!;
await context.queryClient.ensureQueryData({
queryKey: organizationKeys.getOrgById(organizationId),
queryFn: () => fetchOrganizationById(organizationId)
});
await context.queryClient.ensureQueryData({
queryKey: subscriptionQueryKeys.getOrgSubsription(organizationId),
queryFn: () => fetchOrgSubscription(organizationId)
});
await context.queryClient.ensureQueryData({
queryKey: roleQueryKeys.getUserOrgPermissions({ orgId: organizationId }),
queryFn: () => fetchUserOrgPermissions({ orgId: organizationId })
});
return { organizationId };
}
});

View File

@@ -0,0 +1,27 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { setAuthToken } from "@app/hooks/api/reactQuery";
import { ProjectType } from "@app/hooks/api/workspace/types";
export const Route = createFileRoute("/_restrict-login-signup")({
beforeLoad: async ({ context }) => {
const data = await context.queryClient
.fetchQuery({
queryKey: authKeys.getAuthToken,
queryFn: fetchAuthToken
})
.catch(() => {
return null;
});
if (!data) return;
setAuthToken(data.token);
if (!data.organizationId) {
throw redirect({ to: "/login/select-organization" });
}
throw redirect({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
}
});

View File

@@ -0,0 +1,39 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { NotificationContainer } from "@app/components/notifications";
import { TooltipProvider } from "@app/components/v2";
import { adminQueryKeys, fetchServerConfig } from "@app/hooks/api/admin/queries";
import { TServerConfig } from "@app/hooks/api/admin/types";
import { queryClient } from "@app/hooks/api/reactQuery";
type TRouterContext = {
serverConfig: TServerConfig | null;
queryClient: QueryClient;
};
const RootPage = () => {
return (
<>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Outlet />
</TooltipProvider>
</QueryClientProvider>
<NotificationContainer />
<TanStackRouterDevtools />
</>
);
};
export const Route = createRootRouteWithContext<TRouterContext>()({
component: RootPage,
beforeLoad: async ({ context }) => {
const serverConfig = await context.queryClient.ensureQueryData({
queryKey: adminQueryKeys.serverConfig(),
queryFn: fetchServerConfig
});
return { serverConfig };
}
});

View File

@@ -0,0 +1,25 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PersonalTabGroup } from "./components/PersonalTabGroup";
export const PersonalSettingsPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: t("settings.personal.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 px-6 text-white">
<div className="w-full max-w-6xl">
<div className="mb-6 mt-6">
<p className="text-3xl font-semibold text-gray-200">{t("settings.personal.title")}</p>
</div>
<PersonalTabGroup />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Tooltip } from "@app/components/v2";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddAPIKeyModal } from "./AddAPIKeyModal";
import { APIKeyTable } from "./APIKeyTable";
export const APIKeySection = () => {
const { t } = useTranslation();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addAPIKey"] as const);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">
{t("settings.personal.api-keys.title")}
</p>
<Tooltip content="API Keys are deprecated and will be removed in the future.">
<Button
isDisabled
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addAPIKey")}
>
Add API Key
</Button>
</Tooltip>
</div>
<APIKeyTable />
<AddAPIKeyModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</div>
);
};

View File

@@ -0,0 +1,91 @@
import { faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useDeleteAPIKey, useGetMyAPIKeys } from "@app/hooks/api";
export const APIKeyTable = () => {
const { data, isPending } = useGetMyAPIKeys();
const { mutateAsync } = useDeleteAPIKey();
const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => {
try {
await mutateAsync(apiKeyDataId);
createNotification({
text: "Successfully deleted API key",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete API key",
type: "error"
});
}
};
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="flex-1">Name</Th>
<Th className="flex-1">Last active</Th>
<Th className="flex-1">Created</Th>
<Th className="flex-1">Expiration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="api-keys" />}
{!isPending &&
data &&
data.length > 0 &&
data.map(({ id, name, createdAt, expiresAt, lastUsed }) => {
return (
<Tr className="h-10" key={`api-key-${id}`}>
<Td>{name}</Td>
<Td>{format(new Date(lastUsed), "yyyy-MM-dd")}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td>{format(new Date(expiresAt), "yyyy-MM-dd")}</Td>
<Td>
<IconButton
onClick={async () => {
await handleDeleteAPIKeyDataClick(id);
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
})}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No API Keys on file" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};

View File

@@ -0,0 +1,185 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useCreateAPIKey } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const expirations = [
{ label: "1 day", value: "1d" },
{ label: "7 days", value: "7d" },
{ label: "1 month", value: "1mo" },
{ label: "6 months", value: "6mo" },
{ label: "12 months", value: "12mo" }
];
const expirationMapping: { [key: string]: number } = {
"1d": 86400,
"7d": 604800,
"1mo": 2592000,
"6mo": 15552000,
"12mo": 31104000
};
const schema = z
.object({
name: z.string(),
expiresIn: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["addAPIKey"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addAPIKey"]>, state?: boolean) => void;
};
export const AddAPIKeyModal = ({ popUp, handlePopUpToggle }: Props) => {
const [newAPIKey, setNewAPIKey] = useState("");
const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false);
const { mutateAsync, isPending } = useCreateAPIKey();
const { control, handleSubmit, reset } = useForm<FormData>({
resolver: zodResolver(schema)
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isAPIKeyCopied) {
timer = setTimeout(() => setIsAPIKeyCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [setIsAPIKeyCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newAPIKey);
setIsAPIKeyCopied.on();
};
const onFormSubmit = async ({ name, expiresIn }: FormData) => {
try {
const { apiKey } = await mutateAsync({
name,
expiresIn: expirationMapping[expiresIn]
});
setNewAPIKey(apiKey);
createNotification({
text: "Successfully created API key",
type: "success"
});
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create API key",
type: "error"
});
}
};
const hasAPIKey = Boolean(newAPIKey);
return (
<Modal
isOpen={popUp?.addAPIKey?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addAPIKey", isOpen);
reset();
setNewAPIKey("");
}}
>
<ModalContent title="Create API Key">
{!hasAPIKey ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="My API Key" />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue="6mo"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirations.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isPending}
isDisabled={isPending}
>
Add
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("addAPIKey", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newAPIKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isAPIKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
);
};

View File

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

View File

@@ -0,0 +1,132 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { faGithub, faGitlab, faGoogle, IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faEnvelope } from "@fortawesome/free-regular-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 { Switch } from "@app/components/v2";
import { useServerConfig, useUser } from "@app/context";
import { useUpdateUserAuthMethods } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
import { AuthMethod } from "@app/hooks/api/users/types";
interface AuthMethodOption {
label: string;
value: AuthMethod;
icon: IconDefinition;
loginMethod: LoginMethod;
}
const authMethodOpts: AuthMethodOption[] = [
{ label: "Email", value: AuthMethod.EMAIL, icon: faEnvelope, loginMethod: LoginMethod.EMAIL },
{ label: "Google", value: AuthMethod.GOOGLE, icon: faGoogle, loginMethod: LoginMethod.GOOGLE },
{ label: "GitHub", value: AuthMethod.GITHUB, icon: faGithub, loginMethod: LoginMethod.GITHUB },
{ label: "GitLab", value: AuthMethod.GITLAB, icon: faGitlab, loginMethod: LoginMethod.GITLAB }
];
const schema = z.object({
authMethods: z.nativeEnum(AuthMethod).array()
});
export type FormData = z.infer<typeof schema>;
export const AuthMethodSection = () => {
const { user } = useUser();
const { config } = useServerConfig();
const { mutateAsync } = useUpdateUserAuthMethods();
const { reset, setValue, watch } = useForm<FormData>({
defaultValues: {
authMethods: user.authMethods
},
resolver: zodResolver(schema)
});
const authMethods = watch("authMethods");
useEffect(() => {
if (user) {
reset({
authMethods: user.authMethods
});
}
}, [user]);
const onAuthMethodToggle = async (value: boolean, authMethodOpt: AuthMethodOption) => {
const newAuthMethods = value
? [...authMethods, authMethodOpt.value]
: authMethods.filter((auth) => auth !== authMethodOpt.value);
if (value) {
const newUser = await mutateAsync({
authMethods: newAuthMethods
});
setValue("authMethods", newUser.authMethods);
createNotification({
text: "Successfully enabled authentication method",
type: "success"
});
return;
}
if (newAuthMethods.length === 0) {
createNotification({
text: "You must keep at least 1 authentication method enabled",
type: "error"
});
return;
}
const newUser = await mutateAsync({
authMethods: newAuthMethods
});
setValue("authMethods", newUser.authMethods);
createNotification({
text: "Successfully disabled authentication method",
type: "success"
});
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">
Authentication methods
</h2>
<p className="mb-4 text-gray-400">
By enabling a SSO provider, you are allowing an account with that provider which uses the
same email address as your existing Infisical account to be able to log in to Infisical.
</p>
<div className="mb-4">
{user &&
authMethodOpts.map((authMethodOpt) => {
// only filter when enabledLoginMethods is explicitly configured by admin
if (
config.enabledLoginMethods &&
!config.enabledLoginMethods.includes(authMethodOpt.loginMethod)
) {
return null;
}
return (
<div className="flex items-center p-4" key={`auth-method-${authMethodOpt.value}`}>
<div className="flex items-center">
<FontAwesomeIcon icon={authMethodOpt.icon} className="mr-4" />
</div>
<Switch
id={`enable-${authMethodOpt.value}-auth`}
onCheckedChange={(value) => onAuthMethodToggle(value, authMethodOpt)}
isChecked={authMethods?.includes(authMethodOpt.value) ?? false}
>
<p className="mr-4 w-12">{authMethodOpt.label}</p>
</Switch>
</div>
);
})}
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,147 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import attemptChangePassword from "@app/components/utilities/attemptChangePassword";
import checkPassword from "@app/components/utilities/checks/password/checkPassword";
import { Button, FormControl, Input } from "@app/components/v2";
import { useUser } from "@app/context";
type Errors = {
tooShort?: string;
tooLong?: string;
noLetterChar?: string;
noNumOrSpecialChar?: string;
repeatedChar?: string;
escapeChar?: string;
lowEntropy?: string;
breached?: string;
};
const schema = z
.object({
oldPassword: z.string(),
newPassword: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
export const ChangePasswordSection = () => {
const { t } = useTranslation();
const { user } = useUser();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
oldPassword: "",
newPassword: ""
},
resolver: zodResolver(schema)
});
const [errors, setErrors] = useState<Errors>({});
const [isLoading, setIsLoading] = useState(false);
const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => {
try {
const errorCheck = await checkPassword({
password: newPassword,
setErrors
});
if (errorCheck) return;
setIsLoading(true);
await attemptChangePassword({
email: user.username,
currentPassword: oldPassword,
newPassword
});
setIsLoading(false);
createNotification({
text: "Successfully changed password",
type: "success"
});
reset();
window.location.href = "/login";
} catch (err) {
console.error(err);
setIsLoading(false);
createNotification({
text: "Failed to change password",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Change password</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="Old password"
type="password"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="oldPassword"
/>
</div>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="New password"
type="password"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="newPassword"
/>
</div>
{Object.keys(errors).length > 0 && (
<div className="my-4 flex max-w-md 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>
)}
<Button type="submit" colorSchema="secondary" isLoading={isLoading} isDisabled={isLoading}>
Save
</Button>
</form>
);
};

View File

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

View File

@@ -0,0 +1,59 @@
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useDeleteMe } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
export const DeleteAccountSection = () => {
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteAccount"
] as const);
const { mutateAsync: deleteUserMutateAsync, isPending } = useDeleteMe();
const handleDeleteAccountSubmit = async () => {
try {
await deleteUserMutateAsync();
createNotification({
text: "Successfully deleted account",
type: "success"
});
navigate({ to: "/login" });
handlePopUpClose("deleteAccount");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete account",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
<Button
isLoading={isPending}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("deleteAccount")}
>
Delete my account
</Button>
<DeleteActionModal
isOpen={popUp.deleteAccount.isOpen}
title="Are you sure want to delete your account?"
subTitle="Permanently remove this account and all of its data. This action is not reversible, so please be careful."
onChange={(isOpen) => handlePopUpToggle("deleteAccount", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleDeleteAccountSubmit}
/>
</div>
);
};

View File

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

View File

@@ -0,0 +1,80 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
import { Button, FormControl, Input } from "@app/components/v2";
import { useUser } from "@app/context";
const schema = z
.object({
password: z.string().describe("Password is required")
})
.required();
export type FormData = z.infer<typeof schema>;
export const EmergencyKitSection = () => {
const { user } = useUser();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
password: ""
},
resolver: zodResolver(schema)
});
const onFormSubmit = ({ password }: FormData) => {
try {
if (!user?.email) return;
issueBackupKey({
email: user.email,
password,
personalName: `${user.firstName} ${user.lastName}`,
setBackupKeyError: () => {},
setBackupKeyIssued: () => {}
});
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to download emergency kit",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<h2 className="flex-1 text-xl font-semibold text-mineshaft-100">Emergency Kit</h2>
<p className="mb-8 text-gray-400">
The kit contains information you can use to recover your account.
</p>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="Password"
type="password"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="password"
/>
</div>
<Button type="submit" colorSchema="secondary" isLoading={false}>
Save
</Button>
</form>
);
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import { useGetUser } from "@app/hooks/api";
import { AuthMethod } from "@app/hooks/api/users/types";
import { AuthMethodSection } from "../AuthMethodSection";
import { ChangePasswordSection } from "../ChangePasswordSection";
import { MFASection } from "../SecuritySection";
export const PersonalAuthTab = () => {
const { data: user } = useGetUser();
return (
<div>
{user && !user.authMethods.includes(AuthMethod.LDAP) && (
<>
<MFASection />
<AuthMethodSection />
</>
)}
<ChangePasswordSection />
</div>
);
};

View File

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

View File

@@ -0,0 +1,15 @@
import { DeleteAccountSection } from "../DeleteAccountSection";
import { EmergencyKitSection } from "../EmergencyKitSection";
import { SessionsSection } from "../SessionsSection";
import { UserNameSection } from "../UserNameSection";
export const PersonalGeneralTab = () => {
return (
<div>
<UserNameSection />
<SessionsSection />
<EmergencyKitSection />
<DeleteAccountSection />
</div>
);
};

View File

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

View File

@@ -0,0 +1,67 @@
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { PersonalAPIKeyTab } from "../PersonalAPIKeyTab";
import { PersonalAuthTab } from "../PersonalAuthTab";
import { PersonalGeneralTab } from "../PersonalGeneralTab";
const tabs = [
{ name: "General", key: "tab-account-general" },
{ name: "Authentication", key: "tab-account-auth" },
{ name: "API Keys", key: "tab-account-api-keys" }
];
export const PersonalTabGroup = () => {
return (
<Tabs defaultValue={tabs[0].key}>
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key}>{tab.name}</Tab>
))}
</TabList>
<TabPanel value={tabs[0].key}>
<PersonalGeneralTab />
</TabPanel>
<TabPanel value={tabs[1].key}>
<PersonalAuthTab />
</TabPanel>
<TabPanel value={tabs[2].key}>
<div className="space-y-3">
<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">
<FontAwesomeIcon icon={faWarning} className="pr-6 text-4xl text-white/80" />
<div className="flex w-full flex-col text-sm">
<span className="mb-4 text-lg font-semibold">Deprecation Notice</span>
<p>
API Keys are being deprecated in favor of Machine Identities.
<br />
They will be removed in the future in accordance with the deprecation notice and
timeline stated{" "}
<a
href="https://infisical.com/blog/deprecating-api-keys"
target="_blank"
className="font-semibold text-primary-400"
rel="noreferrer"
>
here
</a>
.
<br />
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
className="font-semibold text-primary-400"
rel="noreferrer"
>
Learn more about Machine Identities
</a>
</p>
</div>
</div>
<PersonalAPIKeyTab />
</div>
</TabPanel>
</Tabs>
);
};

View File

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

View File

@@ -0,0 +1,232 @@
import { useQueryClient } from "@tanstack/react-query";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import { createNotification } from "@app/components/notifications";
import {
Button,
ContentLoader,
DeleteActionModal,
EmailServiceSetupModal,
FormControl,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetUser, userKeys, useUpdateUserMfa } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import {
useCreateNewTotpRecoveryCodes,
useDeleteUserTotpConfiguration
} from "@app/hooks/api/users/mutation";
import { useGetUserTotpConfiguration } from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => {
const { data: user } = useGetUser();
const { mutateAsync } = useUpdateUserMfa();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail",
"deleteTotpConfig"
] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration, isPending: isTotpConfigurationLoading } =
useGetUserTotpConfiguration();
const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration();
const { mutateAsync: createTotpRecoveryCodes } = useCreateNewTotpRecoveryCodes();
const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus();
const handleTotpDeletion = async () => {
try {
await deleteTotpConfiguration();
createNotification({
text: "Successfully deleted mobile authenticator",
type: "success"
});
handlePopUpClose("deleteTotpConfig");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete mobile authenticator";
createNotification({
text,
type: "error"
});
}
};
const handleGenerateMoreRecoveryCodes = async () => {
try {
await createTotpRecoveryCodes();
createNotification({
text: "Successfully generated new recovery codes",
type: "success"
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to generate new recovery codes";
createNotification({
text,
type: "error"
});
}
};
const updateSelectedMfa = async (mfaMethod: MfaMethod) => {
try {
if (!user) return;
await mutateAsync({
selectedMfaMethod: mfaMethod
});
createNotification({
text: "Successfully updated selected 2FA method",
type: "success"
});
} catch (err) {
createNotification({
text: "Something went wrong while updating selected 2FA method.",
type: "error"
});
console.error(err);
}
};
const toggleMfa = async (state: boolean) => {
try {
if (!user) return;
if (user.authMethods.includes(AuthMethod.LDAP)) {
createNotification({
text: "Two-factor authentication is not available for LDAP users.",
type: "error"
});
return;
}
const newUser = await mutateAsync({
isMfaEnabled: state
});
createNotification({
text: `${
newUser.isMfaEnabled
? "Successfully turned on two-factor authentication."
: "Successfully turned off two-factor authentication."
}`,
type: "success"
});
} catch (err) {
createNotification({
text: "Something went wrong while toggling the two-factor authentication.",
type: "error"
});
console.error(err);
}
};
return (
<>
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p>
{user && (
<Switch
className="data-[state=checked]:bg-primary"
id="isTwoFAEnabled"
isChecked={user?.isMfaEnabled}
onCheckedChange={(state) => {
if (serverDetails?.emailConfigured) {
toggleMfa(state as boolean);
} else {
handlePopUpOpen("setUpEmail");
}
}}
>
Enable 2-factor authentication
</Switch>
)}
{user?.isMfaEnabled && (
<FormControl label="Selected 2FA method" className="mt-3">
<Select
className="min-w-[20rem] border border-mineshaft-500"
onValueChange={updateSelectedMfa}
defaultValue={user.selectedMfaMethod ?? MfaMethod.EMAIL}
>
<SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
Email
</SelectItem>
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
Mobile Authenticator
</SelectItem>
</Select>
</FormControl>
)}
<div className="mt-8 text-lg font-semibold text-mineshaft-100">Mobile Authenticator</div>
{isTotpConfigurationLoading ? (
<ContentLoader />
) : (
<div>
{totpConfiguration?.isVerified ? (
<div className="mt-2">
<div className="flex flex-row gap-2">
<Button colorSchema="secondary" onClick={setShouldShowRecoveryCodes.toggle}>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button colorSchema="secondary" onClick={handleGenerateMoreRecoveryCodes}>
Generate more codes
</Button>
<Button colorSchema="danger" onClick={() => handlePopUpOpen("deleteTotpConfig")}>
Delete
</Button>
</div>
{shouldShowRecoveryCodes && totpConfiguration.recoveryCodes && (
<div className="mt-4 bg-mineshaft-600 p-4">
{totpConfiguration.recoveryCodes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
)}
</div>
) : (
<>
<div className="text-sm text-gray-400">
For added security, you can configure a mobile authenticator and set it as your
selected 2FA method.
</div>
<div className="ml-6 mt-6 flex min-w-full">
<TotpRegistration
onComplete={async () => {
await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration });
}}
/>
</div>
</>
)}
</div>
)}
</div>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
<DeleteActionModal
isOpen={popUp.deleteTotpConfig.isOpen}
title="Are you sure want to delete the configured authenticator?"
subTitle="This action is irreversible. You’ll have to go through the setup process to enable it again."
onChange={(isOpen) => handlePopUpToggle("deleteTotpConfig", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleTotpDeletion}
/>
</>
);
};

View File

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

View File

@@ -0,0 +1,41 @@
import { faBan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
import { useRevokeMySessions } from "@app/hooks/api";
import { SessionsTable } from "./SessionsTable";
export const SessionsSection = () => {
const { mutateAsync } = useRevokeMySessions();
const onRevokeAllSessionsClick = async () => {
try {
await mutateAsync();
window.location.href = "/login";
} catch (err) {
console.error(err);
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex justify-between">
<h2 className="flex-1 text-xl font-semibold text-mineshaft-100">Sessions</h2>
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faBan} />}
onClick={onRevokeAllSessionsClick}
>
Revoke all
</Button>
</div>
<p className="mb-8 text-gray-400">
Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your
account out all active sessions across all browsers and CLIs.
</p>
<SessionsTable />
</div>
);
};

View File

@@ -0,0 +1,67 @@
import { faServer } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useGetMySessions } from "@app/hooks/api";
export const SessionsTable = () => {
const { data, isPending } = useGetMySessions();
const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const formattedDate = `${day}/${month}/${year}`;
return formattedDate;
};
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Created</Th>
<Th>Last active</Th>
<Th>IP address</Th>
<Th>Device</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="sesssions" />}
{!isPending &&
data &&
data.length > 0 &&
data.map(({ id, createdAt, lastUsed, ip, userAgent }) => {
return (
<Tr className="h-10" key={`session-${id}`}>
<Td>{formatDate(createdAt)}</Td>
<Td>{formatDate(lastUsed)}</Td>
<Td>{ip}</Td>
<Td>{userAgent}</Td>
</Tr>
);
})}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState title="No sessions on file" icon={faServer} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};

View File

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

View File

@@ -0,0 +1,71 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useUser } from "@app/context";
import { useRenameUser } from "@app/hooks/api/users/queries";
const formSchema = z.object({
name: z.string().describe("User Name")
});
type FormData = z.infer<typeof formSchema>;
export const UserNameSection = (): JSX.Element => {
const { user } = useUser();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
const { mutateAsync, isPending } = useRenameUser();
useEffect(() => {
if (user) {
reset({ name: `${user?.firstName}${user?.lastName && " "}${user?.lastName}` });
}
}, [user]);
const onFormSubmit = async ({ name }: FormData) => {
try {
if (!user?.id) return;
if (name === "") return;
await mutateAsync({ newName: name });
createNotification({
text: "Successfully renamed user",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to rename user",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Name</p>
<div className="mb-2 max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder={`${user?.firstName} ${user?.lastName}`} {...field} />
</FormControl>
)}
control={control}
name="name"
/>
</div>
<Button isLoading={isPending} colorSchema="primary" variant="outline_bg" type="submit">
Save
</Button>
</form>
);
};

View File

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

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { PersonalSettingsPage } from "./PersonalSettingsPage";
export const Route = createFileRoute("/_authenticate/personal-settings/_personal-settings-layout/")(
{
component: PersonalSettingsPage
}
);

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { PersonalSettingsLayout } from "@app/layouts/PersonalSettingsLayout";
export const Route = createFileRoute("/_authenticate/personal-settings/_personal-settings-layout")({
component: PersonalSettingsLayout
});

View File

@@ -0,0 +1,3 @@
import { rootRoute } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("root.tsx", []);

View File

@@ -1,6 +1,6 @@
{
"autoCodeSplitting": true,
"routeFileIgnorePrefix": "-",
"routesDirectory": "./src/routes",
"routesDirectory": "./src/pages",
"generatedRouteTree": "./src/routeTree.gen.ts"
}

View File

@@ -1,10 +1,23 @@
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
import { defineConfig, PluginOption } from "vite";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
import tsconfigPaths from "vite-tsconfig-paths";
const virtualRouteFileChangeReloadPlugin: PluginOption = {
name: "watch-config-restart",
configureServer(server) {
server.watcher.add("./src/routes.ts");
server.watcher.on("change", (path) => {
if (path.endsWith("src/routes.ts")) {
console.log("Virtual route changed");
server.restart();
}
});
}
};
// https://vite.dev/config/
export default defineConfig({
@@ -27,7 +40,10 @@ export default defineConfig({
}),
wasm(),
topLevelAwait(),
TanStackRouterVite(),
react()
TanStackRouterVite({
virtualRouteConfig: "./src/routes.ts"
}),
react(),
virtualRouteFileChangeReloadPlugin
]
});