Merge pull request #1914 from SkynetLabs/dashboard-v2-auth-pages-registration-and-recovery
Dashboard v2 - registration and account recovery flows
This commit is contained in:
commit
bb0da7a357
|
@ -26,7 +26,7 @@ module.exports = {
|
||||||
app.use(
|
app.use(
|
||||||
"/api/",
|
"/api/",
|
||||||
createProxyMiddleware({
|
createProxyMiddleware({
|
||||||
target: "https://account.siasky.net",
|
target: "https://account.skynetpro.net",
|
||||||
secure: false, // Do not reject self-signed certificates.
|
secure: false, // Do not reject self-signed certificates.
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
})
|
})
|
||||||
|
|
|
@ -70,7 +70,7 @@ export const LoginForm = ({ onSuccess }) => {
|
||||||
touched={touched.password}
|
touched={touched.password}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Link to="/auth/recover" className="text-sm inline transition-colors hover:text-primary">
|
<Link to="/auth/reset-password" className="text-sm inline transition-colors hover:text-primary">
|
||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Formik, Form } from "formik";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
import { TextField } from "../Form/TextField";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
import accountsService from "../../services/accountsService";
|
||||||
|
|
||||||
|
const recoverySchema = Yup.object().shape({
|
||||||
|
email: Yup.string().required("Email is required").email("Please provide a valid email address"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecoveryForm = ({ onSuccess, onFailure }) => (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
email: "",
|
||||||
|
}}
|
||||||
|
validationSchema={recoverySchema}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
await accountsService.post("user/recover/request", {
|
||||||
|
json: values,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
onFailure();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched }) => (
|
||||||
|
<Form className="flex flex-col gap-4">
|
||||||
|
<h3 className="mt-4 mb-8">Request account recovery</h3>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
label="Email address"
|
||||||
|
error={errors.email}
|
||||||
|
touched={touched.email}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-center mt-4">
|
||||||
|
<Button type="submit" className="px-12" $primary>
|
||||||
|
Send recovery link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
|
||||||
|
RecoveryForm.propTypes = {
|
||||||
|
onFailure: PropTypes.func.isRequired,
|
||||||
|
onSuccess: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -0,0 +1,67 @@
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Formik, Form } from "formik";
|
||||||
|
|
||||||
|
import { TextField } from "../Form/TextField";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { passwordSchema } from "./SignUpForm";
|
||||||
|
|
||||||
|
import accountsService from "../../services/accountsService";
|
||||||
|
|
||||||
|
export const ResetPasswordForm = ({ token, onSuccess, onFailure }) => (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
}}
|
||||||
|
validationSchema={passwordSchema}
|
||||||
|
onSubmit={async ({ password, confirmPassword }) => {
|
||||||
|
try {
|
||||||
|
await accountsService.post("user/recover", {
|
||||||
|
json: {
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
onFailure();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched }) => (
|
||||||
|
<Form className="flex flex-col gap-4">
|
||||||
|
<h3 className="mt-4 mb-8">Set your new password</h3>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
label="New password"
|
||||||
|
error={errors.password}
|
||||||
|
touched={touched.password}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm new password"
|
||||||
|
error={errors.confirmPassword}
|
||||||
|
touched={touched.confirmPassword}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-center mt-4">
|
||||||
|
<Button type="submit" className="px-12" $primary>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
|
||||||
|
ResetPasswordForm.propTypes = {
|
||||||
|
token: PropTypes.string.isRequired,
|
||||||
|
onFailure: PropTypes.func.isRequired,
|
||||||
|
onSuccess: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -0,0 +1,109 @@
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Formik, Form } from "formik";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
import { TextField } from "../Form/TextField";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
import accountsService from "../../services/accountsService";
|
||||||
|
|
||||||
|
export const passwordSchema = Yup.object().shape({
|
||||||
|
password: Yup.string().required("Password is required"),
|
||||||
|
confirmPassword: Yup.string()
|
||||||
|
.oneOf([Yup.ref("password"), null], "Passwords must match")
|
||||||
|
.required("Please re-type your password"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registrationSchema = Yup.object()
|
||||||
|
.shape({
|
||||||
|
email: Yup.string().required("Email is required").email("Please provide a valid email address"),
|
||||||
|
})
|
||||||
|
.concat(passwordSchema);
|
||||||
|
|
||||||
|
const USER_EXISTS_ERROR = "identity already belongs to an existing user";
|
||||||
|
|
||||||
|
export const SignUpForm = ({ onSuccess, onFailure }) => (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
}}
|
||||||
|
validationSchema={registrationSchema}
|
||||||
|
onSubmit={async ({ email, password }, { setErrors }) => {
|
||||||
|
try {
|
||||||
|
await accountsService.post("user", {
|
||||||
|
json: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
let isFormErrorSet = false;
|
||||||
|
|
||||||
|
if (err.response) {
|
||||||
|
const data = await err.response.json();
|
||||||
|
|
||||||
|
// If it's a user error, let's capture it and handle within the form.
|
||||||
|
if (USER_EXISTS_ERROR === data.message) {
|
||||||
|
setErrors({ email: "This email address already in use." });
|
||||||
|
isFormErrorSet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFormErrorSet) {
|
||||||
|
onFailure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched }) => (
|
||||||
|
<Form className="flex flex-col gap-4">
|
||||||
|
<div className="mt-4 mb-8">
|
||||||
|
<h3>Create your free account</h3>
|
||||||
|
<p className="font-light font-sans text-lg">Includes 100 GB storage at basic speed</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
label="Email address"
|
||||||
|
error={errors.email}
|
||||||
|
touched={touched.email}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
error={errors.password}
|
||||||
|
touched={touched.password}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm password"
|
||||||
|
error={errors.confirmPassword}
|
||||||
|
touched={touched.confirmPassword}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-center mt-4">
|
||||||
|
<Button type="submit" className="px-12" $primary>
|
||||||
|
Sign up!
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
|
||||||
|
SignUpForm.propTypes = {
|
||||||
|
onFailure: PropTypes.func.isRequired,
|
||||||
|
onSuccess: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -3,10 +3,10 @@ import styled from "styled-components";
|
||||||
import { SWRConfig } from "swr";
|
import { SWRConfig } from "swr";
|
||||||
|
|
||||||
import { UserProvider } from "../contexts/user";
|
import { UserProvider } from "../contexts/user";
|
||||||
import { guestsOnly } from "../lib/swrConfig";
|
import { guestsOnly, allUsers } from "../lib/swrConfig";
|
||||||
|
|
||||||
const Layout = styled.div.attrs({
|
const Layout = styled.div.attrs({
|
||||||
className: "h-screen w-screen bg-black flex",
|
className: "min-h-screen w-screen bg-black flex",
|
||||||
})`
|
})`
|
||||||
background-image: url(/images/auth-bg.svg);
|
background-image: url(/images/auth-bg.svg);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
@ -21,10 +21,12 @@ const Content = styled.div.attrs({
|
||||||
className: "w-full md:w-5/12 md:max-w-[680px] shrink-0",
|
className: "w-full md:w-5/12 md:max-w-[680px] shrink-0",
|
||||||
})``;
|
})``;
|
||||||
|
|
||||||
const AuthLayout = ({ children }) => {
|
const AuthLayout =
|
||||||
|
(swrConfig) =>
|
||||||
|
({ children }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SWRConfig value={guestsOnly}>
|
<SWRConfig value={swrConfig}>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<Layout>
|
<Layout>
|
||||||
<SloganContainer className="pl-20 pr-20 lg:pr-30 xl:pr-40">
|
<SloganContainer className="pl-20 pr-20 lg:pr-30 xl:pr-40">
|
||||||
|
@ -42,4 +44,7 @@ const AuthLayout = ({ children }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuthLayout;
|
// Some pages (e.g. email confirmation) need to be accessible to both logged-in and guest users.
|
||||||
|
export const AllUsersAuthLayout = AuthLayout(allUsers);
|
||||||
|
|
||||||
|
export default AuthLayout(guestsOnly);
|
||||||
|
|
|
@ -23,6 +23,10 @@ const redirectAuthenticated = (key) =>
|
||||||
return response.json();
|
return response.json();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const allUsers = {
|
||||||
|
fetcher: (key) => fetch(`${baseUrl}/${key}`).then((response) => response.json()),
|
||||||
|
};
|
||||||
|
|
||||||
export const authenticatedOnly = {
|
export const authenticatedOnly = {
|
||||||
fetcher: redirectUnauthenticated,
|
fetcher: redirectUnauthenticated,
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,8 +9,8 @@ const LoginPage = ({ location }) => {
|
||||||
const redirectTo = query.get("return_to");
|
const redirectTo = query.get("return_to");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white px-8 py-10 md:py-32 lg:px-16 xl:px-28 h-screen">
|
<div className="bg-white px-8 py-10 md:py-32 lg:px-16 xl:px-28 min-h-screen">
|
||||||
<div className="mb-16">
|
<div className="mb-4 md:mb-16">
|
||||||
<img src="/images/logo-black-text.svg" alt="Skynet" />
|
<img src="/images/logo-black-text.svg" alt="Skynet" />
|
||||||
</div>
|
</div>
|
||||||
<LoginForm onSuccess={() => navigate(redirectTo || "/")} />
|
<LoginForm onSuccess={() => navigate(redirectTo || "/")} />
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import AuthLayout from "../../layouts/AuthLayout";
|
||||||
|
|
||||||
|
import { RecoveryForm } from "../../components/forms/RecoveryForm";
|
||||||
|
import HighlightedLink from "../../components/HighlightedLink";
|
||||||
|
|
||||||
|
const State = {
|
||||||
|
Pure: "PURE",
|
||||||
|
Success: "SUCCESS",
|
||||||
|
Failure: "FAILURE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResetPasswordPage = () => {
|
||||||
|
const [state, setState] = useState(State.Pure);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white px-8 py-10 md:py-32 lg:px-16 xl:px-28 min-h-screen">
|
||||||
|
<div className="mb-4 md:mb-16">
|
||||||
|
<img src="/images/logo-black-text.svg" alt="Skynet" />
|
||||||
|
</div>
|
||||||
|
{state !== State.Success && (
|
||||||
|
<RecoveryForm onSuccess={() => setState(State.Success)} onFailure={() => setState(State.Failure)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === State.Success && (
|
||||||
|
<p className="text-primary text-center font-semibold">Please check your inbox for further instructions.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === State.Failure && (
|
||||||
|
<p className="text-error text-center">Something went wrong, please try again later.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm text-center mt-8">
|
||||||
|
<p>
|
||||||
|
Suddenly remembered your password? <HighlightedLink to="/auth/login">Sign in</HighlightedLink>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Don't actually have an account? <HighlightedLink to="/auth/signup">Create one!</HighlightedLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ResetPasswordPage.Layout = AuthLayout;
|
||||||
|
|
||||||
|
export default ResetPasswordPage;
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import AuthLayout from "../../layouts/AuthLayout";
|
||||||
|
|
||||||
|
import HighlightedLink from "../../components/HighlightedLink";
|
||||||
|
import { SignUpForm } from "../../components/forms/SignUpForm";
|
||||||
|
import { navigate } from "gatsby";
|
||||||
|
|
||||||
|
const State = {
|
||||||
|
Pure: "PURE",
|
||||||
|
Success: "SUCCESS",
|
||||||
|
Failure: "FAILURE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SignUpPage = () => {
|
||||||
|
const [state, setState] = useState(State.Pure);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === State.Success) {
|
||||||
|
const timer = setTimeout(() => navigate("/"), 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white px-8 py-10 md:py-32 lg:px-16 xl:px-28 min-h-screen">
|
||||||
|
<div className="mb-4 md:mb-16">
|
||||||
|
<img src="/images/logo-black-text.svg" alt="Skynet" />
|
||||||
|
</div>
|
||||||
|
{state !== State.Success && (
|
||||||
|
<SignUpForm onSuccess={() => setState(State.Success)} onFailure={() => setState(State.Failure)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === State.Success && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-primary font-semibold">Please check your inbox and confirm your email address.</p>
|
||||||
|
<p>You will be redirected to your dashboard shortly.</p>
|
||||||
|
<HighlightedLink to="/">Click here to go there now.</HighlightedLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === State.Failure && (
|
||||||
|
<p className="text-error text-center">Something went wrong, please try again later.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-center mt-8">
|
||||||
|
Already have an account? <HighlightedLink to="/auth/login">Sign in</HighlightedLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SignUpPage.Layout = AuthLayout;
|
||||||
|
|
||||||
|
export default SignUpPage;
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { navigate } from "gatsby";
|
||||||
|
|
||||||
|
import { AllUsersAuthLayout } from "../../layouts/AuthLayout";
|
||||||
|
|
||||||
|
import HighlightedLink from "../../components/HighlightedLink";
|
||||||
|
import accountsService from "../../services/accountsService";
|
||||||
|
|
||||||
|
const State = {
|
||||||
|
Pure: "PURE",
|
||||||
|
Success: "SUCCESS",
|
||||||
|
Failure: "FAILURE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmailConfirmationPage = ({ location }) => {
|
||||||
|
const query = new URLSearchParams(location.search);
|
||||||
|
const token = query.get("token");
|
||||||
|
|
||||||
|
const [state, setState] = useState(State.Pure);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer;
|
||||||
|
|
||||||
|
async function confirm(token) {
|
||||||
|
try {
|
||||||
|
await accountsService.get("user/confirm", {
|
||||||
|
signal: controller.signal,
|
||||||
|
searchParams: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
navigate("/");
|
||||||
|
}, 3000);
|
||||||
|
setState(State.Success);
|
||||||
|
} catch (err) {
|
||||||
|
// Don't show an error message if request was aborted due to `token` changing.
|
||||||
|
if (err.code !== DOMException.ABORT_ERR) {
|
||||||
|
setState(State.Failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
confirm(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white px-8 py-10 md:py-32 lg:px-16 xl:px-28 min-h-screen">
|
||||||
|
<div className="mb-4 md:mb-16">
|
||||||
|
<img src="/images/logo-black-text.svg" alt="Skynet" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
{state === State.Pure && <p>Please wait while we verify your account...</p>}
|
||||||
|
|
||||||
|
{state === State.Success && (
|
||||||
|
<>
|
||||||
|
<p className="text-primary font-semibold">All done!</p>
|
||||||
|
<p>You will be redirected to your dashboard shortly.</p>
|
||||||
|
<HighlightedLink to="/">Redirect now.</HighlightedLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === State.Failure && <p className="text-error">Something went wrong, please try again later.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EmailConfirmationPage.Layout = AllUsersAuthLayout;
|
||||||
|
|
||||||
|
export default EmailConfirmationPage;
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { navigate } from "gatsby";
|
||||||
|
|
||||||
|
import AuthLayout from "../../layouts/AuthLayout";
|
||||||
|
|
||||||
|
import { ResetPasswordForm } from "../../components/forms/ResetPasswordForm";
|
||||||
|
import HighlightedLink from "../../components/HighlightedLink";
|
||||||
|
|
||||||
|
const State = {
|
||||||
|
Pure: "PURE",
|
||||||
|
Success: "SUCCESS",
|
||||||
|
Failure: "FAILURE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecoverPage = ({ location }) => {
|
||||||
|
const query = new URLSearchParams(location.search);
|
||||||
|
const token = query.get("token");
|
||||||
|
|
||||||
|
const [state, setState] = useState(State.Pure);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white px-8 py-10 md:py-32 lg:px-16 xl:px-28 min-h-screen">
|
||||||
|
<div className="mb-4 md:mb-16">
|
||||||
|
<img src="/images/logo-black-text.svg" alt="Skynet" />
|
||||||
|
</div>
|
||||||
|
{state !== State.Success && (
|
||||||
|
<ResetPasswordForm
|
||||||
|
token={token}
|
||||||
|
onSuccess={() => {
|
||||||
|
setState(State.Success);
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
onFailure={() => setState(State.Failure)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === State.Success && (
|
||||||
|
<p className="text-primary text-center font-semibold">
|
||||||
|
All done! You will be redirected to your dashboard shortly.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === State.Failure && (
|
||||||
|
<p className="text-error text-center">Something went wrong, please try again later.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-center mt-8">
|
||||||
|
Suddenly remembered your old password? <HighlightedLink to="/auth/login">Sign in</HighlightedLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
RecoverPage.Layout = AuthLayout;
|
||||||
|
|
||||||
|
export default RecoverPage;
|
Reference in New Issue