mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: changed to virtual route for auth and personal settings
This commit is contained in:
15
frontend-v2/src/const/routes.ts
Normal file
15
frontend-v2/src/const/routes.ts
Normal 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")
|
||||
});
|
||||
@@ -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'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've successfully logged in to the Infisical CLI
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
frontend-v2/src/pages/auth/CliRedirectPage/route.tsx
Normal file
7
frontend-v2/src/pages/auth/CliRedirectPage/route.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { CliRedirectPage } from "./CliRedirectPage";
|
||||
|
||||
export const Route = createFileRoute("/_authenticate/cli-redirect")({
|
||||
component: CliRedirectPage
|
||||
});
|
||||
@@ -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't work, please reach out to us at support@infisical.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
154
frontend-v2/src/pages/auth/LoginLdapPage/LoginLDAPPage.tsx
Normal file
154
frontend-v2/src/pages/auth/LoginLdapPage/LoginLDAPPage.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
||||
7
frontend-v2/src/pages/auth/LoginLdapPage/route.tsx
Normal file
7
frontend-v2/src/pages/auth/LoginLdapPage/route.tsx
Normal 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
|
||||
});
|
||||
51
frontend-v2/src/pages/auth/LoginPage/Login.utils.tsx
Normal file
51
frontend-v2/src/pages/auth/LoginPage/Login.utils.tsx
Normal 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 };
|
||||
};
|
||||
77
frontend-v2/src/pages/auth/LoginPage/LoginPage.tsx
Normal file
77
frontend-v2/src/pages/auth/LoginPage/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { InitialStep } from "./InitialStep";
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SSOStep } from "./SSOStep";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { InitialStep } from "./InitialStep";
|
||||
export { SSOStep } from "./SSOStep";
|
||||
|
||||
// SSO-specific step
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
7
frontend-v2/src/pages/auth/LoginPage/route.tsx
Normal file
7
frontend-v2/src/pages/auth/LoginPage/route.tsx
Normal 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
|
||||
});
|
||||
59
frontend-v2/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx
Normal file
59
frontend-v2/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
frontend-v2/src/pages/auth/LoginSsoPage/route.tsx
Normal file
14
frontend-v2/src/pages/auth/LoginSsoPage/route.tsx
Normal 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)
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
15
frontend-v2/src/pages/auth/PasswordResetPage/route.tsx
Normal file
15
frontend-v2/src/pages/auth/PasswordResetPage/route.tsx
Normal 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)
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const LoginProviderErrorPage = () => {
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("PROVIDER_AUTH_ERROR", "err");
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
return <div />;
|
||||
};
|
||||
7
frontend-v2/src/pages/auth/ProviderErrorPage/route.tsx
Normal file
7
frontend-v2/src/pages/auth/ProviderErrorPage/route.tsx
Normal 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
|
||||
});
|
||||
@@ -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 />;
|
||||
};
|
||||
14
frontend-v2/src/pages/auth/ProviderSuccessPage/route.tsx
Normal file
14
frontend-v2/src/pages/auth/ProviderSuccessPage/route.tsx
Normal 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)
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { RequestNewInvitePage } from "./RequestNewInvitePage";
|
||||
|
||||
export const Route = createFileRoute("/_restrict-login-signup/requestnewinvite")({
|
||||
component: RequestNewInvitePage
|
||||
});
|
||||
278
frontend-v2/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx
Normal file
278
frontend-v2/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx
Normal 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‘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>
|
||||
);
|
||||
};
|
||||
7
frontend-v2/src/pages/auth/SelectOrgPage/route.tsx
Normal file
7
frontend-v2/src/pages/auth/SelectOrgPage/route.tsx
Normal 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
|
||||
});
|
||||
425
frontend-v2/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx
Normal file
425
frontend-v2/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
frontend-v2/src/pages/auth/SignUpInvitePage/route.tsx
Normal file
16
frontend-v2/src/pages/auth/SignUpInvitePage/route.tsx
Normal 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)
|
||||
});
|
||||
171
frontend-v2/src/pages/auth/SignUpPage/SignUpPage.tsx
Normal file
171
frontend-v2/src/pages/auth/SignUpPage/SignUpPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
7
frontend-v2/src/pages/auth/SignUpPage/route.tsx
Normal file
7
frontend-v2/src/pages/auth/SignUpPage/route.tsx
Normal 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
|
||||
});
|
||||
88
frontend-v2/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx
Normal file
88
frontend-v2/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BackupPDFStep } from "./BackupPDFStep";
|
||||
@@ -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'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'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { EmailConfirmationStep } from "./EmailConfirmationStep";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { UserInfoSSOStep } from "./UserInfoSSOStep";
|
||||
14
frontend-v2/src/pages/auth/SignUpSsoPage/route.tsx
Normal file
14
frontend-v2/src/pages/auth/SignUpSsoPage/route.tsx
Normal 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)
|
||||
});
|
||||
111
frontend-v2/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx
Normal file
111
frontend-v2/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
6
frontend-v2/src/pages/auth/VerifyEmailPage/route.tsx
Normal file
6
frontend-v2/src/pages/auth/VerifyEmailPage/route.tsx
Normal 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
|
||||
});
|
||||
37
frontend-v2/src/pages/middlewares/authenticate.tsx
Normal file
37
frontend-v2/src/pages/middlewares/authenticate.tsx
Normal 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 };
|
||||
}
|
||||
});
|
||||
27
frontend-v2/src/pages/middlewares/inject-org-details.tsx
Normal file
27
frontend-v2/src/pages/middlewares/inject-org-details.tsx
Normal 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 };
|
||||
}
|
||||
});
|
||||
27
frontend-v2/src/pages/middlewares/restrict-login-signup.tsx
Normal file
27
frontend-v2/src/pages/middlewares/restrict-login-signup.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
});
|
||||
39
frontend-v2/src/pages/root.tsx
Normal file
39
frontend-v2/src/pages/root.tsx
Normal 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 };
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { APIKeySection } from "./APIKeySection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthMethodSection } from "./AuthMethodSection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangePasswordSection } from "./ChangePasswordSection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { DeleteAccountSection } from "./DeleteAccountSection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { EmergencyKitSection } from "./EmergencyKitSection";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { APIKeySection } from "../APIKeySection";
|
||||
|
||||
export const PersonalAPIKeyTab = () => {
|
||||
return <APIKeySection />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalAPIKeyTab } from "./PersonalAPIKeyTab";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalAuthTab } from "./PersonalAuthTab";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalGeneralTab } from "./PersonalGeneralTab";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalTabGroup } from "./PersonalTabGroup";
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { MFASection } from "./MFASection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SessionsSection } from "./SessionsSection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { UserNameSection } from "./UserNameSection";
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
7
frontend-v2/src/pages/user/layout.tsx
Normal file
7
frontend-v2/src/pages/user/layout.tsx
Normal 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
|
||||
});
|
||||
3
frontend-v2/src/routes.ts
Normal file
3
frontend-v2/src/routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { rootRoute } from "@tanstack/virtual-file-routes";
|
||||
|
||||
export const routes = rootRoute("root.tsx", []);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"autoCodeSplitting": true,
|
||||
"routeFileIgnorePrefix": "-",
|
||||
"routesDirectory": "./src/routes",
|
||||
"routesDirectory": "./src/pages",
|
||||
"generatedRouteTree": "./src/routeTree.gen.ts"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user