From 3ebbaefc2a4db3db931c8f0266e2486229a0377b Mon Sep 17 00:00:00 2001 From: = Date: Tue, 24 Dec 2024 20:09:50 +0530 Subject: [PATCH] feat: changed to virtual route for auth and personal settings --- frontend-v2/src/const/routes.ts | 15 + .../auth/CliRedirectPage/CliRedirectPage.tsx | 90 ++++ .../src/pages/auth/CliRedirectPage/route.tsx | 7 + .../EmailNotVerifiedPage.tsx | 20 + .../pages/auth/EmailNotVerifiedPage/route.tsx | 6 + .../auth/LoginLdapPage/LoginLDAPPage.tsx | 154 +++++++ .../src/pages/auth/LoginLdapPage/route.tsx | 7 + .../src/pages/auth/LoginPage/Login.utils.tsx | 51 +++ .../src/pages/auth/LoginPage/LoginPage.tsx | 77 ++++ .../components/InitialStep/InitialStep.tsx | 408 +++++++++++++++++ .../components/InitialStep/index.tsx | 1 + .../components/PasswordStep/PasswordStep.tsx | 379 ++++++++++++++++ .../components/PasswordStep/index.tsx | 1 + .../LoginPage/components/SSOStep/SSOStep.tsx | 82 ++++ .../LoginPage/components/SSOStep/index.tsx | 1 + .../pages/auth/LoginPage/components/index.tsx | 5 + .../src/pages/auth/LoginPage/route.tsx | 7 + .../pages/auth/LoginSsoPage/LoginSsoPage.tsx | 59 +++ .../src/pages/auth/LoginSsoPage/route.tsx | 14 + .../PasswordResetPage/PasswordResetPage.tsx | 391 ++++++++++++++++ .../pages/auth/PasswordResetPage/route.tsx | 15 + .../ProviderErrorPage/ProviderErrorPage.tsx | 10 + .../pages/auth/ProviderErrorPage/route.tsx | 7 + .../ProviderSuccessPage.tsx | 18 + .../pages/auth/ProviderSuccessPage/route.tsx | 14 + .../RequestNewInvitePage.tsx | 32 ++ .../pages/auth/RequestNewInvitePage/route.tsx | 7 + .../auth/SelectOrgPage/SelectOrgPage.tsx | 278 ++++++++++++ .../src/pages/auth/SelectOrgPage/route.tsx | 7 + .../SignUpInvitePage/SignUpInvitePage.tsx | 425 ++++++++++++++++++ .../src/pages/auth/SignUpInvitePage/route.tsx | 16 + .../src/pages/auth/SignUpPage/SignUpPage.tsx | 171 +++++++ .../src/pages/auth/SignUpPage/route.tsx | 7 + .../auth/SignUpSsoPage/SignUpSsoPage.tsx | 88 ++++ .../BackupPDFStep/BackupPDFStep.tsx | 71 +++ .../components/BackupPDFStep/index.tsx | 1 + .../EmailConfirmationStep.tsx | 189 ++++++++ .../EmailConfirmationStep/index.tsx | 1 + .../UserInfoSSOStep/UserInfoSSOStep.tsx | 324 +++++++++++++ .../components/UserInfoSSOStep/index.tsx | 1 + .../src/pages/auth/SignUpSsoPage/route.tsx | 14 + .../auth/VerifyEmailPage/VerifyEmailPage.tsx | 111 +++++ .../src/pages/auth/VerifyEmailPage/route.tsx | 6 + .../src/pages/middlewares/authenticate.tsx | 37 ++ .../pages/middlewares/inject-org-details.tsx | 27 ++ .../middlewares/restrict-login-signup.tsx | 27 ++ frontend-v2/src/pages/root.tsx | 39 ++ .../PersonalSettingsPage.tsx | 25 ++ .../APIKeySection/APIKeySection.tsx | 37 ++ .../components/APIKeySection/APIKeyTable.tsx | 91 ++++ .../APIKeySection/AddAPIKeyModal.tsx | 185 ++++++++ .../components/APIKeySection/index.tsx | 1 + .../AuthMethodSection/AuthMethodSection.tsx | 132 ++++++ .../components/AuthMethodSection/index.tsx | 1 + .../ChangePasswordSection.tsx | 147 ++++++ .../ChangePasswordSection/index.tsx | 1 + .../DeleteAccountSection.tsx | 59 +++ .../components/DeleteAccountSection/index.tsx | 1 + .../EmergencyKitSection.tsx | 80 ++++ .../components/EmergencyKitSection/index.tsx | 1 + .../PersonalAPIKeyTab/PersonalAPIKeyTab.tsx | 5 + .../components/PersonalAPIKeyTab/index.tsx | 1 + .../PersonalAuthTab/PersonalAuthTab.tsx | 21 + .../components/PersonalAuthTab/index.tsx | 1 + .../PersonalGeneralTab/PersonalGeneralTab.tsx | 15 + .../components/PersonalGeneralTab/index.tsx | 1 + .../PersonalTabGroup/PersonalTabGroup.tsx | 67 +++ .../components/PersonalTabGroup/index.tsx | 1 + .../components/SecuritySection/MFASection.tsx | 232 ++++++++++ .../components/SecuritySection/index.tsx | 1 + .../SessionsSection/SessionsSection.tsx | 41 ++ .../SessionsSection/SessionsTable.tsx | 67 +++ .../components/SessionsSection/index.tsx | 1 + .../UserNameSection/UserNameSection.tsx | 71 +++ .../components/UserNameSection/index.tsx | 1 + .../pages/user/PersonalSettingsPage/route.tsx | 9 + frontend-v2/src/pages/user/layout.tsx | 7 + frontend-v2/src/routes.ts | 3 + frontend-v2/tsr.config.json | 2 +- frontend-v2/vite.config.ts | 28 +- 80 files changed, 5047 insertions(+), 7 deletions(-) create mode 100644 frontend-v2/src/const/routes.ts create mode 100644 frontend-v2/src/pages/auth/CliRedirectPage/CliRedirectPage.tsx create mode 100644 frontend-v2/src/pages/auth/CliRedirectPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx create mode 100644 frontend-v2/src/pages/auth/EmailNotVerifiedPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/LoginLdapPage/LoginLDAPPage.tsx create mode 100644 frontend-v2/src/pages/auth/LoginLdapPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/Login.utils.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/LoginPage.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/components/InitialStep/index.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/index.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/components/SSOStep/SSOStep.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/components/SSOStep/index.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/components/index.tsx create mode 100644 frontend-v2/src/pages/auth/LoginPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx create mode 100644 frontend-v2/src/pages/auth/LoginSsoPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx create mode 100644 frontend-v2/src/pages/auth/PasswordResetPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/ProviderErrorPage/ProviderErrorPage.tsx create mode 100644 frontend-v2/src/pages/auth/ProviderErrorPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/ProviderSuccessPage/ProviderSuccessPage.tsx create mode 100644 frontend-v2/src/pages/auth/ProviderSuccessPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/RequestNewInvitePage/RequestNewInvitePage.tsx create mode 100644 frontend-v2/src/pages/auth/RequestNewInvitePage/route.tsx create mode 100644 frontend-v2/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx create mode 100644 frontend-v2/src/pages/auth/SelectOrgPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpInvitePage/route.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpPage/SignUpPage.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/BackupPDFStep.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/index.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/index.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/index.tsx create mode 100644 frontend-v2/src/pages/auth/SignUpSsoPage/route.tsx create mode 100644 frontend-v2/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx create mode 100644 frontend-v2/src/pages/auth/VerifyEmailPage/route.tsx create mode 100644 frontend-v2/src/pages/middlewares/authenticate.tsx create mode 100644 frontend-v2/src/pages/middlewares/inject-org-details.tsx create mode 100644 frontend-v2/src/pages/middlewares/restrict-login-signup.tsx create mode 100644 frontend-v2/src/pages/root.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/PersonalSettingsPage.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeySection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeyTable.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/AddAPIKeyModal.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/AuthMethodSection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/DeleteAccountSection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/EmergencyKitSection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/PersonalTabGroup.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/MFASection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsSection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/UserNameSection.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/index.tsx create mode 100644 frontend-v2/src/pages/user/PersonalSettingsPage/route.tsx create mode 100644 frontend-v2/src/pages/user/layout.tsx create mode 100644 frontend-v2/src/routes.ts diff --git a/frontend-v2/src/const/routes.ts b/frontend-v2/src/const/routes.ts new file mode 100644 index 000000000..9c68a6138 --- /dev/null +++ b/frontend-v2/src/const/routes.ts @@ -0,0 +1,15 @@ +import { FileRouteTypes } from "@app/routeTree.gen"; + +const setRoute = ( + 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") +}); diff --git a/frontend-v2/src/pages/auth/CliRedirectPage/CliRedirectPage.tsx b/frontend-v2/src/pages/auth/CliRedirectPage/CliRedirectPage.tsx new file mode 100644 index 000000000..476127a67 --- /dev/null +++ b/frontend-v2/src/pages/auth/CliRedirectPage/CliRedirectPage.tsx @@ -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({ + initialState: false + }); + const cliToken = getTerminalCliToken(); + + const copyUrlToClipboard = () => { + if (cliToken) { + navigator.clipboard.writeText(cliToken); + setIsUrlCopied(true); + sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN); + } + }; + + return ( +
+ + Infisical CLI | Login Successful! + + +
+
+ Infisical Logo +
+ {cliToken ? ( + <> +
+

+ Unable to reach CLI +

+

+ Your login was successful but, Infisical couldn't automatically push your login + token to the CLI. +

+

+ Please copy the token below and manually provide it to your CLI. +

+
+
+ +
+ + Copy to + clipboard + +
+
+ + ) : ( + <> +

+ Head back to your terminal +

+

+ You've successfully logged in to the Infisical CLI +

+ + )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/CliRedirectPage/route.tsx b/frontend-v2/src/pages/auth/CliRedirectPage/route.tsx new file mode 100644 index 000000000..db8b032d3 --- /dev/null +++ b/frontend-v2/src/pages/auth/CliRedirectPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { CliRedirectPage } from "./CliRedirectPage"; + +export const Route = createFileRoute("/_authenticate/cli-redirect")({ + component: CliRedirectPage +}); diff --git a/frontend-v2/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx b/frontend-v2/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx new file mode 100644 index 000000000..92c485ed3 --- /dev/null +++ b/frontend-v2/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx @@ -0,0 +1,20 @@ +import { Helmet } from "react-helmet"; + +export const EmailNotVerifiedPage = () => { + return ( +
+ + Request a New Invite + + +
+

Oops.

+

Your email was not verified.

+

Please try again.

+

+ Note: If it still doesn't work, please reach out to us at support@infisical.com +

+
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/EmailNotVerifiedPage/route.tsx b/frontend-v2/src/pages/auth/EmailNotVerifiedPage/route.tsx new file mode 100644 index 000000000..034880d09 --- /dev/null +++ b/frontend-v2/src/pages/auth/EmailNotVerifiedPage/route.tsx @@ -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 +}); diff --git a/frontend-v2/src/pages/auth/LoginLdapPage/LoginLDAPPage.tsx b/frontend-v2/src/pages/auth/LoginLdapPage/LoginLDAPPage.tsx new file mode 100644 index 000000000..1227705ba --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginLdapPage/LoginLDAPPage.tsx @@ -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 ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + +
+ Infisical logo +
+ +
+

+ What's your LDAP Login? +

+
+ {!config.defaultAuthOrgSlug && !passedOrgSlug && ( +
+
+ setOrganizationSlug(e.target.value)} + type="text" + placeholder="Enter your organization slug..." + isRequired + autoComplete="email" + id="email" + className="h-12" + /> +
+
+ )} +
+
+ setUsername(e.target.value)} + type="text" + placeholder="Enter your LDAP username..." + isRequired + autoComplete="email" + id="email" + className="h-12" + isDisabled={passedUsername !== null} + /> +
+
+
+
+ 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" + /> +
+
+
+ +
+
+
+ +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/LoginLdapPage/route.tsx b/frontend-v2/src/pages/auth/LoginLdapPage/route.tsx new file mode 100644 index 000000000..4b88ab568 --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginLdapPage/route.tsx @@ -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 +}); diff --git a/frontend-v2/src/pages/auth/LoginPage/Login.utils.tsx b/frontend-v2/src/pages/auth/LoginPage/Login.utils.tsx new file mode 100644 index 000000000..46f60858a --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/Login.utils.tsx @@ -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 }; +}; diff --git a/frontend-v2/src/pages/auth/LoginPage/LoginPage.tsx b/frontend-v2/src/pages/auth/LoginPage/LoginPage.tsx new file mode 100644 index 000000000..ff0154be8 --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/LoginPage.tsx @@ -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 ( + + ); + case 2: + return ; + case 3: + return ; + default: + return
; + } + }; + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + +
+ Infisical logo +
+ +
{renderView()}
; +
+ ); +}; diff --git a/frontend-v2/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx b/frontend-v2/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx new file mode 100644 index 000000000..d6b591fec --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx @@ -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(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) => { + 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 ( +
+

+ Login to Infisical +

+ + {config.defaultAuthOrgAuthMethod === AuthMethod.SAML && ( +
+ +
+ )} + {config.defaultAuthOrgAuthMethod === AuthMethod.OIDC && ( +
+ +
+ )} + + ); + } + + return ( +
+

+ Login to Infisical +

+ + {shouldDisplayLoginMethod(LoginMethod.SAML) && ( +
+ +
+ )} + {shouldDisplayLoginMethod(LoginMethod.OIDC) && ( +
+ +
+ )} + {shouldDisplayLoginMethod(LoginMethod.LDAP) && ( +
+ +
+ )} +
+ {shouldDisplayLoginMethod(LoginMethod.GOOGLE) && ( + + { + 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" + > + + + + )} + {shouldDisplayLoginMethod(LoginMethod.GITHUB) && ( + + { + 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" + > + + + + )} + {shouldDisplayLoginMethod(LoginMethod.GITLAB) && ( + + { + 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" + > + + + + )} +
+ {(!config.enabledLoginMethods || + (shouldDisplayLoginMethod(LoginMethod.EMAIL) && config.enabledLoginMethods.length > 1)) && ( +
+
+ or +
+
+ )} + {shouldDisplayLoginMethod(LoginMethod.EMAIL) && ( + <> +
+ setEmail(e.target.value)} + type="email" + placeholder="Enter your email..." + isRequired + autoComplete="username" + className="h-10" + /> +
+
+ setPassword(e.target.value)} + type="password" + placeholder="Enter your password..." + isRequired + autoComplete="current-password" + id="current-password" + className="select:-webkit-autofill:focus h-10" + /> +
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )} +
+ +
+ + )} + {!isLoading && loginError && } + {config.allowSignUp && + (shouldDisplayLoginMethod(LoginMethod.EMAIL) || + shouldDisplayLoginMethod(LoginMethod.GOOGLE) || + shouldDisplayLoginMethod(LoginMethod.GITHUB) || + shouldDisplayLoginMethod(LoginMethod.GITLAB)) ? ( +
+ + + Don't have an account yet? {t("login.create-account")} + + +
+ ) : ( +
+ )} + {shouldDisplayLoginMethod(LoginMethod.EMAIL) && ( +
+ + + Forgot password? Recover your account + + +
+ )} + + ); +}; diff --git a/frontend-v2/src/pages/auth/LoginPage/components/InitialStep/index.tsx b/frontend-v2/src/pages/auth/LoginPage/components/InitialStep/index.tsx new file mode 100644 index 000000000..33142e0b0 --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/components/InitialStep/index.tsx @@ -0,0 +1 @@ +export { InitialStep } from "./InitialStep"; diff --git a/frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx new file mode 100644 index 000000000..cb49fc475 --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -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(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 ( +
+ toggleShowMfa.off()} + /> +
+ ); + } + + if (hasExchangedPrivateKey) { + return ( +
+ +

Loading, please wait

+
+ ); + } + + return ( +
+
+

+ What's your Infisical password? +

+
+
+
+ setPassword(e.target.value)} + type="password" + placeholder="Enter your password..." + isRequired + autoComplete="current-password" + id="current-password" + className="h-12" + /> +
+
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )} +
+ +
+
+ + Infisical Master Password serves as a decryption mechanism so that even Google is not able + to access your secrets. + + + + {t("login.forgot-password")} + + +
+
+ +
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/index.tsx b/frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/index.tsx new file mode 100644 index 000000000..e2d3993e4 --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/components/PasswordStep/index.tsx @@ -0,0 +1 @@ +export { PasswordStep } from "./PasswordStep"; diff --git a/frontend-v2/src/pages/auth/LoginPage/components/SSOStep/SSOStep.tsx b/frontend-v2/src/pages/auth/LoginPage/components/SSOStep/SSOStep.tsx new file mode 100644 index 000000000..9663c04a9 --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/components/SSOStep/SSOStep.tsx @@ -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 ( +
+

+ What's your organization slug? +

+
+
+
+ setSSOIdentifier(e.target.value)} + type="text" + placeholder="acme-123" + isRequired + autoComplete="email" + id="email" + className="h-12" + /> +
+
+
+ +
+
+
+ +
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/LoginPage/components/SSOStep/index.tsx b/frontend-v2/src/pages/auth/LoginPage/components/SSOStep/index.tsx new file mode 100644 index 000000000..e7d80b2c0 --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/components/SSOStep/index.tsx @@ -0,0 +1 @@ +export { SSOStep } from "./SSOStep"; diff --git a/frontend-v2/src/pages/auth/LoginPage/components/index.tsx b/frontend-v2/src/pages/auth/LoginPage/components/index.tsx new file mode 100644 index 000000000..84ad4f73d --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/components/index.tsx @@ -0,0 +1,5 @@ +export { InitialStep } from "./InitialStep"; +export { SSOStep } from "./SSOStep"; + +// SSO-specific step +export { PasswordStep } from "./PasswordStep"; diff --git a/frontend-v2/src/pages/auth/LoginPage/route.tsx b/frontend-v2/src/pages/auth/LoginPage/route.tsx new file mode 100644 index 000000000..dd0dadccd --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { LoginPage } from "./LoginPage"; + +export const Route = createFileRoute("/_restrict-login-signup/login/")({ + component: LoginPage +}); diff --git a/frontend-v2/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx b/frontend-v2/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx new file mode 100644 index 000000000..6ec6e2c9d --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx @@ -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
; + case 1: + return ( + + ); + default: + return
; + } + }; + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + +
+ Infisical logo +
+ +
{renderView()}
; +
+ ); +}; diff --git a/frontend-v2/src/pages/auth/LoginSsoPage/route.tsx b/frontend-v2/src/pages/auth/LoginSsoPage/route.tsx new file mode 100644 index 000000000..3b8af708d --- /dev/null +++ b/frontend-v2/src/pages/auth/LoginSsoPage/route.tsx @@ -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) +}); diff --git a/frontend-v2/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx b/frontend-v2/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx new file mode 100644 index 000000000..5c8c7d0f2 --- /dev/null +++ b/frontend-v2/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx @@ -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) => { + 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) => { + 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 = ( +
+

+ Confirm your email +

+ verify email +
+ +
+
+ ); + + // Input backup key + const stepInputBackupKey = ( +
+

+ Enter your backup key +

+
+

+ You can find it in your emergency kit. You had to download the emergency kit during + signup. +

+
+
+ +
+
+
+ +
+
+
+ ); + + // Enter new password + const stepEnterNewPassword = ( +
+

+ Enter new password +

+
+

+ Make sure you save it somewhere safe. +

+
+
+ { + 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" + /> +
+ {passwordErrorTooShort || + passwordErrorTooLong || + passwordErrorNoLetterChar || + passwordErrorNoNumOrSpecialChar || + passwordErrorRepeatedChar || + passwordErrorEscapeChar || + passwordErrorLowEntropy || + passwordErrorBreached ? ( +
+
Password should contain:
+
+ {passwordErrorTooShort ? ( + + ) : ( + + )} +
+ at least 14 characters +
+
+
+ {passwordErrorTooLong ? ( + + ) : ( + + )} +
+ at most 100 characters +
+
+
+ {passwordErrorNoLetterChar ? ( + + ) : ( + + )} +
+ at least 1 letter character +
+
+
+ {passwordErrorNoNumOrSpecialChar ? ( + + ) : ( + + )} +
+ at least 1 number or special character +
+
+
+ {passwordErrorRepeatedChar ? ( + + ) : ( + + )} +
+ at most 3 repeated, consecutive characters +
+
+
+ {passwordErrorEscapeChar ? ( + + ) : ( + + )} +
+ No escape characters allowed. +
+
+
+ {passwordErrorLowEntropy ? ( + + ) : ( + + )} +
+ Password contains personal info. +
+
+
+ {passwordErrorBreached ? ( + + ) : ( + + )} +
+ Password was found in a data breach. +
+
+
+ ) : ( +
+ )} +
+
+ +
+
+ + ); + + return ( +
+ {step === 1 && stepConfirmEmail} + {step === 2 && stepInputBackupKey} + {step === 3 && stepEnterNewPassword} +
+ ); +}; diff --git a/frontend-v2/src/pages/auth/PasswordResetPage/route.tsx b/frontend-v2/src/pages/auth/PasswordResetPage/route.tsx new file mode 100644 index 000000000..b2c926d79 --- /dev/null +++ b/frontend-v2/src/pages/auth/PasswordResetPage/route.tsx @@ -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) +}); diff --git a/frontend-v2/src/pages/auth/ProviderErrorPage/ProviderErrorPage.tsx b/frontend-v2/src/pages/auth/ProviderErrorPage/ProviderErrorPage.tsx new file mode 100644 index 000000000..eb03c3387 --- /dev/null +++ b/frontend-v2/src/pages/auth/ProviderErrorPage/ProviderErrorPage.tsx @@ -0,0 +1,10 @@ +import { useEffect } from "react"; + +export const LoginProviderErrorPage = () => { + useEffect(() => { + window.localStorage.setItem("PROVIDER_AUTH_ERROR", "err"); + window.close(); + }, []); + + return
; +}; diff --git a/frontend-v2/src/pages/auth/ProviderErrorPage/route.tsx b/frontend-v2/src/pages/auth/ProviderErrorPage/route.tsx new file mode 100644 index 000000000..6df6a07a3 --- /dev/null +++ b/frontend-v2/src/pages/auth/ProviderErrorPage/route.tsx @@ -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 +}); diff --git a/frontend-v2/src/pages/auth/ProviderSuccessPage/ProviderSuccessPage.tsx b/frontend-v2/src/pages/auth/ProviderSuccessPage/ProviderSuccessPage.tsx new file mode 100644 index 000000000..c335ae1ba --- /dev/null +++ b/frontend-v2/src/pages/auth/ProviderSuccessPage/ProviderSuccessPage.tsx @@ -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
; +}; diff --git a/frontend-v2/src/pages/auth/ProviderSuccessPage/route.tsx b/frontend-v2/src/pages/auth/ProviderSuccessPage/route.tsx new file mode 100644 index 000000000..df71b2cb4 --- /dev/null +++ b/frontend-v2/src/pages/auth/ProviderSuccessPage/route.tsx @@ -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) +}); diff --git a/frontend-v2/src/pages/auth/RequestNewInvitePage/RequestNewInvitePage.tsx b/frontend-v2/src/pages/auth/RequestNewInvitePage/RequestNewInvitePage.tsx new file mode 100644 index 000000000..71d63102b --- /dev/null +++ b/frontend-v2/src/pages/auth/RequestNewInvitePage/RequestNewInvitePage.tsx @@ -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 ( +
+ + Request a New Invite + + +
+

Oops, your invite has expired.

+

Ask your admin for a new one.

+

+ Note: If it still + doesn't work, please reach out to us at support@infisical.com +

+
+ invitation expired illustration +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/RequestNewInvitePage/route.tsx b/frontend-v2/src/pages/auth/RequestNewInvitePage/route.tsx new file mode 100644 index 000000000..768e32bcc --- /dev/null +++ b/frontend-v2/src/pages/auth/RequestNewInvitePage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { RequestNewInvitePage } from "./RequestNewInvitePage"; + +export const Route = createFileRoute("/_restrict-login-signup/requestnewinvite")({ + component: RequestNewInvitePage +}); diff --git a/frontend-v2/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx b/frontend-v2/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx new file mode 100644 index 000000000..21fb70bf7 --- /dev/null +++ b/frontend-v2/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx @@ -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 ( +
+ +

Loading, please wait

+
+ ); +}; + +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 ; + } + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + {shouldShowMfa ? ( + + ) : ( +
+ +
+ Infisical logo +
+ +
+
+

+ Choose your organization +

+ +
+

+ You‘re currently logged in as {user.username} +

+

+ Not you?{" "} + +

+
+
+
+ {organizations.isPending ? ( + + ) : ( + organizations.data?.map((org) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
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" + > +

{org.name}

+ + +
+ )) + )} +
+
+
+ )} + +
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/SelectOrgPage/route.tsx b/frontend-v2/src/pages/auth/SelectOrgPage/route.tsx new file mode 100644 index 000000000..b3141e192 --- /dev/null +++ b/frontend-v2/src/pages/auth/SelectOrgPage/route.tsx @@ -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 +}); diff --git a/frontend-v2/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend-v2/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx new file mode 100644 index 000000000..80d918f9a --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -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({}); + + 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 = ( +
+

+ Confirm your email +

+ verify email +
+ +
+
+ ); + + // Because this is the invite signup - we directly go to the last step of signup (email is already verified) + const main = ( +
+

+ Almost there! +

+
+ +
+
+ +
+
+ { + 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 && ( +
+
Password should contain at least:
+ {Object.keys(errors).map((key) => { + if (errors[key as keyof Errors]) { + return ( +
+
+ +
+

{errors[key as keyof Errors]}

+
+ ); + } + + return null; + })} +
+ )} +
+
+ +
+
+ ); + + // Step 4 of the sign up process (download the emergency kit pdf) + const step4 = ( +
+

+ Save your Emergency Kit +

+
+
+ If you get locked out of your account, your Emergency Kit is the only way to sign in. +
+
We recommend you download it and keep it somewhere safe.
+
+
+ + It contains your Secret Key which we cannot access or recover for you if you lose it. +
+
+ +
+
+ ); + + return ( +
+ + Sign Up + + + {shouldShowMfa ? ( + toggleShowMfa.off()} + /> + ) : ( + <> + +
+ Infisical Logo +
+ + {step === 1 ? stepConfirmEmail : step === 2 ? main : step4} + + )} +
+ ); +}; diff --git a/frontend-v2/src/pages/auth/SignUpInvitePage/route.tsx b/frontend-v2/src/pages/auth/SignUpInvitePage/route.tsx new file mode 100644 index 000000000..0e31645fc --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpInvitePage/route.tsx @@ -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) +}); diff --git a/frontend-v2/src/pages/auth/SignUpPage/SignUpPage.tsx b/frontend-v2/src/pages/auth/SignUpPage/SignUpPage.tsx new file mode 100644 index 000000000..f5dfa4a5d --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpPage/SignUpPage.tsx @@ -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 ; + } + + if (!isSignupWithEmail && registerStep === 1) { + return ; + } + + if (registerStep === 2) { + return ( + + ); + } + + if (registerStep === 3) { + return ( + + ); + } + + if (registerStep === 4) { + return ( + + ); + } + + if (serverDetails?.emailConfigured) { + return ; + } + + return ""; + }; + + return ( +
+ + {t("common.head-title", { title: t("signup.title") })} + + + + + +
+ Infisical Logo +
+
e.preventDefault()}>{renderView(step)}
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/SignUpPage/route.tsx b/frontend-v2/src/pages/auth/SignUpPage/route.tsx new file mode 100644 index 000000000..185a12135 --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SignUpPage } from "./SignUpPage"; + +export const Route = createFileRoute("/_restrict-login-signup/signup/")({ + component: SignUpPage +}); diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx new file mode 100644 index 000000000..83d0221f4 --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx @@ -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 ( + + ); + case 1: + return ( + + ); + case 2: + return ( + + ); + default: + return
; + } + }; + + return ( +
+ + {t("common.head-title", { title: t("signup.title") })} + + + + + +
+ Infisical Logo +
+
{renderView()}
; +
+ ); +}; diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/BackupPDFStep.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/BackupPDFStep.tsx new file mode 100644 index 000000000..8d0597c16 --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/BackupPDFStep.tsx @@ -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 ( +
+

+ + {t("signup.step4-message")} +

+
+
+ + {t("signup.step4-description1")} {t("signup.step4-description3")} + +
+
+
+ +
+
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/index.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/index.tsx new file mode 100644 index 000000000..01f7745cd --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/index.tsx @@ -0,0 +1 @@ +export { BackupPDFStep } from "./BackupPDFStep"; diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx new file mode 100644 index 000000000..47cbc5b41 --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -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 ( +
+

+ We've sent a verification code to {email} +

+
+ +
+
+ +
+ {codeError && } +
+
+ +
+
+
+
+ Don't see the code? +
+ +
+
+

Make sure to check your spam inbox.

+
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/index.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/index.tsx new file mode 100644 index 000000000..32f3a636e --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/index.tsx @@ -0,0 +1 @@ +export { EmailConfirmationStep } from "./EmailConfirmationStep"; diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx new file mode 100644 index 000000000..ee3aa70a7 --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -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 ( + toggleShowMfa.off()} + /> + ); + } + + return ( +
+

+ {t("signup.step3-message")} +

+
+
+

+ Your Name +

+ + {nameError && ( +

+ Please, specify your name +

+ )} +
+ {providerOrganizationName === undefined && ( +
+

+ Organization Name +

+ setOrganizationName(e.target.value)} + isRequired + className="h-12" + maxLength={64} + disabled + /> + {organizationNameError && ( +

+ Please, specify your organization name +

+ )} +
+ )} + {providerOrganizationName === undefined && ( +
+

+ Where did you hear about us? (optional) +

+ setAttributionSource(e.target.value)} + value={attributionSource} + className="h-12" + /> +
+ )} +
+
+ +
+
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/index.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/index.tsx new file mode 100644 index 000000000..d62267cd3 --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/index.tsx @@ -0,0 +1 @@ +export { UserInfoSSOStep } from "./UserInfoSSOStep"; diff --git a/frontend-v2/src/pages/auth/SignUpSsoPage/route.tsx b/frontend-v2/src/pages/auth/SignUpSsoPage/route.tsx new file mode 100644 index 000000000..4855e35ed --- /dev/null +++ b/frontend-v2/src/pages/auth/SignUpSsoPage/route.tsx @@ -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) +}); diff --git a/frontend-v2/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx b/frontend-v2/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx new file mode 100644 index 000000000..04d25f477 --- /dev/null +++ b/frontend-v2/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx @@ -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) => { + e.preventDefault(); + setLoading(true); + + if (serverDetails?.emailConfigured) { + sendVerificationEmail(); + } else { + handlePopUpOpen("setUpEmail"); + setLoading(false); + } + }; + + return ( +
+ + Login + + + + + + +
+ long logo +
+ + {step === 1 && ( +
+

+ Forgot your password? +

+
+

+ You will need your emergency kit. Enter your email to start account recovery. +

+
+
+ +
+
+
+ +
+
+
+ )} + {step === 2 && ( +
+

+ Look for an email in your inbox. +

+
+

+ An email with instructions has been sent to {email}. +

+
+
+ )} + + handlePopUpToggle("setUpEmail", isOpen)} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/auth/VerifyEmailPage/route.tsx b/frontend-v2/src/pages/auth/VerifyEmailPage/route.tsx new file mode 100644 index 000000000..8019554f8 --- /dev/null +++ b/frontend-v2/src/pages/auth/VerifyEmailPage/route.tsx @@ -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 +}); diff --git a/frontend-v2/src/pages/middlewares/authenticate.tsx b/frontend-v2/src/pages/middlewares/authenticate.tsx new file mode 100644 index 000000000..cb70c4c6f --- /dev/null +++ b/frontend-v2/src/pages/middlewares/authenticate.tsx @@ -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 }; + } +}); diff --git a/frontend-v2/src/pages/middlewares/inject-org-details.tsx b/frontend-v2/src/pages/middlewares/inject-org-details.tsx new file mode 100644 index 000000000..cf8b07f83 --- /dev/null +++ b/frontend-v2/src/pages/middlewares/inject-org-details.tsx @@ -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 }; + } +}); diff --git a/frontend-v2/src/pages/middlewares/restrict-login-signup.tsx b/frontend-v2/src/pages/middlewares/restrict-login-signup.tsx new file mode 100644 index 000000000..946888142 --- /dev/null +++ b/frontend-v2/src/pages/middlewares/restrict-login-signup.tsx @@ -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 + }); + } +}); diff --git a/frontend-v2/src/pages/root.tsx b/frontend-v2/src/pages/root.tsx new file mode 100644 index 000000000..65c344299 --- /dev/null +++ b/frontend-v2/src/pages/root.tsx @@ -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 ( + <> + + + + + + + + + ); +}; + +export const Route = createRootRouteWithContext()({ + component: RootPage, + beforeLoad: async ({ context }) => { + const serverConfig = await context.queryClient.ensureQueryData({ + queryKey: adminQueryKeys.serverConfig(), + queryFn: fetchServerConfig + }); + return { serverConfig }; + } +}); diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/PersonalSettingsPage.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/PersonalSettingsPage.tsx new file mode 100644 index 000000000..7ec7df849 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/PersonalSettingsPage.tsx @@ -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 ( +
+ + {t("common.head-title", { title: t("settings.personal.title") })} + + +
+
+
+

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

+
+ +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeySection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeySection.tsx new file mode 100644 index 000000000..a0f033049 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeySection.tsx @@ -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 ( +
+
+

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

+ + + +
+ + +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeyTable.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeyTable.tsx new file mode 100644 index 000000000..6fe6afd43 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/APIKeyTable.tsx @@ -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 ( + + + + + + + + + + + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map(({ id, name, createdAt, expiresAt, lastUsed }) => { + return ( + + + + + + + + ); + })} + {!isPending && data && data?.length === 0 && ( + + + + )} + +
NameLast activeCreatedExpiration +
{name}{format(new Date(lastUsed), "yyyy-MM-dd")}{format(new Date(createdAt), "yyyy-MM-dd")}{format(new Date(expiresAt), "yyyy-MM-dd")} + { + await handleDeleteAPIKeyDataClick(id); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/AddAPIKeyModal.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/AddAPIKeyModal.tsx new file mode 100644 index 000000000..ce901112e --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/AddAPIKeyModal.tsx @@ -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; + +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({ + 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 ( + { + handlePopUpToggle("addAPIKey", isOpen); + reset(); + setNewAPIKey(""); + }} + > + + {!hasAPIKey ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+

{newAPIKey}

+ + + + Click to copy + + +
+ )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/index.tsx new file mode 100644 index 000000000..f4bdf430e --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/APIKeySection/index.tsx @@ -0,0 +1 @@ +export { APIKeySection } from "./APIKeySection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/AuthMethodSection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/AuthMethodSection.tsx new file mode 100644 index 000000000..91080a73e --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/AuthMethodSection.tsx @@ -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; + +export const AuthMethodSection = () => { + const { user } = useUser(); + const { config } = useServerConfig(); + const { mutateAsync } = useUpdateUserAuthMethods(); + + const { reset, setValue, watch } = useForm({ + 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 ( +
+

+ Authentication methods +

+

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

+
+ {user && + authMethodOpts.map((authMethodOpt) => { + // only filter when enabledLoginMethods is explicitly configured by admin + if ( + config.enabledLoginMethods && + !config.enabledLoginMethods.includes(authMethodOpt.loginMethod) + ) { + return null; + } + + return ( +
+
+ +
+ onAuthMethodToggle(value, authMethodOpt)} + isChecked={authMethods?.includes(authMethodOpt.value) ?? false} + > +

{authMethodOpt.label}

+
+
+ ); + })} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/index.tsx new file mode 100644 index 000000000..3f6ad3da6 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/AuthMethodSection/index.tsx @@ -0,0 +1 @@ +export { AuthMethodSection } from "./AuthMethodSection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx new file mode 100644 index 000000000..efe07961e --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx @@ -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; + +export const ChangePasswordSection = () => { + const { t } = useTranslation(); + + const { user } = useUser(); + const { reset, control, handleSubmit } = useForm({ + defaultValues: { + oldPassword: "", + newPassword: "" + }, + resolver: zodResolver(schema) + }); + const [errors, setErrors] = useState({}); + 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 ( +
+

Change password

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

{errors[key as keyof Errors]}

+
+ ); + } + + return null; + })} +
+ )} + +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/index.tsx new file mode 100644 index 000000000..c2d2dfd80 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/index.tsx @@ -0,0 +1 @@ +export { ChangePasswordSection } from "./ChangePasswordSection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/DeleteAccountSection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/DeleteAccountSection.tsx new file mode 100644 index 000000000..f69c7aab1 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/DeleteAccountSection.tsx @@ -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 ( +
+

Danger Zone

+ + handlePopUpToggle("deleteAccount", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleDeleteAccountSubmit} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/index.tsx new file mode 100644 index 000000000..a4354d533 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/DeleteAccountSection/index.tsx @@ -0,0 +1 @@ +export { DeleteAccountSection } from "./DeleteAccountSection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/EmergencyKitSection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/EmergencyKitSection.tsx new file mode 100644 index 000000000..1cf0d336e --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/EmergencyKitSection.tsx @@ -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; + +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 ( +
+

Emergency Kit

+

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

+
+ ( + + + + )} + control={control} + name="password" + /> +
+ +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/index.tsx new file mode 100644 index 000000000..c817666b1 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/EmergencyKitSection/index.tsx @@ -0,0 +1 @@ +export { EmergencyKitSection } from "./EmergencyKitSection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx new file mode 100644 index 000000000..ca1fa130e --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx @@ -0,0 +1,5 @@ +import { APIKeySection } from "../APIKeySection"; + +export const PersonalAPIKeyTab = () => { + return ; +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/index.tsx new file mode 100644 index 000000000..50c77ba0a --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAPIKeyTab/index.tsx @@ -0,0 +1 @@ +export { PersonalAPIKeyTab } from "./PersonalAPIKeyTab"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx new file mode 100644 index 000000000..b87d87dbb --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx @@ -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 ( +
+ {user && !user.authMethods.includes(AuthMethod.LDAP) && ( + <> + + + + )} + +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/index.tsx new file mode 100644 index 000000000..2a042aaf5 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/index.tsx @@ -0,0 +1 @@ +export { PersonalAuthTab } from "./PersonalAuthTab"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx new file mode 100644 index 000000000..ac4c5bea2 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx @@ -0,0 +1,15 @@ +import { DeleteAccountSection } from "../DeleteAccountSection"; +import { EmergencyKitSection } from "../EmergencyKitSection"; +import { SessionsSection } from "../SessionsSection"; +import { UserNameSection } from "../UserNameSection"; + +export const PersonalGeneralTab = () => { + return ( +
+ + + + +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/index.tsx new file mode 100644 index 000000000..97c1cb39a --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/index.tsx @@ -0,0 +1 @@ +export { PersonalGeneralTab } from "./PersonalGeneralTab"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/PersonalTabGroup.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/PersonalTabGroup.tsx new file mode 100644 index 000000000..bff53c3c6 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/PersonalTabGroup.tsx @@ -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.map((tab) => ( + {tab.name} + ))} + + + + + + + + +
+
+ +
+ Deprecation Notice +

+ API Keys are being deprecated in favor of Machine Identities. +
+ They will be removed in the future in accordance with the deprecation notice and + timeline stated{" "} + + here + + . +
+ + Learn more about Machine Identities + +

+
+
+ +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/index.tsx new file mode 100644 index 000000000..5bc002597 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/PersonalTabGroup/index.tsx @@ -0,0 +1 @@ +export { PersonalTabGroup } from "./PersonalTabGroup"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/MFASection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/MFASection.tsx new file mode 100644 index 000000000..7f8988fa5 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/MFASection.tsx @@ -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 ( + <> +
+

Two-factor Authentication

+ {user && ( + { + if (serverDetails?.emailConfigured) { + toggleMfa(state as boolean); + } else { + handlePopUpOpen("setUpEmail"); + } + }} + > + Enable 2-factor authentication + + )} + {user?.isMfaEnabled && ( + + + + )} +
Mobile Authenticator
+ {isTotpConfigurationLoading ? ( + + ) : ( +
+ {totpConfiguration?.isVerified ? ( +
+
+ + + +
+ {shouldShowRecoveryCodes && totpConfiguration.recoveryCodes && ( +
+ {totpConfiguration.recoveryCodes.map((code) => ( +
{code}
+ ))} +
+ )} +
+ ) : ( + <> +
+ For added security, you can configure a mobile authenticator and set it as your + selected 2FA method. +
+
+ { + await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration }); + }} + /> +
+ + )} +
+ )} +
+ handlePopUpToggle("setUpEmail", isOpen)} + /> + handlePopUpToggle("deleteTotpConfig", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleTotpDeletion} + /> + + ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/index.tsx new file mode 100644 index 000000000..1284d0848 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SecuritySection/index.tsx @@ -0,0 +1 @@ +export { MFASection } from "./MFASection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsSection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsSection.tsx new file mode 100644 index 000000000..91369d060 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsSection.tsx @@ -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 ( +
+
+

Sessions

+ +
+

+ Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your + account out all active sessions across all browsers and CLIs. +

+ +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx new file mode 100644 index 000000000..9a24b1e27 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx @@ -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 ( + + + + + + + + + + + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map(({ id, createdAt, lastUsed, ip, userAgent }) => { + return ( + + + + + + + ); + })} + {!isPending && data && data?.length === 0 && ( + + + + )} + +
CreatedLast activeIP addressDevice
{formatDate(createdAt)}{formatDate(lastUsed)}{ip}{userAgent}
+ +
+
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/index.tsx new file mode 100644 index 000000000..cc0fe376b --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/SessionsSection/index.tsx @@ -0,0 +1 @@ +export { SessionsSection } from "./SessionsSection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/UserNameSection.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/UserNameSection.tsx new file mode 100644 index 000000000..00d32c1fb --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/UserNameSection.tsx @@ -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; + +export const UserNameSection = (): JSX.Element => { + const { user } = useUser(); + + const { handleSubmit, control, reset } = useForm({ 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 ( +
+

Name

+
+ ( + + + + )} + control={control} + name="name" + /> +
+ +
+ ); +}; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/index.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/index.tsx new file mode 100644 index 000000000..a81ecba8f --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/components/UserNameSection/index.tsx @@ -0,0 +1 @@ +export { UserNameSection } from "./UserNameSection"; diff --git a/frontend-v2/src/pages/user/PersonalSettingsPage/route.tsx b/frontend-v2/src/pages/user/PersonalSettingsPage/route.tsx new file mode 100644 index 000000000..ec1f06254 --- /dev/null +++ b/frontend-v2/src/pages/user/PersonalSettingsPage/route.tsx @@ -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 + } +); diff --git a/frontend-v2/src/pages/user/layout.tsx b/frontend-v2/src/pages/user/layout.tsx new file mode 100644 index 000000000..e25580027 --- /dev/null +++ b/frontend-v2/src/pages/user/layout.tsx @@ -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 +}); diff --git a/frontend-v2/src/routes.ts b/frontend-v2/src/routes.ts new file mode 100644 index 000000000..7a7ba4c1e --- /dev/null +++ b/frontend-v2/src/routes.ts @@ -0,0 +1,3 @@ +import { rootRoute } from "@tanstack/virtual-file-routes"; + +export const routes = rootRoute("root.tsx", []); diff --git a/frontend-v2/tsr.config.json b/frontend-v2/tsr.config.json index 566318c3f..0d6a78fc9 100644 --- a/frontend-v2/tsr.config.json +++ b/frontend-v2/tsr.config.json @@ -1,6 +1,6 @@ { "autoCodeSplitting": true, "routeFileIgnorePrefix": "-", - "routesDirectory": "./src/routes", + "routesDirectory": "./src/pages", "generatedRouteTree": "./src/routeTree.gen.ts" } diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts index da55e8294..93c4966b1 100644 --- a/frontend-v2/vite.config.ts +++ b/frontend-v2/vite.config.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 ] });