web: refactor and cleanup auth & recovery

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
Abdullah Atta
2026-08-07 12:54:42 +05:00
committed by 01zulfi
parent c01ae6f158
commit a037e772db
5 changed files with 1907 additions and 2080 deletions

View File

@@ -49,7 +49,6 @@ import { getQueryParams, hardNavigate, makeURL } from "../navigation";
import { store as userstore } from "../stores/user-store";
import { db } from "../common/db";
import Config from "../utils/config";
import { Loader } from "../components/loader";
import { showToast } from "../utils/toast";
import AuthContainer from "../components/auth-container";
import { useTimer } from "../hooks/use-timer";
@@ -242,10 +241,6 @@ function Login(props: BaseAuthComponentProps<"login">) {
action={{ text: strings.signUp(), onClick: () => navigate("signup") }}
/>
}
loading={{
title: strings.loggingIn(),
subtitle: strings.authWait()
}}
onSubmit={async (form) => {
if (!isEmail(form.email)) throw new Error(strings.emailInvalid());
@@ -268,7 +263,7 @@ function Login(props: BaseAuthComponentProps<"login">) {
});
}}
>
{(form?: LoginFormData) => (
{(form, options) => (
<>
{IS_BETA ? (
<Flex
@@ -312,7 +307,14 @@ function Login(props: BaseAuthComponentProps<"login">) {
>
{strings.forgotPassword()}
</Button>
<SubmitButton text={strings.loginToYourAccount()} />
<SubmitButton
loading={options?.loading}
text={
options?.loading
? strings.loggingIn()
: strings.loginToYourAccount()
}
/>
</>
)}
</AuthForm>
@@ -337,10 +339,6 @@ function Signup(props: BaseAuthComponentProps<"signup">) {
}}
/>
}
loading={{
title: strings.creatingAccount(),
subtitle: strings.creatingAccountDesc()
}}
openURL={openURL}
onSubmit={async (form) => {
if (!isEmail(form.email)) throw new Error(strings.emailInvalid());
@@ -353,7 +351,7 @@ function Signup(props: BaseAuthComponentProps<"signup">) {
openURL("/plans", { authenticated: true });
}}
>
{(form?: SignupFormData) => (
{(form, options) => (
<>
<AuthField
id="email"
@@ -377,7 +375,14 @@ function Signup(props: BaseAuthComponentProps<"signup">) {
label={strings.confirmPassword()}
defaultValue={form?.["confirm-password"]}
/>
<SubmitButton text={strings.createAccount()} />
<SubmitButton
loading={options?.loading}
text={
options?.loading
? strings.creatingAccount()
: strings.createAccount()
}
/>
</>
)}
</AuthForm>
@@ -411,10 +416,6 @@ function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
</Text>
</Flex>
}
loading={{
title: strings.loggingIn(),
subtitle: strings.pleaseWaitLogin()
}}
openURL={openURL}
onSubmit={async (form) => {
if (!user) return;
@@ -440,59 +441,70 @@ function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
});
}}
>
<AuthField
id="email"
type="email"
autoComplete={"false"}
label={strings.enterEmailAddress()}
placeholder={user ? maskEmail(user.email) : undefined}
autoFocus
disabled
required={false}
/>
<AuthField
id="password"
type="password"
autoComplete={"false"}
label={strings.enterPassword()}
autoFocus
/>
<Button
data-test-id="auth-forgot-password"
type="button"
mt={2}
variant="anchor"
onClick={() => user && navigate("recover", { email: user.email })}
sx={{ color: "paragraph", alignSelf: "end" }}
>
{strings.forgotPassword()}
</Button>
<SubmitButton text={strings.reloginToYourAccount()} />
<Button
type="button"
variant="anchor"
sx={{
mt: 5,
color: "paragraph-error",
textDecoration: "none",
":hover": {
color: "var(--paragraph-error)"
}
}}
onClick={async () => {
if (await showLogoutConfirmation()) {
await TaskManager.startTask({
type: "modal",
title: strings.loggingOut(),
action: () => db.user.logout(true),
subtitle: strings.loggingOutDesc()
});
navigate("login");
}
}}
>
{strings.logout()}
</Button>
{(_, options) => (
<>
<AuthField
id="email"
type="email"
autoComplete={"false"}
label={strings.enterEmailAddress()}
placeholder={user ? maskEmail(user.email) : undefined}
autoFocus
disabled
required={false}
/>
<AuthField
id="password"
type="password"
autoComplete={"false"}
label={strings.enterPassword()}
autoFocus
/>
<Button
data-test-id="auth-forgot-password"
type="button"
mt={2}
variant="anchor"
onClick={() => user && navigate("recover", { email: user.email })}
sx={{ color: "paragraph", alignSelf: "end" }}
>
{strings.forgotPassword()}
</Button>
<SubmitButton
loading={options?.loading}
text={
options?.loading
? strings.loggingIn()
: strings.reloginToYourAccount()
}
/>
<Button
type="button"
variant="anchor"
sx={{
mt: 5,
color: "paragraph-error",
textDecoration: "none",
":hover": {
color: "var(--paragraph-error)"
}
}}
onClick={async () => {
if (await showLogoutConfirmation()) {
await TaskManager.startTask({
type: "modal",
title: strings.loggingOut(),
action: () => db.user.logout(true),
subtitle: strings.loggingOutDesc()
});
navigate("login");
}
}}
>
{strings.logout()}
</Button>
</>
)}
</AuthForm>
);
}
@@ -737,10 +749,6 @@ function MFACode(props: BaseAuthComponentProps<"mfa:code">) {
type="mfa:code"
title={strings["2fa"]()}
subtitle={texts.subtitle}
loading={{
title: strings.verifying2faCode(),
subtitle: strings.authWait()
}}
openURL={openURL}
onSubmit={async (form) => {
const code = selectedMethod !== "recoveryCode" ? otpValue : form.code;
@@ -761,34 +769,115 @@ function MFACode(props: BaseAuthComponentProps<"mfa:code">) {
});
}}
>
{selectedMethod !== "recoveryCode" ? (
<OtpInput
length={6}
value={otpValue}
onChange={setOtpValue}
autoFocus
/>
) : (
<AuthField id="code" type="text" label={texts.label} autoFocus />
)}
<SubmitButton text={strings.submit()} />
<Flex
sx={{
flexDirection: "column",
my: "spacing7",
gap: "spacing4"
}}
>
{(selectedMethod === "sms" || selectedMethod === "email") && (
<>
<Flex
{(_, options) => (
<>
{selectedMethod !== "recoveryCode" ? (
<OtpInput
length={6}
value={otpValue}
onChange={setOtpValue}
autoFocus
/>
) : (
<AuthField id="code" type="text" label={texts.label} autoFocus />
)}
<SubmitButton
loading={options?.loading}
text={
options?.loading ? strings.verifying2faCode() : strings.submit()
}
/>
<Flex
sx={{
flexDirection: "column",
my: "spacing7",
gap: "spacing4"
}}
>
{(selectedMethod === "sms" || selectedMethod === "email") && (
<>
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
background: "background-secondary",
p: "spacing5",
borderRadius: "radius2"
}}
>
<Flex sx={{ alignItems: "center", gap: "10px" }}>
<Flex
sx={{
bg: "background-tertiary",
borderRadius: "5px",
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
flexShrink: 0
}}
>
<Clock size={15} color="icon" />
</Flex>
<Flex sx={{ flexDirection: "column" }}>
<Text
sx={{
fontSize: "sm",
fontWeight: 500,
color: "heading"
}}
>
{"Didn't receive code?"}
</Text>
{!enabled && (
<Text
sx={{
color: "paragraph",
fontSize: "xs",
fontWeight: 400
}}
>
{`${strings.resendCodeWait()} `}
<Text as="span" sx={{ color: "accent" }}>
{elapsed}s
</Text>
</Text>
)}
</Flex>
</Flex>
<Button
type="button"
variant="anchor"
disabled={isSending || !enabled}
onClick={() => sendCode(selectedMethod as "sms" | "email")}
sx={{
fontSize: "xs",
fontWeight: 500,
color:
enabled && !isSending
? "accent"
: "paragraph-secondary",
textDecoration: "none"
}}
>
{isSending ? <Loading size={18} /> : strings.resendCode()}
</Button>
</Flex>
</>
)}
<Button
type="button"
variant="secondary"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "background-secondary",
p: "spacing5",
textAlign: "left",
borderRadius: "radius2"
}}
onClick={() => navigate("mfa:select", formData)}
>
<Flex sx={{ alignItems: "center", gap: "10px" }}>
<Flex
@@ -802,91 +891,27 @@ function MFACode(props: BaseAuthComponentProps<"mfa:code">) {
flexShrink: 0
}}
>
<Clock size={15} color="icon" />
</Flex>
<Flex sx={{ flexDirection: "column" }}>
<Text
sx={{ fontSize: "sm", fontWeight: 500, color: "heading" }}
>
{"Didn't receive code?"}
</Text>
{!enabled && (
<Text
sx={{
color: "paragraph",
fontSize: "xs",
fontWeight: 400
}}
>
{`${strings.resendCodeWait()} `}
<Text as="span" sx={{ color: "accent" }}>
{elapsed}s
</Text>
</Text>
{selectedMethod === "email" ? (
<MfaEmail size={16} color="icon" />
) : selectedMethod === "sms" ? (
<MfaSms size={16} color="icon" />
) : selectedMethod === "app" ? (
<MfaAuthenticator size={16} color="icon" />
) : (
<MfaRecoveryCode size={16} color="icon" />
)}
</Flex>
<Text
sx={{ fontWeight: 500, fontSize: "sm", color: "heading" }}
>
{texts.selector}
</Text>
</Flex>
<Button
type="button"
variant="anchor"
disabled={isSending || !enabled}
onClick={() => sendCode(selectedMethod as "sms" | "email")}
sx={{
fontSize: "xs",
fontWeight: 500,
color:
enabled && !isSending ? "accent" : "paragraph-secondary",
textDecoration: "none"
}}
>
{isSending ? <Loading size={18} /> : strings.resendCode()}
</Button>
</Flex>
</>
)}
<Button
type="button"
variant="secondary"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: "spacing5",
textAlign: "left",
borderRadius: "radius2"
}}
onClick={() => navigate("mfa:select", formData)}
>
<Flex sx={{ alignItems: "center", gap: "10px" }}>
<Flex
sx={{
bg: "background-tertiary",
borderRadius: "5px",
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
flexShrink: 0
}}
>
{selectedMethod === "email" ? (
<MfaEmail size={16} color="icon" />
) : selectedMethod === "sms" ? (
<MfaSms size={16} color="icon" />
) : selectedMethod === "app" ? (
<MfaAuthenticator size={16} color="icon" />
) : (
<MfaRecoveryCode size={16} color="icon" />
)}
</Flex>
<Text sx={{ fontWeight: 500, fontSize: "sm", color: "heading" }}>
{texts.selector}
</Text>
<ChevronRight size={13} color="icon" sx={{ flexShrink: 0 }} />
</Button>
</Flex>
<ChevronRight size={13} color="icon" sx={{ flexShrink: 0 }} />
</Button>
</Flex>
</>
)}
</AuthForm>
);
}
@@ -946,10 +971,6 @@ function MFASelector(props: BaseAuthComponentProps<"mfa:select">) {
type="mfa:select"
title={strings.select2faMethod()}
subtitle={strings.select2faCodeHelpText()}
loading={{
title: strings.loggingIn(),
subtitle: strings.authWait()
}}
openURL={openURL}
onSubmit={async () => {}}
onBack={() => navigate("mfa:code", formData)}
@@ -1035,13 +1056,17 @@ function MFASelector(props: BaseAuthComponentProps<"mfa:select">) {
// function MFAMethodSelector(params) {}
type AuthFormProps<TType extends AuthRoutes> = {
export type AuthFormContainerProps<
TType extends string,
TFormData extends Record<TType, Record<string, any>>
> = {
testId?: string;
title: string;
subtitle: string | JSX.Element;
// loading: { title: string; subtitle: string };
type: TType;
onSubmit: (form: AuthFormData[TType]) => Promise<void>;
openURL: OpenURLFunction;
onSubmit: (form: TFormData[TType]) => Promise<void>;
openURL?: OpenURLFunction;
loadForever?: boolean;
canSkip?: boolean;
showAgreement?: boolean;
@@ -1049,12 +1074,21 @@ type AuthFormProps<TType extends AuthRoutes> = {
children?:
| React.ReactNode
| ((
form?: AuthFormData[TType],
form?: TFormData[TType],
options?: { loading?: boolean }
) => React.ReactNode);
};
export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
function AuthForm<T extends AuthRoutes>(
props: AuthFormContainerProps<T, AuthFormData>
) {
return <AuthFormContainer {...props} />;
}
export function AuthFormContainer<
TType extends string,
TFormData extends Record<TType, Record<string, any>>
>(props: AuthFormContainerProps<TType, TFormData>) {
const {
title,
subtitle,
@@ -1068,7 +1102,7 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>();
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState<AuthFormData[T] | undefined>();
const [form, setForm] = useState<TFormData[TType] | undefined>();
// if (isSubmitting)
// return <Loader title={props.loading.title} text={props.loading.subtitle} />;
@@ -1088,7 +1122,7 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
const formData = new FormData(formRef.current);
const form = Object.fromEntries(
formData.entries()
) as AuthFormData[T];
) as TFormData[TType];
try {
setForm(form);
await props.onSubmit(form);
@@ -1137,7 +1171,8 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
sx={{
fontSize: "sm",
fontWeight: 600,
color: "heading"
color: "heading",
lineHeight: "100%"
}}
>
{strings.goBack()}
@@ -1198,7 +1233,7 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
? children(form, { loading: isSubmitting })
: children}
{canSkip && (
{canSkip && openURL && (
<Button
type="button"
variant="new_bordered"

View File

@@ -17,22 +17,26 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useMemo, useRef, useState, Suspense } from "react";
import React, { useEffect, useMemo, useState, Suspense } from "react";
import { Button, Flex, Text } from "@theme-ui/components";
import { makeURL, useQueryParams } from "../navigation";
import { db } from "../common/db";
import { Loader } from "../components/loader";
import { showToast } from "../utils/toast";
import AuthContainer from "../components/auth-container";
import { AuthField, AuthFormContext, SubmitButton } from "./auth";
import {
AuthField,
AuthFormContainer,
AuthFormContainerProps,
SubmitButton
} from "./auth";
import Config from "../utils/config";
import { EVENTS, User } from "@notesnook/core";
import { User } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useKeyStore } from "../interfaces/key-store";
import { ScrollContainer } from "@notesnook/ui";
import Logo from "../assets/notesnook-logo.png";
import {
ChevronLeft,
KeyIcon,
Trash,
CheckCircle,
@@ -119,13 +123,18 @@ function useAuthenticateUser({
code,
userId
}: {
code: string;
userId: string;
code?: string;
userId?: string;
}) {
const [isAuthenticating, setIsAuthenticating] = useState(true);
const [user, setUser] = useState<User>();
useEffect(() => {
async function authenticateUser() {
if (!code || !userId) {
openURL("/");
return;
}
setIsAuthenticating(true);
try {
const accessToken = await db.tokenManager.getAccessToken();
@@ -346,18 +355,6 @@ function RecoveryMethods(props: BaseRecoveryComponentProps<"methods">) {
function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
const { navigate, formData } = props;
const [progress, setProgress] = useState(0);
useEffect(() => {
db.eventManager.subscribe(
EVENTS.syncProgress,
({ type, current }: { type: string; current: number }) => {
if (type === "download") {
setProgress(current);
}
}
);
}, []);
return (
<RecoveryForm
@@ -365,18 +362,12 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
type="method:key"
title={strings.accountRecovery()}
subtitle={strings.accountRecoveryWithKey()}
loading={{
title: strings.network.downloading(progress),
subtitle: strings.keyRecoveryProgressDesc()
}}
onBack={() => navigate("methods")}
onSubmit={async (form) => {
const recoveryKey = form.recoveryKey;
// if (recoveryKey.length < 40) {
// throw new Error(strings.invalidRecoveryKey());
// }
setProgress(0);
if (recoveryKey.length < 40) {
throw new Error(strings.invalidRecoveryKey());
}
const user = await db.user.getUser();
if (!user) throw new Error(strings.notLoggedIn());
@@ -415,16 +406,6 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
function NewPassword(props: BaseRecoveryComponentProps<"new">) {
const { navigate, formData } = props;
const [progress, setProgress] = useState(0);
useEffect(() => {
db.eventManager.subscribe(
EVENTS.syncProgress,
({ current }: { current: number }) => {
setProgress(current);
}
);
}, []);
return (
<RecoveryForm
@@ -432,10 +413,6 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
type="new"
title={strings.resetAccountPassword()}
subtitle={strings.accountPassDesc()}
loading={{
title: strings.resettingAccountPassword(progress),
subtitle: strings.resetPasswordWait()
}}
onBack={() =>
navigate(
formData?.userResetRequired ? "methods" : "method:key",
@@ -444,16 +421,14 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
}
onSubmit={async (form) => {
try {
setProgress(0);
if (form.password !== form.confirmPassword)
throw new Error("Passwords do not match.");
// if (formData?.userResetRequired && !(await db.user.resetUser()))
// throw new Error("Failed to reset user.");
if (formData?.userResetRequired && !(await db.user.resetUser()))
throw new Error("Failed to reset user.");
// if (!(await db.user.resetPassword(form.password)))
// throw new Error("Could not reset account password.");
if (!(await db.user.resetPassword(form.password)))
throw new Error("Could not reset account password.");
navigate("final");
} catch (e) {
@@ -468,7 +443,7 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
}
}}
>
{(form?: NewPasswordFormData) => (
{(form, options) => (
<>
<AuthField
id="password"
@@ -484,7 +459,14 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
label={strings.confirmPassword()}
defaultValue={form?.confirmPassword}
/>
<SubmitButton text={strings.continue()} />
<SubmitButton
loading={options?.loading}
text={
options?.loading
? strings.resettingAccountPassword()
: strings.continue()
}
/>
</>
)}
</RecoveryForm>
@@ -888,153 +870,10 @@ function Final(_props: BaseRecoveryComponentProps<"final">) {
);
}
type RecoveryFormProps<TType extends RecoveryRoutes> = {
testId: string;
title: string;
subtitle: string | JSX.Element;
loading?: { title: string; subtitle: string };
type: TType;
onSubmit: (form: RecoveryFormData[TType]) => Promise<void>;
children?:
| React.ReactNode
| ((form?: RecoveryFormData[TType]) => React.ReactNode);
onBack?: () => void;
};
export function RecoveryForm<T extends RecoveryRoutes>(
props: RecoveryFormProps<T>
function RecoveryForm<T extends RecoveryRoutes>(
props: AuthFormContainerProps<T, RecoveryFormData>
) {
const { title, subtitle, children, testId, onBack } = props;
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>();
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState<RecoveryFormData[T] | undefined>();
if (isSubmitting && props.loading)
return <Loader title={props.loading.title} text={props.loading.subtitle} />;
return (
<AuthFormContext.Provider value={{ error }}>
<Flex
ref={formRef}
data-test-id={testId}
as="form"
id="authForm"
onSubmit={async (e) => {
if (!formRef.current) return;
e.preventDefault();
setError("");
setIsSubmitting(true);
const formData = new FormData(formRef.current);
const form = Object.fromEntries(
formData.entries()
) as RecoveryFormData[T];
try {
setForm(form);
await props.onSubmit(form);
} catch (e) {
console.error(e);
const error = e as Error;
setError(error.message);
} finally {
setIsSubmitting(false);
}
}}
sx={{
flex: 1,
flexDirection: "column",
alignItems: "left",
width: ["95%", "95%", "65%"],
maxWidth: "500px",
alignSelf: "center",
mt: 100
}}
>
{onBack && (
<Button
type="button"
variant="new_bordered"
onClick={onBack}
sx={{
background: "background",
borderRadius: "radius2",
display: "flex",
alignItems: "center",
gap: "spacing3",
px: "spacing6",
py: "spacing5",
alignSelf: "flex-start",
mb: "spacing9",
border: "1px solid var(--border)"
}}
>
<ChevronLeft size={14} color="icon" />
<Text
sx={{
fontSize: "sm",
fontWeight: 600,
color: "heading"
}}
>
{strings.goBack()}
</Text>
</Button>
)}
<Flex
sx={{
mb: "spacing13",
alignItems: "center",
gap: "spacing4"
}}
>
<svg
style={{
borderRadius: "default",
height: 30,
width: 30,
alignSelf: "center"
}}
>
<use href="#full-logo" />
</svg>
<Text
sx={{
fontSize: "2xl",
fontWeight: 600,
color: "heading"
}}
>
Notesnook
</Text>
</Flex>
<Text
sx={{
fontSize: "xl",
textAlign: "left",
fontWeight: 600,
color: "heading"
}}
>
{title}
</Text>
<Text
sx={{
mt: "spacing3",
mb: "spacing7",
fontSize: "sm",
textAlign: "left",
color: "paragraph",
fontWeight: 400
}}
>
{subtitle}
</Text>
{typeof children === "function" ? children(form) : children}
</Flex>
</AuthFormContext.Provider>
);
return <AuthFormContainer {...props} />;
}
function openURL(url: string) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1975,8 +1975,7 @@ All attachments will be downloaded & cached again on access.
backingUpDataWait: () =>
t`We are creating a backup of your data. Please wait...`,
resetAccountPassword: () => t`Reset account password`,
resettingAccountPassword: (progress: number) =>
t`Resetting account password (${progress})`,
resettingAccountPassword: () => t`Resetting account password...`,
resetPasswordWait: () => t`Please wait while we reset your account password.`,
recoverySuccess: () => t`Recovery successful!`,
recoverySuccessDesc: () => t`Your account has been recovered.`,