mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-24 12:12:54 +01:00
Merge branch 'release/1.8.3'
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"name": "@notesnook/desktop",
|
||||
"productName": "Notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "1.8.2",
|
||||
"version": "1.8.3",
|
||||
"private": true,
|
||||
"main": "./build/electron.js",
|
||||
"homepage": "https://notesnook.com/",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "1.8.2",
|
||||
"version": "1.8.3",
|
||||
"private": true,
|
||||
"main": "./src/App.js",
|
||||
"homepage": "https://notesnook.com/",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@rebass/forms": "^4.0.6",
|
||||
"@streetwriters/tinymce-plugins": "^1.5.17",
|
||||
"@tinymce/tinymce-react": "^3.13.0",
|
||||
"@types/rebass": "^4.0.10",
|
||||
"async-mutex": "^0.3.2",
|
||||
"axios": "^0.21.4",
|
||||
"clipboard-polyfill": "^3.0.3",
|
||||
@@ -37,6 +38,7 @@
|
||||
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
|
||||
"nncryptoworker": "file:packages/nncryptoworker",
|
||||
"notes-core": "npm:@streetwriters/notesnook-core@latest",
|
||||
"phone": "^3.1.14",
|
||||
"platform": "^1.3.6",
|
||||
"print-js": "^1.6.0",
|
||||
"qclone": "^1.0.4",
|
||||
|
||||
7
apps/web/src/assets/fallback2fa.svg
Normal file
7
apps/web/src/assets/fallback2fa.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 717.67 430.74">
|
||||
<path fill="#f2f2f2" d="M120.43 410.02a2.8 2.8 0 0 1-2.04-4.87l.2-.76-.08-.18a7.54 7.54 0 0 0-13.9.05c-2.28 5.48-5.18 10.96-5.89 16.75a22.3 22.3 0 0 0 .4 7.68 89.42 89.42 0 0 1-8.14-37.14 86.3 86.3 0 0 1 .53-9.63q.45-3.93 1.23-7.8a90.46 90.46 0 0 1 17.94-38.35 24.07 24.07 0 0 0 10.01-10.38 18.36 18.36 0 0 0 1.67-5.02c-.48.06-1.83-7.36-1.47-7.82-.68-1.03-1.9-1.54-2.63-2.54-3.7-5-8.78-4.13-11.43 2.66-5.67 2.87-5.72 7.61-2.24 12.17 2.2 2.9 2.51 6.84 4.45 9.94l-.6.76a91.04 91.04 0 0 0-9.5 15.06 37.85 37.85 0 0 0-2.26-17.58c-2.17-5.22-6.22-9.61-9.8-14.12-4.28-5.42-13.07-3.06-13.83 3.81l-.02.2q.8.45 1.56.95a3.8 3.8 0 0 1-1.54 6.93l-.07.01a37.89 37.89 0 0 0 1 5.67c-4.58 17.71 5.3 24.16 19.42 24.45.31.16.61.32.93.47a92.92 92.92 0 0 0-5 23.54 88.14 88.14 0 0 0 .06 14.23l-.03-.17a23.29 23.29 0 0 0-7.95-13.44c-6.11-5.03-14.76-6.88-21.36-10.92a4.37 4.37 0 0 0-6.7 4.25l.03.18a25.58 25.58 0 0 1 2.87 1.38q.8.45 1.56.95a3.8 3.8 0 0 1-1.54 6.93l-.07.01-.16.03A37.92 37.92 0 0 0 63 399.28c2.87 15.46 15.16 16.93 28.32 12.43a92.9 92.9 0 0 0 6.25 18.21h22.3l.21-.75a25.33 25.33 0 0 1-6.16-.36c1.65-2.03 3.3-4.08 4.96-6.1a1.39 1.39 0 0 0 .1-.13l2.53-3.1a37.1 37.1 0 0 0-1.09-9.46Zm465.62 0a2.8 2.8 0 0 0 2.04-4.87l-.2-.76.08-.18a7.54 7.54 0 0 1 13.9.05c2.28 5.48 5.18 10.96 5.89 16.75a22.3 22.3 0 0 1-.4 7.68 89.42 89.42 0 0 0 8.14-37.14 86.3 86.3 0 0 0-.53-9.63q-.45-3.93-1.23-7.8a90.46 90.46 0 0 0-17.94-38.35 24.07 24.07 0 0 1-10.01-10.38 18.36 18.36 0 0 1-1.67-5.02c.48.06 1.84-7.36 1.47-7.82.68-1.03 1.9-1.54 2.63-2.54 3.7-5 8.78-4.13 11.43 2.66 5.67 2.87 5.72 7.61 2.25 12.17-2.22 2.9-2.52 6.84-4.46 9.94l.6.76a91.04 91.04 0 0 1 9.5 15.06 37.85 37.85 0 0 1 2.27-17.58c2.16-5.22 6.21-9.61 9.78-14.12 4.29-5.42 13.08-3.06 13.84 3.81l.02.2q-.8.45-1.56.95a3.8 3.8 0 0 0 1.54 6.93l.08.01a37.89 37.89 0 0 1-1 5.67c4.58 17.71-5.31 24.16-19.43 24.45l-.92.47a92.93 92.93 0 0 1 5 23.54 88.14 88.14 0 0 1-.07 14.23l.03-.17a23.29 23.29 0 0 1 7.95-13.44c6.12-5.03 14.76-6.88 21.36-10.92a4.37 4.37 0 0 1 6.7 4.25l-.03.18a25.58 25.58 0 0 0-2.87 1.38q-.8.45-1.56.95a3.8 3.8 0 0 0 1.54 6.93l.07.01.16.03a37.92 37.92 0 0 1-6.97 10.92c-2.86 15.46-15.16 16.93-28.32 12.43a92.9 92.9 0 0 1-6.25 18.21h-22.29l-.22-.75a25.33 25.33 0 0 0 6.16-.36c-1.65-2.03-3.3-4.08-4.96-6.1a1.39 1.39 0 0 1-.1-.13l-2.53-3.1a37.1 37.1 0 0 1 1.1-9.46Zm-439.98 19.4h35.97c6.16-20.71.04-69.67 8.15-89.68 15.56-38.37 35.43-78.85 72.53-97.59 15.84-8 33.11-11.12 50.75-10.95 24.37.23 49.42 6.77 72.35 15.44a672.45 672.45 0 0 1 27.94 11.4c34.88 14.99 69.39 31.32 106.9 35.73 47.48 5.58 103.29-15.66 116.25-61.4 9.9-35.03-7.92-71.4-26.61-102.68-18.7-31.28-39.58-64.75-36.17-100.96.01-.12.02-.26.05-.38 1.28-13 9.05-22.36 19.9-28.35h-47.24c-.62 33.79 18.92 63.6 36.51 93.05 18.7 31.27 36.51 67.65 26.62 102.67-12.97 45.74-120.33-7.87-167.8-13.47-13.4-1.57 7.89 4.01-4.9 0-20.04-6.28-22.26 51.37-41.77 42.9-12.15-5.27-24.3-10.51-36.63-15.17a305.14 305.14 0 0 0-15.58-5.42l-.02-.01c-35.4-11.21-74.12-15.58-106.82.63l-.68.33c-37.1 18.72-56.97 59.2-72.53 97.57-12.86 31.72-10.83 92.89-17.17 126.34Z"/>
|
||||
<circle cx="346.21" cy="263.14" r="165.22" fill="#fff"/>
|
||||
<path fill="#3f3d56" d="M345.74 429.42c-163.47 3.76-167.06-73.9-167.06-166.28 0-92.37 75.16-167.53 167.53-167.53s167.53 75.16 167.53 167.53c0 92.38-75.62 166.28-168 166.28Zm.47-329.2c-89.83 0-161.4 73.1-162.92 162.92-1.4 83.54 87.35 187.8 162.92 162.92 40.53-44.64 155.11-92.71 162.92-162.92 9.93-89.28-73.08-162.92-162.92-162.92Z"/>
|
||||
<path fill="#f2f2f2" d="M323.67 349.13a18.37 18.37 0 0 1-14.7-7.35l-45.07-60.1a18.38 18.38 0 1 1 29.4-22.06l29.5 39.32 75.73-113.6a18.38 18.38 0 0 1 30.59 20.38l-90.15 135.23a18.39 18.39 0 0 1-14.79 8.18h-.5Z"/>
|
||||
<path fill="var(--primary)" d="M317.67 347.13a18.37 18.37 0 0 1-14.7-7.35l-45.07-60.1a18.38 18.38 0 1 1 29.4-22.06l29.5 39.32 75.73-113.6a18.38 18.38 0 1 1 30.59 20.38l-90.15 135.23a18.39 18.39 0 0 1-14.79 8.18h-.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
6
apps/web/src/assets/mfa.svg
Normal file
6
apps/web/src/assets/mfa.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 382.94 405.93">
|
||||
<path fill="var(--text)" d="M192.58 405.92a75.19 75.19 0 0 1-18.64-2.41l-1.2-.33-1.12-.56c-40.24-20.18-74.19-46.83-100.9-79.21a299.86 299.86 0 0 1-50.95-90.47A348.21 348.21 0 0 1 .07 110.27l.04-2.02c0-20.29 11.26-38.09 28.7-45.35C42.13 57.34 163.24 7.6 172 4c16.48-8.26 34.06-1.36 36.87-.16 6.31 2.58 118.28 48.38 142.47 59.9 24.94 11.87 31.6 33.2 31.6 43.93 0 48.6-8.43 94-25.02 134.97a312.52 312.52 0 0 1-56.16 90.51c-45.85 51.6-91.7 69.89-92.15 70.05a50.11 50.11 0 0 1-17.04 2.72zm-10.79-26.71c3.98.89 13.13 2.22 19.1.05 7.58-2.77 45.96-22.67 81.83-63.03 49.55-55.77 74.7-125.88 74.74-208.38-.1-1.67-1.28-13.59-17.07-21.1-23.72-11.3-140.1-58.89-141.27-59.37l-.32-.14c-2.44-1.02-10.2-3.17-15.55-.37l-1.08.5c-1.3.54-129.86 53.34-143.57 59.05-9.6 4-13 13.9-13 21.83 0 .58-.02 1.43-.05 2.52-1.1 56.44 11.97 195.34 156.24 268.44z"/>
|
||||
<path fill="var(--bgSecondary)" d="M177.33 15.59S47.61 68.87 33.71 74.66c-13.9 5.79-20.85 19.7-20.85 33.6 0 13.9-10.45 195.26 164.47 282.96 0 0 15.88 4.39 27.92 0 12.04-4.39 164.96-78.52 164.96-283.55 0 0 0-20.85-24.33-32.43C321.55 63.66 203.94 15.6 203.94 15.6s-14.44-6.37-26.6 0z"/>
|
||||
<path d="M191.23 57.29v284.25S60.34 278.53 61.51 112.89z" opacity=".2"/>
|
||||
<path fill="var(--icon)" d="m192.94 261.58-41.69-53.61 24.24-18.86 19.75 25.38 66.7-70.4 22.3 21.13z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -18,24 +18,24 @@ async function initializeDatabase() {
|
||||
db = new Database(Storage, EventSource, FS);
|
||||
|
||||
// if (isTesting()) {
|
||||
db.host({
|
||||
API_HOST: "https://api.notesnook.com",
|
||||
AUTH_HOST: "https://auth.streetwriters.co",
|
||||
SSE_HOST: "https://events.streetwriters.co",
|
||||
});
|
||||
// db.host({
|
||||
// API_HOST: "https://api.notesnook.com",
|
||||
// AUTH_HOST: "https://auth.streetwriters.co",
|
||||
// SSE_HOST: "https://events.streetwriters.co",
|
||||
// });
|
||||
// } else {
|
||||
// db.host({
|
||||
// API_HOST: "http://localhost:5264",
|
||||
// AUTH_HOST: "http://localhost:8264",
|
||||
// SSE_HOST: "http://localhost:7264",
|
||||
// });
|
||||
// db.host({
|
||||
// API_HOST: "http://192.168.10.29:5264",
|
||||
// AUTH_HOST: "http://192.168.10.29:8264",
|
||||
// SSE_HOST: "http://192.168.10.29:7264",
|
||||
// ISSUES_HOST: "http://192.168.10.29:2624",
|
||||
// SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264",
|
||||
// });
|
||||
db.host({
|
||||
API_HOST: "http://192.168.10.29:5264",
|
||||
AUTH_HOST: "http://192.168.10.29:8264",
|
||||
SSE_HOST: "http://192.168.10.29:7264",
|
||||
ISSUES_HOST: "http://192.168.10.29:2624",
|
||||
SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264",
|
||||
});
|
||||
// }
|
||||
|
||||
await db.init();
|
||||
|
||||
@@ -607,6 +607,24 @@ export function showImportDialog() {
|
||||
));
|
||||
}
|
||||
|
||||
export function showMultifactorDialog(primaryMethod = "") {
|
||||
return showDialog((Dialogs, perform) => (
|
||||
<Dialogs.MultifactorDialog
|
||||
onClose={(res) => perform(res)}
|
||||
primaryMethod={primaryMethod}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function show2FARecoveryCodesDialog(primaryMethod) {
|
||||
return showDialog((Dialogs, perform) => (
|
||||
<Dialogs.RecoveryCodesDialog
|
||||
onClose={(res) => perform(res)}
|
||||
primaryMethod={primaryMethod}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function showAttachmentsDialog() {
|
||||
return showDialog((Dialogs, perform) => (
|
||||
<Dialogs.AttachmentsDialog onClose={(res) => perform(res)} />
|
||||
|
||||
@@ -3,13 +3,18 @@ import { TaskManager } from "./task-manager";
|
||||
import { zip } from "../utils/zip";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
async function exportToPDF(content: string): Promise<boolean> {
|
||||
export async function exportToPDF(
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<boolean> {
|
||||
if (!content) return false;
|
||||
const { default: printjs } = await import("print-js");
|
||||
return new Promise(async (resolve) => {
|
||||
printjs({
|
||||
printable: content,
|
||||
type: "raw-html",
|
||||
documentTitle: title,
|
||||
header: '<h3 class="custom-h3">My custom header</h3>',
|
||||
onPrintDialogClose: () => {
|
||||
resolve(false);
|
||||
},
|
||||
@@ -30,7 +35,7 @@ export async function exportNotes(
|
||||
action: async (report) => {
|
||||
if (format === "pdf") {
|
||||
const note = db.notes!.note(noteIds[0]);
|
||||
return await exportToPDF(await note.export("html", null));
|
||||
return await exportToPDF(note.title, await note.export("html", null));
|
||||
}
|
||||
|
||||
var files = [];
|
||||
|
||||
@@ -8,12 +8,11 @@ import Vault from "./vault";
|
||||
import { showItemDeletedToast } from "./toasts";
|
||||
import { TaskManager } from "./task-manager";
|
||||
|
||||
async function moveNotesToTrash(notes: any[]) {
|
||||
async function moveNotesToTrash(notes: any[], confirm = true) {
|
||||
const item = notes[0];
|
||||
const isMultiselect = notes.length > 1;
|
||||
if (isMultiselect) {
|
||||
if (!(await showMultiDeleteConfirmation(notes.length))) return;
|
||||
} else {
|
||||
if (confirm && !(await showMultiDeleteConfirmation(notes.length))) return;
|
||||
|
||||
if (notes.length === 1) {
|
||||
if (
|
||||
item.locked &&
|
||||
!(await Vault.unlockNote(item.id, "unlock_and_delete_note"))
|
||||
@@ -38,11 +37,7 @@ async function moveNotesToTrash(notes: any[]) {
|
||||
},
|
||||
});
|
||||
|
||||
if (isMultiselect) {
|
||||
showToast("success", `${items.length} notes moved to trash`);
|
||||
} else {
|
||||
showItemDeletedToast(item);
|
||||
}
|
||||
showToast("success", `${items.length} notes moved to trash`);
|
||||
}
|
||||
|
||||
async function moveNotebooksToTrash(notebooks: any[]) {
|
||||
|
||||
@@ -88,6 +88,7 @@ export const Reminders = {
|
||||
},
|
||||
};
|
||||
|
||||
var openedToast = null;
|
||||
export async function resetReminders() {
|
||||
const reminders = [];
|
||||
|
||||
@@ -102,7 +103,28 @@ export async function resetReminders() {
|
||||
saveFile(filePath, data);
|
||||
showToast("success", `Backup saved at ${filePath}.`);
|
||||
} else if (isUserPremium() && !isTesting()) {
|
||||
await showBackupDialog();
|
||||
if (openedToast !== null) return;
|
||||
openedToast = showToast(
|
||||
"success",
|
||||
"Your backup is ready for download.",
|
||||
[
|
||||
{
|
||||
text: "Later",
|
||||
onClick: () => {
|
||||
createBackup(false);
|
||||
openedToast?.hide();
|
||||
openedToast = null;
|
||||
},
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
text: "Download",
|
||||
onClick: () => createBackup(true),
|
||||
type: "primary",
|
||||
},
|
||||
],
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
if (await shouldAddLoginReminder()) {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Text } from "rebass";
|
||||
import { createBackup } from "../../common";
|
||||
import Dialog from "./dialog";
|
||||
|
||||
type BackupDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
function BackupDialog(props: BackupDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
alignment="center"
|
||||
onClose={props.onClose}
|
||||
title="Your backup is ready"
|
||||
description="Download a backup of your notes to keep them safe."
|
||||
positiveButton={{
|
||||
text: "Save to disk",
|
||||
onClick: async () => {
|
||||
await createBackup(true);
|
||||
props.onClose();
|
||||
},
|
||||
}}
|
||||
negativeButton={{
|
||||
text: "Remind me later",
|
||||
onClick: async () => {
|
||||
await createBackup(false);
|
||||
props.onClose();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Text variant={"body"}>
|
||||
In case forget your password or something unfortunate happens, you can
|
||||
restore a backup to recover lost data.{" "}
|
||||
</Text>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackupDialog;
|
||||
@@ -120,16 +120,10 @@ function Dialog(props) {
|
||||
>
|
||||
{props.negativeButton && (
|
||||
<RebassButton
|
||||
variant="primary"
|
||||
variant="dialog"
|
||||
data-test-id="dialog-no"
|
||||
onClick={props.negativeButton.onClick}
|
||||
color="text"
|
||||
fontWeight="bold"
|
||||
bg={"transparent"}
|
||||
sx={{
|
||||
opacity: props.negativeButton.disabled ? 0.7 : 1,
|
||||
":hover": { bg: "bgSecondary" },
|
||||
}}
|
||||
>
|
||||
{props.negativeButton.text || "Cancel"}
|
||||
</RebassButton>
|
||||
@@ -137,15 +131,9 @@ function Dialog(props) {
|
||||
{props.positiveButton && (
|
||||
<RebassButton
|
||||
{...props.positiveButton.props}
|
||||
variant="primary"
|
||||
color="primary"
|
||||
fontWeight="bold"
|
||||
bg={"transparent"}
|
||||
variant="dialog"
|
||||
data-test-id="dialog-yes"
|
||||
sx={{
|
||||
opacity: props.positiveButton.disabled ? 0.7 : 1,
|
||||
":hover": { bg: "bgSecondary" },
|
||||
}}
|
||||
autoFocus={props.positiveButton.autoFocus}
|
||||
disabled={props.positiveButton.disabled || false}
|
||||
onClick={
|
||||
!props.positiveButton.disabled
|
||||
|
||||
@@ -40,31 +40,14 @@ const features: Record<FeatureKeys, Feature> = {
|
||||
subtitle: `Welcome to v${appVersion.clean}`,
|
||||
subFeatures: [
|
||||
{
|
||||
title: "Duplicate notes",
|
||||
title: "Two-factor authentication",
|
||||
subtitle: (
|
||||
<>
|
||||
Tired of copy-pasting notes? Wish there were templates? Not anymore.{" "}
|
||||
<Code text="Right click > Duplicate" /> on a note to create its
|
||||
duplicate.
|
||||
Add an additional layer of security for your notes by enabling 2FA
|
||||
from <Code text="Settings" />.
|
||||
</>
|
||||
),
|
||||
icon: Icon.Duplicate,
|
||||
},
|
||||
{
|
||||
title: "Disable sync for specific notes",
|
||||
subtitle: (
|
||||
<>
|
||||
Make any note 100% offline by{" "}
|
||||
<Code text="Right click > Disable sync" /> on a note.
|
||||
</>
|
||||
),
|
||||
icon: Icon.Sync,
|
||||
},
|
||||
{
|
||||
title: "Improved pasting of code",
|
||||
subtitle:
|
||||
"Pasting code from anywhere (GitHub, VSCode etc.) will now keep the highlighting & formatting intact.",
|
||||
icon: Icon.Codeblock,
|
||||
icon: Icon.MFAAuthenticator,
|
||||
},
|
||||
],
|
||||
cta: {
|
||||
|
||||
@@ -14,9 +14,9 @@ import TrackingDetailsDialog from "./trackingdetailsdialog";
|
||||
import ReminderDialog from "./reminderdialog";
|
||||
import AnnouncementDialog from "./announcementdialog";
|
||||
import IssueDialog from "./issuedialog";
|
||||
import { MultifactorDialog, RecoveryCodesDialog } from "./multi-factor-dialog";
|
||||
import OnboardingDialog from "./onboarding-dialog";
|
||||
import AttachmentsDialog from "./attachmentsdialog";
|
||||
import BackupDialog from "./backupdialog";
|
||||
|
||||
const Dialogs = {
|
||||
AddNotebookDialog,
|
||||
@@ -35,8 +35,9 @@ const Dialogs = {
|
||||
AnnouncementDialog,
|
||||
IssueDialog,
|
||||
ImportDialog,
|
||||
MultifactorDialog,
|
||||
RecoveryCodesDialog,
|
||||
OnboardingDialog,
|
||||
AttachmentsDialog,
|
||||
BackupDialog,
|
||||
};
|
||||
export default Dialogs;
|
||||
|
||||
852
apps/web/src/components/dialogs/multifactordialog.tsx
Normal file
852
apps/web/src/components/dialogs/multifactordialog.tsx
Normal file
@@ -0,0 +1,852 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Text, Flex, Button, Box, BoxProps } from "rebass";
|
||||
import Dialog from "./dialog";
|
||||
import { db } from "../../common/db";
|
||||
import { ReactComponent as MFA } from "../../assets/mfa.svg";
|
||||
import { ReactComponent as Fallback2FA } from "../../assets/fallback2fa.svg";
|
||||
import * as clipboard from "clipboard-polyfill/text";
|
||||
import { Suspense } from "react";
|
||||
import FileSaver from "file-saver";
|
||||
import {
|
||||
Loading,
|
||||
MFAAuthenticator,
|
||||
MFAEmail,
|
||||
MFASMS,
|
||||
Download,
|
||||
Print,
|
||||
Copy,
|
||||
Refresh,
|
||||
} from "../icons";
|
||||
import Field from "../field";
|
||||
import { useSessionState } from "../../utils/hooks";
|
||||
import { exportToPDF } from "../../common/export";
|
||||
import { useTimer } from "../../hooks/use-timer";
|
||||
import { phone } from "phone";
|
||||
import { showMultifactorDialog } from "../../common/dialog-controller";
|
||||
const QRCode = React.lazy(() => import("../../re-exports/react-qrcode-logo"));
|
||||
|
||||
export type AuthenticatorType = "app" | "sms" | "email";
|
||||
type StepKeys = keyof Steps; // "choose" | "setup" | "recoveryCodes" | "finish";
|
||||
type FallbackStepKeys = keyof FallbackSteps;
|
||||
type Steps = typeof steps;
|
||||
type FallbackSteps = typeof fallbackSteps;
|
||||
|
||||
type Authenticator = {
|
||||
type: AuthenticatorType;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.FunctionComponent<BoxProps>;
|
||||
recommended?: boolean;
|
||||
};
|
||||
|
||||
type StepComponentProps = {
|
||||
onNext: (...args: any[]) => void;
|
||||
onClose?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
};
|
||||
|
||||
type StepComponent = React.FunctionComponent<StepComponentProps>;
|
||||
|
||||
type Step = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
component?: StepComponent;
|
||||
next?: StepKeys;
|
||||
cancellable?: boolean;
|
||||
};
|
||||
type FallbackStep = Step & {
|
||||
next?: FallbackStepKeys;
|
||||
};
|
||||
|
||||
type SubmitCodeFunction = (code: string) => void;
|
||||
|
||||
type AuthenticatorSelectorProps = StepComponentProps & {
|
||||
authenticator: AuthenticatorType;
|
||||
isFallback?: boolean;
|
||||
};
|
||||
|
||||
type VerifyAuthenticatorFormProps = PropsWithChildren<{
|
||||
codeHelpText: string;
|
||||
onSubmitCode: SubmitCodeFunction;
|
||||
}>;
|
||||
|
||||
type SetupAuthenticatorProps = { onSubmitCode: SubmitCodeFunction };
|
||||
|
||||
type MultifactorDialogProps = {
|
||||
onClose: () => void;
|
||||
primaryMethod?: AuthenticatorType;
|
||||
};
|
||||
|
||||
type RecoveryCodesDialogProps = {
|
||||
onClose: () => void;
|
||||
primaryMethod: AuthenticatorType;
|
||||
};
|
||||
|
||||
const defaultAuthenticators: AuthenticatorType[] = ["app", "sms", "email"];
|
||||
const Authenticators: Authenticator[] = [
|
||||
{
|
||||
type: "app",
|
||||
title: "Set up using an Authenticator app",
|
||||
subtitle:
|
||||
"Use an authenticator app like Aegis or Raivo Authenticator to get the authentication codes.",
|
||||
icon: MFAAuthenticator,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
type: "sms",
|
||||
title: "Set up using SMS",
|
||||
subtitle: "Notesnook will send you an SMS text with the 2FA code at login.",
|
||||
icon: MFASMS,
|
||||
},
|
||||
{
|
||||
type: "email",
|
||||
title: "Set up using Email",
|
||||
subtitle: "Notesnook will send you the 2FA code on your email at login.",
|
||||
icon: MFAEmail,
|
||||
},
|
||||
];
|
||||
|
||||
const steps = {
|
||||
choose: (): Step => ({
|
||||
title: "Protect your notes by enabling 2FA",
|
||||
description: "Choose how you want to receive your authentication codes.",
|
||||
component: ({ onNext }) => (
|
||||
<ChooseAuthenticator
|
||||
onNext={onNext}
|
||||
authenticators={defaultAuthenticators}
|
||||
/>
|
||||
),
|
||||
next: "setup",
|
||||
cancellable: true,
|
||||
}),
|
||||
setup: (authenticator: Authenticator): Step => ({
|
||||
title: authenticator.title,
|
||||
description: authenticator.subtitle,
|
||||
next: "recoveryCodes",
|
||||
component: ({ onNext }) => (
|
||||
<AuthenticatorSelector
|
||||
onNext={onNext}
|
||||
authenticator={authenticator.type}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
recoveryCodes: (authenticatorType: AuthenticatorType): Step => ({
|
||||
title: "Save your recovery codes",
|
||||
description: `If you lose access to your ${
|
||||
authenticatorType === "email"
|
||||
? "email"
|
||||
: authenticatorType === "sms"
|
||||
? "phone"
|
||||
: "auth app"
|
||||
}, you can login to Notesnook using your recovery codes. Each code can only be used once!`,
|
||||
component: ({ onNext, onClose, onError }) => (
|
||||
<BackupRecoveryCodes
|
||||
onClose={onClose}
|
||||
onNext={onNext}
|
||||
onError={onError}
|
||||
authenticatorType={authenticatorType}
|
||||
/>
|
||||
),
|
||||
next: "finish",
|
||||
}),
|
||||
finish: (authenticatorType: AuthenticatorType): Step => ({
|
||||
component: ({ onNext, onClose, onError }) => (
|
||||
<TwoFactorEnabled
|
||||
onClose={onClose}
|
||||
onNext={onNext}
|
||||
onError={onError}
|
||||
authenticatorType={authenticatorType}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
const fallbackSteps = {
|
||||
choose: (primaryMethod: AuthenticatorType): FallbackStep => ({
|
||||
title: "Add a fallback 2FA method",
|
||||
description:
|
||||
"A fallback method helps you get your 2FA codes on an alternative device in case you lose your primary device.",
|
||||
component: ({ onNext }) => (
|
||||
<ChooseAuthenticator
|
||||
onNext={onNext}
|
||||
authenticators={defaultAuthenticators.filter(
|
||||
(i) => i !== primaryMethod
|
||||
)}
|
||||
/>
|
||||
),
|
||||
next: "setup",
|
||||
cancellable: true,
|
||||
}),
|
||||
setup: (authenticator: Authenticator): FallbackStep => ({
|
||||
title: authenticator.title,
|
||||
description: authenticator.subtitle,
|
||||
next: "finish",
|
||||
cancellable: true,
|
||||
component: ({ onNext }) => (
|
||||
<AuthenticatorSelector
|
||||
onNext={onNext}
|
||||
authenticator={authenticator.type}
|
||||
isFallback
|
||||
/>
|
||||
),
|
||||
}),
|
||||
finish: (
|
||||
fallbackMethod: AuthenticatorType,
|
||||
primaryMethod: AuthenticatorType
|
||||
): FallbackStep => ({
|
||||
component: ({ onNext, onClose }) => (
|
||||
<Fallback2FAEnabled
|
||||
onNext={onNext}
|
||||
onClose={onClose}
|
||||
primaryMethod={primaryMethod}
|
||||
fallbackMethod={fallbackMethod}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export function MultifactorDialog(props: MultifactorDialogProps) {
|
||||
const { onClose, primaryMethod } = props;
|
||||
const [step, setStep] = useState<FallbackStep | Step>(
|
||||
primaryMethod ? fallbackSteps.choose(primaryMethod) : steps.choose()
|
||||
);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
width={500}
|
||||
positiveButton={
|
||||
step.next
|
||||
? {
|
||||
text: "Continue",
|
||||
props: { form: "2faForm" },
|
||||
}
|
||||
: null
|
||||
}
|
||||
negativeButton={
|
||||
step.cancellable
|
||||
? {
|
||||
text: "Cancel",
|
||||
onClick: onClose,
|
||||
}
|
||||
: null
|
||||
}
|
||||
>
|
||||
{step.component && (
|
||||
<step.component
|
||||
onNext={(...args) => {
|
||||
if (!step.next) return onClose();
|
||||
|
||||
const nextStepCreator: Function =
|
||||
step.next !== "recoveryCodes" && primaryMethod
|
||||
? fallbackSteps[step.next]
|
||||
: steps[step.next];
|
||||
|
||||
const nextStep = primaryMethod
|
||||
? nextStepCreator(...args, primaryMethod)
|
||||
: nextStepCreator(...args);
|
||||
|
||||
setStep(nextStep);
|
||||
}}
|
||||
onError={setError}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<Text variant={"error"} bg="errorBg" p={1} mt={2}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecoveryCodesDialog(props: RecoveryCodesDialogProps) {
|
||||
const { onClose, primaryMethod } = props;
|
||||
const [error, setError] = useState<string>();
|
||||
const step = steps.recoveryCodes(primaryMethod);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
width={500}
|
||||
positiveButton={{
|
||||
text: "Okay",
|
||||
onClick: onClose,
|
||||
}}
|
||||
>
|
||||
{step.component && (
|
||||
<step.component onNext={() => {}} onError={setError} />
|
||||
)}
|
||||
{error && (
|
||||
<Text variant={"error"} bg="errorBg" p={1} mt={2}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type ChooseAuthenticatorProps = StepComponentProps & {
|
||||
authenticators: AuthenticatorType[];
|
||||
};
|
||||
|
||||
function ChooseAuthenticator(props: ChooseAuthenticatorProps) {
|
||||
const [selected, setSelected] = useSessionState("selectedAuthenticator", 0);
|
||||
const { authenticators, onNext } = props;
|
||||
const filteredAuthenticators = authenticators.map(
|
||||
(a) => Authenticators.find((auth) => auth.type === a)!
|
||||
);
|
||||
return (
|
||||
<Flex
|
||||
as="form"
|
||||
id="2faForm"
|
||||
flexDirection="column"
|
||||
flex={1}
|
||||
sx={{ overflow: "hidden" }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const authenticator = filteredAuthenticators[selected];
|
||||
onNext(authenticator);
|
||||
}}
|
||||
>
|
||||
{filteredAuthenticators.map((auth, index) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
mt={2}
|
||||
sx={{
|
||||
":first-of-type": { mt: 2 },
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
alignItems: "start",
|
||||
textAlign: "left",
|
||||
bg: "transparent",
|
||||
px: 0,
|
||||
}}
|
||||
onClick={() => setSelected(index)}
|
||||
>
|
||||
<auth.icon
|
||||
className="2fa-icon"
|
||||
sx={{
|
||||
bg: selected === index ? "shade" : "bgSecondary",
|
||||
borderRadius: 100,
|
||||
width: 35,
|
||||
height: 35,
|
||||
mr: 2,
|
||||
}}
|
||||
size={16}
|
||||
color={selected === index ? "primary" : "text"}
|
||||
/>
|
||||
<Text variant={"title"} fontWeight="body">
|
||||
{auth.title}{" "}
|
||||
{auth.recommended ? (
|
||||
<Text
|
||||
as="span"
|
||||
variant={"subBody"}
|
||||
color="primary"
|
||||
bg="shade"
|
||||
px={1}
|
||||
sx={{ borderRadius: "default" }}
|
||||
>
|
||||
Recommended
|
||||
</Text>
|
||||
) : (
|
||||
false
|
||||
)}
|
||||
<Text variant="body" fontWeight="normal" mt={1}>
|
||||
{auth.subtitle}
|
||||
</Text>
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatorSelector(props: AuthenticatorSelectorProps) {
|
||||
const { authenticator, isFallback, onNext, onError } = props;
|
||||
const onSubmitCode: SubmitCodeFunction = useCallback(
|
||||
async (code) => {
|
||||
try {
|
||||
if (isFallback) await db.mfa?.enableFallback(authenticator, code);
|
||||
else await db.mfa!.enable(authenticator, code);
|
||||
onNext(authenticator);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
onError && onError(error.message);
|
||||
}
|
||||
},
|
||||
[authenticator, onError, onNext, isFallback]
|
||||
);
|
||||
|
||||
return authenticator === "app" ? (
|
||||
<SetupAuthenticatorApp onSubmitCode={onSubmitCode} />
|
||||
) : authenticator === "email" ? (
|
||||
<SetupEmail onSubmitCode={onSubmitCode} />
|
||||
) : authenticator === "sms" ? (
|
||||
<SetupSMS onSubmitCode={onSubmitCode} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function SetupAuthenticatorApp(props: SetupAuthenticatorProps) {
|
||||
const { onSubmitCode } = props;
|
||||
const [authenticatorDetails, setAuthenticatorDetails] = useState({
|
||||
sharedKey: null,
|
||||
authenticatorUri: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
setAuthenticatorDetails(await db.mfa!.setup("app"));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VerifyAuthenticatorForm
|
||||
codeHelpText={
|
||||
"After scanning the QR code image, the app will display a code that you can enter below."
|
||||
}
|
||||
onSubmitCode={onSubmitCode}
|
||||
>
|
||||
<Text variant={"body"}>
|
||||
Scan the QR code below with your authenticator app.
|
||||
</Text>
|
||||
<Box alignSelf={"center"}>
|
||||
{authenticatorDetails.authenticatorUri ? (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<QRCode
|
||||
value={authenticatorDetails.authenticatorUri}
|
||||
ecLevel={"M"}
|
||||
size={150}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</Box>
|
||||
<Text variant={"subBody"}>
|
||||
If you can't scan the QR code above, enter this text instead (spaces
|
||||
don't matter):
|
||||
</Text>
|
||||
<Text
|
||||
mt={2}
|
||||
bg="bgSecondary"
|
||||
p={2}
|
||||
fontFamily="monospace"
|
||||
fontSize="body"
|
||||
sx={{ borderRadius: "default", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{authenticatorDetails.sharedKey ? (
|
||||
authenticatorDetails.sharedKey
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</Text>
|
||||
</VerifyAuthenticatorForm>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupEmail(props: SetupAuthenticatorProps) {
|
||||
const { onSubmitCode } = props;
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const { elapsed, enabled, setEnabled } = useTimer(`2fa.email`, 60);
|
||||
const [email, setEmail] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { email } = await db.user!.getUser();
|
||||
setEmail(email);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VerifyAuthenticatorForm
|
||||
codeHelpText={
|
||||
"You will receive a 2FA code on your email address which you can enter below"
|
||||
}
|
||||
onSubmitCode={onSubmitCode}
|
||||
>
|
||||
<Flex
|
||||
mt={2}
|
||||
bg="bgSecondary"
|
||||
alignItems={"center"}
|
||||
sx={{ borderRadius: "default", overflowWrap: "anywhere" }}
|
||||
>
|
||||
<Text ml={2} fontFamily="monospace" fontSize="subtitle" flex={1}>
|
||||
{email}
|
||||
</Text>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
alignSelf={"center"}
|
||||
sx={{ p: 2, m: 0 }}
|
||||
disabled={isSending || !enabled}
|
||||
onClick={async () => {
|
||||
setIsSending(true);
|
||||
try {
|
||||
await db.mfa!.setup("email");
|
||||
setEnabled(false);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loading size={18} />
|
||||
) : enabled ? (
|
||||
`Send code`
|
||||
) : (
|
||||
`Resend (${elapsed})`
|
||||
)}
|
||||
</Button>
|
||||
</Flex>
|
||||
{error ? (
|
||||
<Text
|
||||
variant={"error"}
|
||||
bg="errorBg"
|
||||
p={1}
|
||||
sx={{ borderRadius: "default" }}
|
||||
mt={1}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</VerifyAuthenticatorForm>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupSMS(props: SetupAuthenticatorProps) {
|
||||
const { onSubmitCode } = props;
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [phoneNumber, setPhoneNumber] = useState<string>();
|
||||
const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60);
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
|
||||
return (
|
||||
<VerifyAuthenticatorForm
|
||||
codeHelpText={
|
||||
"You will receive a 2FA code on your phone number which you can enter below"
|
||||
}
|
||||
onSubmitCode={onSubmitCode}
|
||||
>
|
||||
<Field
|
||||
inputRef={inputRef}
|
||||
id="phone-number"
|
||||
name="phone-number"
|
||||
helpText="Authentication codes will be sent to this number"
|
||||
label="Phone number"
|
||||
sx={{ mt: 2 }}
|
||||
autoFocus
|
||||
required
|
||||
styles={{
|
||||
input: { flex: 1 },
|
||||
}}
|
||||
placeholder={"+1234567890"}
|
||||
onChange={() => {
|
||||
const number = inputRef.current?.value;
|
||||
if (!number) return setError("");
|
||||
const validationResult = phone(number);
|
||||
|
||||
if (validationResult.isValid) {
|
||||
setPhoneNumber(validationResult.phoneNumber);
|
||||
setError("");
|
||||
} else {
|
||||
setPhoneNumber("");
|
||||
setError("Please enter a valid phone number with country code.");
|
||||
}
|
||||
}}
|
||||
action={{
|
||||
disabled: error || isSending || !enabled,
|
||||
component: (
|
||||
<Text variant={"body"}>
|
||||
{isSending ? (
|
||||
<Loading size={18} />
|
||||
) : enabled ? (
|
||||
`Send code`
|
||||
) : (
|
||||
`Resend (${elapsed})`
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
onClick: async () => {
|
||||
if (!phoneNumber) {
|
||||
setError("Please provide a phone number.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
await db.mfa!.setup("sms", phoneNumber);
|
||||
setEnabled(false);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<Text
|
||||
variant={"error"}
|
||||
bg="errorBg"
|
||||
p={1}
|
||||
sx={{ borderRadius: "default" }}
|
||||
mt={1}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</VerifyAuthenticatorForm>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupRecoveryCodes(props: TwoFactorEnabledProps) {
|
||||
const { onNext, onError } = props;
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
const recoveryCodesRef = useRef<HTMLDivElement>();
|
||||
const generate = useCallback(async () => {
|
||||
onError && onError("");
|
||||
try {
|
||||
const codes = await db.mfa?.codes();
|
||||
if (codes) setCodes(codes);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
onError && onError(error.message);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
await generate();
|
||||
})();
|
||||
}, [generate]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Print",
|
||||
icon: Print,
|
||||
action: async () => {
|
||||
if (!recoveryCodesRef.current) return;
|
||||
await exportToPDF(
|
||||
"Notesnook 2FA Recovery Codes",
|
||||
recoveryCodesRef.current.outerHTML
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Copy",
|
||||
icon: Copy,
|
||||
action: async () => {
|
||||
await clipboard.writeText(codes.join("\n"));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Download",
|
||||
icon: Download,
|
||||
action: () => {
|
||||
FileSaver.saveAs(
|
||||
new Blob([Buffer.from(codes.join("\n"))]),
|
||||
`notesnook-recovery-codes.txt`
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: "Regenerate", icon: Refresh, action: generate },
|
||||
],
|
||||
[codes, generate]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
as="form"
|
||||
id="2faForm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onNext(props.authenticatorType);
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="selectable"
|
||||
ref={recoveryCodesRef}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr 1fr",
|
||||
bg: "bgSecondary",
|
||||
p: 2,
|
||||
borderRadius: "default",
|
||||
}}
|
||||
>
|
||||
{codes.map((code) => (
|
||||
<Text
|
||||
className="selectable"
|
||||
as="code"
|
||||
variant={"subheading"}
|
||||
textAlign="center"
|
||||
fontWeight="body"
|
||||
fontFamily={"monospace"}
|
||||
>
|
||||
{code}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Flex sx={{ justifyContent: "start", alignItems: "center", mt: 2 }}>
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
mr={1}
|
||||
py={1}
|
||||
sx={{ display: "flex", alignItems: "center" }}
|
||||
onClick={action.action}
|
||||
>
|
||||
<action.icon size={15} sx={{ mr: "2px" }} />
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
type TwoFactorEnabledProps = StepComponentProps & {
|
||||
authenticatorType: AuthenticatorType;
|
||||
};
|
||||
function TwoFactorEnabled(props: TwoFactorEnabledProps) {
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
justifyContent="center"
|
||||
alignItems={"center"}
|
||||
mb={2}
|
||||
>
|
||||
<MFA width={120} />
|
||||
<Text variant={"heading"} fontSize="subheading" mt={2} textAlign="center">
|
||||
Two-factor authentication enabled!
|
||||
</Text>
|
||||
<Text variant={"body"} color="fontTertiary" mt={1} textAlign="center">
|
||||
Your account is now 100% secure against unauthorized logins.
|
||||
</Text>
|
||||
<Button mt={2} sx={{ borderRadius: 100, px: 6 }} onClick={props.onClose}>
|
||||
Done
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={"anchor"}
|
||||
mt={2}
|
||||
onClick={() => {
|
||||
props.onClose && props.onClose();
|
||||
setTimeout(async () => {
|
||||
await showMultifactorDialog(props.authenticatorType);
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
Setup a fallback 2FA method
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
type Fallback2FAEnabledProps = StepComponentProps & {
|
||||
fallbackMethod: AuthenticatorType;
|
||||
primaryMethod: AuthenticatorType;
|
||||
};
|
||||
function Fallback2FAEnabled(props: Fallback2FAEnabledProps) {
|
||||
const { fallbackMethod, primaryMethod, onClose } = props;
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
justifyContent="center"
|
||||
alignItems={"center"}
|
||||
mb={2}
|
||||
>
|
||||
<Fallback2FA width={200} />
|
||||
<Text variant={"heading"} fontSize="subheading" mt={2} textAlign="center">
|
||||
Fallback 2FA method enabled!
|
||||
</Text>
|
||||
<Text variant={"body"} color="fontTertiary" mt={1} textAlign="center">
|
||||
You will now receive your 2FA codes on your{" "}
|
||||
{mfaMethodToPhrase(fallbackMethod)} in case you lose access to your{" "}
|
||||
{mfaMethodToPhrase(primaryMethod)}.
|
||||
</Text>
|
||||
<Button mt={2} sx={{ borderRadius: 100, px: 6 }} onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyAuthenticatorForm(props: VerifyAuthenticatorFormProps) {
|
||||
const { codeHelpText, onSubmitCode, children } = props;
|
||||
const formRef = useRef<HTMLFormElement>();
|
||||
return (
|
||||
<Flex
|
||||
ref={formRef}
|
||||
as="form"
|
||||
id="2faForm"
|
||||
flexDirection="column"
|
||||
flex={1}
|
||||
sx={{ overflow: "hidden" }}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(formRef.current);
|
||||
const code = form.get("code");
|
||||
if (!code || code.toString().length !== 6) return;
|
||||
onSubmitCode(code.toString());
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Field
|
||||
id="code"
|
||||
name="code"
|
||||
helpText={codeHelpText}
|
||||
label="Enter the 6-digit code"
|
||||
sx={{ alignItems: "center", mt: 2 }}
|
||||
required
|
||||
placeholder="010101"
|
||||
min={99999}
|
||||
max={999999}
|
||||
type="number"
|
||||
variant="clean"
|
||||
styles={{
|
||||
input: {
|
||||
width: "100%",
|
||||
fontSize: 38,
|
||||
fontFamily: "monospace",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function mfaMethodToPhrase(method: AuthenticatorType): string {
|
||||
return method === "email"
|
||||
? "email"
|
||||
: method === "app"
|
||||
? "authentication app"
|
||||
: "phone number";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import { Input, Label } from "@rebass/forms";
|
||||
import * as Icon from "../icons";
|
||||
|
||||
@@ -48,6 +48,8 @@ function Field(props) {
|
||||
placeholder,
|
||||
validatePassword,
|
||||
onError,
|
||||
inputMode,
|
||||
pattern,
|
||||
variant = "input",
|
||||
as = "input",
|
||||
} = props;
|
||||
@@ -96,6 +98,8 @@ function Field(props) {
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
pattern={pattern}
|
||||
type={type || "text"}
|
||||
sx={{
|
||||
...styles.input,
|
||||
@@ -148,10 +152,11 @@ function Field(props) {
|
||||
</Flex>
|
||||
)}
|
||||
{action && (
|
||||
<Flex
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
data-test-id={action.testId}
|
||||
onClick={action.onClick}
|
||||
variant="rowCenter"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
@@ -162,9 +167,10 @@ function Field(props) {
|
||||
borderRadius: "default",
|
||||
":hover": { bg: "border" },
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<action.icon size={20} />
|
||||
</Flex>
|
||||
{action.component ? action.component : <action.icon size={20} />}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
{validatePassword && (
|
||||
|
||||
@@ -136,6 +136,10 @@ import {
|
||||
mdiContentSaveCheckOutline,
|
||||
mdiContentSaveAlertOutline,
|
||||
mdiCurrencyUsd,
|
||||
mdiCellphoneKey,
|
||||
mdiEmailOutline,
|
||||
mdiMessageLockOutline,
|
||||
mdiShieldCheckOutline,
|
||||
mdiAlertOctagonOutline,
|
||||
mdiGithub,
|
||||
mdiAlertCircleOutline,
|
||||
@@ -144,8 +148,10 @@ import {
|
||||
mdiCheckAll,
|
||||
mdiCloudOffOutline,
|
||||
mdiContentDuplicate,
|
||||
mdiPrinterOutline,
|
||||
mdiRefresh,
|
||||
mdiRestore,
|
||||
mdiVectorLink,
|
||||
mdiCodeString,
|
||||
mdiCodeBraces,
|
||||
} from "@mdi/js";
|
||||
import { useTheme } from "emotion-theming";
|
||||
@@ -175,6 +181,7 @@ function createIcon(name, rotate = false) {
|
||||
<AnimatedFlex
|
||||
flexShrink={0}
|
||||
id={props.id}
|
||||
className={props.className}
|
||||
title={props.title}
|
||||
variant={props.variant}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
@@ -295,6 +302,7 @@ export const Publish = createIcon(mdiCloudUploadOutline);
|
||||
export const Colors = createIcon(mdiPaletteOutline);
|
||||
export const Published = createIcon(mdiCloudCheckOutline);
|
||||
export const Copy = createIcon(mdiContentCopy);
|
||||
export const Refresh = createIcon(mdiRefresh);
|
||||
export const Duplicate = createIcon(mdiContentDuplicate);
|
||||
export const Select = createIcon(mdiCheckboxMultipleMarkedCircleOutline);
|
||||
export const NotebookEdit = createIcon(mdiBookEditOutline);
|
||||
@@ -314,6 +322,7 @@ export const Reddit = createIcon(mdiReddit);
|
||||
export const Dismiss = createIcon(mdiClose);
|
||||
export const File = createIcon(mdiFileOutline);
|
||||
export const Download = createIcon(mdiArrowDown);
|
||||
export const Print = createIcon(mdiPrinterOutline);
|
||||
export const ImageDownload = createIcon(mdiImage);
|
||||
export const Billboard = createIcon(mdiBillboard);
|
||||
export const Cellphone = createIcon(mdiCellphone);
|
||||
@@ -360,6 +369,11 @@ export const OrderNewestOldest = createIcon(mdiOrderNumericAscending);
|
||||
export const Saved = createIcon(mdiContentSaveCheckOutline);
|
||||
export const NotSaved = createIcon(mdiContentSaveAlertOutline);
|
||||
|
||||
export const MFAAuthenticator = createIcon(mdiCellphoneKey);
|
||||
export const MFAEmail = createIcon(mdiEmailOutline);
|
||||
export const MFARecoveryCode = createIcon(mdiRestore);
|
||||
export const MFASMS = createIcon(mdiMessageLockOutline);
|
||||
export const MFAEnabled = createIcon(mdiShieldCheckOutline);
|
||||
export const Reupload = createIcon(mdiProgressUpload);
|
||||
export const Rename = createIcon(mdiFormTextbox);
|
||||
export const Upload = createIcon(mdiCloudOffOutline);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Flex, Button } from "rebass";
|
||||
import * as Icon from "../icons";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
@@ -29,6 +29,12 @@ function ListContainer(props) {
|
||||
[props.items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
selectionStore.toggleSelectionMode(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex variant="columnFill">
|
||||
{!props.items.length && props.placeholder ? (
|
||||
|
||||
@@ -58,12 +58,6 @@ function ListItem(props) {
|
||||
|
||||
const selectItem = useSelectionStore((store) => store.selectItem);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
selectionStore.toggleSelectionMode(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={listItemRef}
|
||||
|
||||
@@ -15,6 +15,7 @@ import IconTag from "../icon-tag";
|
||||
import { COLORS } from "../../common/constants";
|
||||
import { exportNotes } from "../../common/export";
|
||||
import { Multiselect } from "../../common/multi-select";
|
||||
import { store as selectionStore } from "../../stores/selection-store";
|
||||
|
||||
function Note(props) {
|
||||
const { tags, notebook, item, index, context, date } = props;
|
||||
@@ -46,14 +47,10 @@ function Note(props) {
|
||||
index={index}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === "Delete") {
|
||||
await confirm({
|
||||
title: "Delete note?",
|
||||
message:
|
||||
"This item will be kept in your Trash for 7 days after which it will be permanently removed",
|
||||
noText: "No",
|
||||
yesText: "Yes",
|
||||
yesAction: () => Multiselect.moveNotesToTrash([item]),
|
||||
});
|
||||
let selectedItems = selectionStore
|
||||
.get()
|
||||
.selectedItems.filter((i) => i.type === item.type && i !== item);
|
||||
await Multiselect.moveNotesToTrash([item, ...selectedItems]);
|
||||
}
|
||||
}}
|
||||
colors={{
|
||||
@@ -380,7 +377,7 @@ const menuItems = [
|
||||
items.length === 1 && db.monographs.isPublished(items[0].id),
|
||||
disableReason: "Please unpublish this note to move it to trash",
|
||||
onClick: async ({ items }) => {
|
||||
await Multiselect.moveNotesToTrash(items);
|
||||
await Multiselect.moveNotesToTrash(items, items.length > 1);
|
||||
},
|
||||
multiSelect: true,
|
||||
},
|
||||
|
||||
29
apps/web/src/hooks/use-timer.ts
Normal file
29
apps/web/src/hooks/use-timer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSessionState } from "../utils/hooks";
|
||||
|
||||
export function useTimer(id: string, duration: number) {
|
||||
const [seconds, setSeconds] = useSessionState(id, duration);
|
||||
const [enabled, setEnabled] = useSessionState(`${id}.canSendAgain`, true);
|
||||
const interval = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
interval.current = setInterval(() => {
|
||||
setSeconds((seconds: number) => {
|
||||
--seconds;
|
||||
if (seconds <= 0) {
|
||||
setEnabled(true);
|
||||
if (interval.current) clearInterval(interval.current);
|
||||
return duration;
|
||||
}
|
||||
return seconds;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (interval.current) clearInterval(interval.current);
|
||||
};
|
||||
}, [enabled, setEnabled, setSeconds, duration]);
|
||||
|
||||
return { elapsed: seconds, enabled, setEnabled };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "notes-core/types";
|
||||
import { EVENTS } from "@notesnook/desktop/events";
|
||||
import { render } from "react-dom";
|
||||
import { AppEventManager } from "./common/app-events";
|
||||
@@ -21,19 +22,27 @@ const ROUTES = {
|
||||
},
|
||||
"/signup": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "signup" },
|
||||
props: { route: "signup" },
|
||||
},
|
||||
"/sessionexpired": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "sessionexpired" },
|
||||
props: { route: "sessionExpiry" },
|
||||
},
|
||||
"/login": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "login" },
|
||||
props: { route: "login" },
|
||||
},
|
||||
"/recover": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "recover" },
|
||||
props: { route: "recover" },
|
||||
},
|
||||
"/mfa/code": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { route: "login" },
|
||||
},
|
||||
"/mfa/select": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { route: "login" },
|
||||
},
|
||||
default: { component: () => import("./app"), props: {} },
|
||||
};
|
||||
|
||||
@@ -549,7 +549,7 @@ function parseS3Error(data) {
|
||||
}
|
||||
|
||||
function handleS3Error(e, message) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (axios.isAxiosError(e) && e.response?.data) {
|
||||
const error = parseS3Error(e.response.data);
|
||||
showToast("error", `${message}: [${error.Code}] ${error.Message}`);
|
||||
} else {
|
||||
|
||||
@@ -104,8 +104,9 @@ export function hardNavigate(route) {
|
||||
window.open(makeURL(route, getCurrentHash()), "_self");
|
||||
}
|
||||
|
||||
export function makeURL(route, hash) {
|
||||
export function makeURL(route, hash, search) {
|
||||
const url = new URL(route, window.location.origin);
|
||||
if (!url.hash) url.hash = hash || getCurrentHash();
|
||||
url.search = search || getQueryString();
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ class EditorStore extends BaseStore {
|
||||
});
|
||||
noteStore.setSelectedNote(0);
|
||||
this.toggleProperties(false);
|
||||
if (shouldNavigate) hashNavigate(`/`, { replace: true });
|
||||
if (shouldNavigate)
|
||||
hashNavigate(`/notes/create`, { replace: true, addNonce: true });
|
||||
};
|
||||
|
||||
setTitle = (sessionId, title) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ class NoteStore extends BaseStore {
|
||||
notes = [];
|
||||
context = undefined;
|
||||
selectedNote = 0;
|
||||
nonce = 0;
|
||||
viewMode = Config.get("notes:viewMode", "detailed");
|
||||
|
||||
setViewMode = (viewMode) => {
|
||||
@@ -43,6 +44,7 @@ class NoteStore extends BaseStore {
|
||||
db.notes.all,
|
||||
db.settings.getGroupOptions("home")
|
||||
);
|
||||
state.nonce = Math.random();
|
||||
});
|
||||
this.refreshContext();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,9 @@ class UserStore extends BaseStore {
|
||||
isLoggedIn = false;
|
||||
isLoggingIn = false;
|
||||
isSigningIn = false;
|
||||
/**
|
||||
* @type {User}
|
||||
*/
|
||||
user = undefined;
|
||||
|
||||
init = () => {
|
||||
@@ -85,17 +88,25 @@ class UserStore extends BaseStore {
|
||||
});
|
||||
};
|
||||
|
||||
login = (form, skipInit = false) => {
|
||||
refreshUser = async () => {
|
||||
return db.user.fetchUser().then(async (user) => {
|
||||
this.set((state) => (state.user = user));
|
||||
});
|
||||
};
|
||||
|
||||
login = async (form, skipInit = false) => {
|
||||
this.set((state) => (state.isLoggingIn = true));
|
||||
return db.user
|
||||
.login(form.email.toLowerCase(), form.password)
|
||||
.then(() => {
|
||||
if (skipInit) return true;
|
||||
return this.init();
|
||||
})
|
||||
.finally(() => {
|
||||
this.set((state) => (state.isLoggingIn = false));
|
||||
});
|
||||
const { email, password, code, method } = form;
|
||||
|
||||
try {
|
||||
if (code) await db.user.mfaLogin(email, password, { code, method });
|
||||
else await db.user.login(email, password);
|
||||
|
||||
if (skipInit) return true;
|
||||
return this.init();
|
||||
} finally {
|
||||
this.set((state) => (state.isLoggingIn = false));
|
||||
}
|
||||
};
|
||||
|
||||
signup = (form) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ class ButtonFactory {
|
||||
anchor: new Anchor(),
|
||||
tool: new Tool(),
|
||||
icon: new Icon(),
|
||||
shade: new Shade(),
|
||||
dialog: new Dialog(),
|
||||
statusitem: new StatusItem(),
|
||||
menuitem: new MenuItem(),
|
||||
};
|
||||
@@ -37,7 +37,8 @@ class Default {
|
||||
},
|
||||
outline: "none",
|
||||
":focus-visible:not(:active)": {
|
||||
boxShadow: "0px 0px 0px 2px var(--text)",
|
||||
filter: "brightness(90%)",
|
||||
bg: "bgSecondary",
|
||||
},
|
||||
":disabled": {
|
||||
opacity: 0.5,
|
||||
@@ -57,9 +58,24 @@ class Primary {
|
||||
}
|
||||
}
|
||||
|
||||
class Shade {
|
||||
class Dialog {
|
||||
constructor() {
|
||||
return { variant: "buttons.primary", color: "primary", bg: "shade" };
|
||||
return {
|
||||
variant: "buttons.primary",
|
||||
color: "primary",
|
||||
fontWeight: "bold",
|
||||
bg: "transparent",
|
||||
":hover": { bg: "bgSecondary" },
|
||||
":focus:not(:active), :focus-within:not(:active), :focus-visible:not(:active)":
|
||||
{
|
||||
bg: "hover",
|
||||
filter: "brightness(90%)",
|
||||
},
|
||||
":disabled": {
|
||||
opacity: 0.7,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@ import Config from "./config";
|
||||
import { getPlatform } from "./platform";
|
||||
import { appVersion } from "./version";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
trackEvent: (
|
||||
value: string,
|
||||
type: string,
|
||||
url?: string,
|
||||
websiteId?: string
|
||||
) => void;
|
||||
trackView: (url: string, referrer?: string, websiteId?: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTrackerScript() {
|
||||
if (Config.get("telemetry") === "false") return;
|
||||
var script = document.createElement("script");
|
||||
@@ -18,9 +32,15 @@ export function loadTrackerScript() {
|
||||
script.onload = function () {
|
||||
trackVisit();
|
||||
};
|
||||
firstScriptElement.parentNode.insertBefore(script, firstScriptElement);
|
||||
firstScriptElement.parentNode?.insertBefore(script, firstScriptElement);
|
||||
}
|
||||
|
||||
type TrackerEvent = {
|
||||
name: string;
|
||||
description: string;
|
||||
type?: "event" | "view";
|
||||
};
|
||||
|
||||
export const ANALYTICS_EVENTS = {
|
||||
version: {
|
||||
name: "version",
|
||||
@@ -53,22 +73,30 @@ export const ANALYTICS_EVENTS = {
|
||||
name: "announcement:cta",
|
||||
description: "Sent whenever you an announcement CTA is invoked.",
|
||||
},
|
||||
};
|
||||
accountCreated: {
|
||||
name: "/account/created",
|
||||
description: "Sent when you create an account.",
|
||||
type: "view",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function trackEvent(event, eventMessage) {
|
||||
export function trackEvent(event: TrackerEvent, eventMessage?: string) {
|
||||
if (Config.get("telemetry") === "false") return;
|
||||
if (window.umami) {
|
||||
if (!window.umami) return;
|
||||
if (event.type === "event" && eventMessage)
|
||||
window.umami.trackEvent(eventMessage, event.name);
|
||||
}
|
||||
else trackVisit(event.name);
|
||||
}
|
||||
|
||||
export function trackVisit() {
|
||||
export function trackVisit(url: string = "/") {
|
||||
if (Config.get("telemetry") === "false") return;
|
||||
if (window.umami) {
|
||||
window.umami.trackView("/");
|
||||
const platform = getPlatform();
|
||||
if (!window.umami || !platform) return;
|
||||
|
||||
window.umami.trackView(url);
|
||||
if (url === "/")
|
||||
trackEvent(
|
||||
ANALYTICS_EVENTS.version,
|
||||
`${appVersion.formatted}-${getPlatform().toLowerCase()}`
|
||||
`${appVersion.formatted}-${platform.toLowerCase()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from "react";
|
||||
import CogoToast from "cogo-toast";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import ThemeProvider from "../components/theme-provider";
|
||||
import * as Icon from "../components/icons";
|
||||
import { toTitleCase } from "./string";
|
||||
import { store as appstore } from "../stores/app-store";
|
||||
/**
|
||||
*
|
||||
* @returns {import("cogo-toast").CTReturn}
|
||||
*/
|
||||
function showToast(type, message, actions) {
|
||||
if (appstore.get().isFocusMode) return null;
|
||||
const IconComponent = Icon[toTitleCase(type)];
|
||||
const toast = CogoToast[type];
|
||||
if (!toast) return;
|
||||
return toast(
|
||||
<ToastContainer type={type} message={message} actions={actions} />,
|
||||
{
|
||||
position: "top-right",
|
||||
hideAfter: actions ? 5 : type === "error" ? 5 : 3,
|
||||
bar: { size: "0px" },
|
||||
renderIcon: () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<IconComponent size={28} color={type} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ToastContainer(props) {
|
||||
const { type, message, actions } = props;
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Flex
|
||||
data-test-id="toast"
|
||||
justifyContent="center"
|
||||
alignContent="center"
|
||||
my={2}
|
||||
>
|
||||
<Text
|
||||
data-test-id="toast-message"
|
||||
variant="body"
|
||||
fontSize="body"
|
||||
color="text"
|
||||
mr={2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
{actions?.map((action) => (
|
||||
<Button
|
||||
flexShrink={0}
|
||||
variant="anchor"
|
||||
fontSize="body"
|
||||
color={type}
|
||||
key={action.text}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.text}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { showToast };
|
||||
96
apps/web/src/utils/toast.tsx
Normal file
96
apps/web/src/utils/toast.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import CogoToast, { CTReturn } from "cogo-toast";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import ThemeProvider from "../components/theme-provider";
|
||||
import { Error, Warn, Success } from "../components/icons";
|
||||
import { store as appstore } from "../stores/app-store";
|
||||
|
||||
type ToastType = "success" | "error" | "warn" | "info";
|
||||
type ToastAction = {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
type: "primary" | "text";
|
||||
};
|
||||
|
||||
function showToast(
|
||||
type: ToastType,
|
||||
message: string,
|
||||
actions?: ToastAction[],
|
||||
hideAfter?: number
|
||||
): CTReturn | null | undefined {
|
||||
if (appstore.get().isFocusMode) return null;
|
||||
const IconComponent =
|
||||
type === "error" ? Error : type === "success" ? Success : Warn;
|
||||
const toast = CogoToast[type];
|
||||
if (!toast) return;
|
||||
const t = toast(<ToastContainer message={message} actions={actions} />, {
|
||||
position: "top-right",
|
||||
hideAfter:
|
||||
hideAfter === undefined
|
||||
? actions
|
||||
? 5
|
||||
: type === "error"
|
||||
? 5
|
||||
: 3
|
||||
: hideAfter,
|
||||
bar: { size: "0px" },
|
||||
renderIcon: () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<IconComponent size={28} color={type} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
return t;
|
||||
}
|
||||
|
||||
type ToastContainerProps = {
|
||||
message: string;
|
||||
actions?: ToastAction[];
|
||||
};
|
||||
|
||||
function ToastContainer(props: ToastContainerProps) {
|
||||
const { message, actions } = props;
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Flex
|
||||
data-test-id="toast"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
my={2}
|
||||
sx={{ borderRadius: "default" }}
|
||||
>
|
||||
<Text
|
||||
data-test-id="toast-message"
|
||||
variant="body"
|
||||
fontSize="body"
|
||||
color="text"
|
||||
mr={2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
{actions?.map((action) => (
|
||||
<Button
|
||||
flexShrink={0}
|
||||
variant="primary"
|
||||
color={action.type}
|
||||
fontWeight="bold"
|
||||
bg={"transparent"}
|
||||
fontSize="body"
|
||||
sx={{
|
||||
py: "7px",
|
||||
":hover": { bg: "bgSecondary" },
|
||||
m: 0,
|
||||
}}
|
||||
key={action.text}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.text}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { showToast };
|
||||
@@ -1,517 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loading,
|
||||
Error,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} from "../components/icons";
|
||||
import Field from "../components/field";
|
||||
import { getQueryParams, hardNavigate, useQueryParams } from "../navigation";
|
||||
import { store as userstore } from "../stores/user-store";
|
||||
import { db } from "../common/db";
|
||||
import Config from "../utils/config";
|
||||
import useDatabase from "../hooks/use-database";
|
||||
import Loader from "../components/loader";
|
||||
import {
|
||||
showLoadingDialog,
|
||||
showLogoutConfirmation,
|
||||
} from "../common/dialog-controller";
|
||||
import { showToast } from "../utils/toast";
|
||||
import AuthContainer from "../components/auth-container";
|
||||
|
||||
const authTypes = {
|
||||
sessionexpired: {
|
||||
title: "Your session has expired",
|
||||
subtitle: {
|
||||
text: (
|
||||
<Flex bg="shade" p={1} sx={{ borderRadius: "default" }}>
|
||||
<Text as="span" fontSize="body" color="primary">
|
||||
<b>
|
||||
All your local changes are safe and will be synced after you
|
||||
relogin.
|
||||
</b>{" "}
|
||||
Please enter your password to continue.
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Your account email",
|
||||
defaultValue: (user) => maskEmail(user?.email),
|
||||
disabled: true,
|
||||
autoComplete: "false",
|
||||
type: "email",
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
name: "password",
|
||||
label: "Enter your password",
|
||||
autoComplete: "current-password",
|
||||
type: "password",
|
||||
autoFocus: true,
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Relogin to your account",
|
||||
},
|
||||
secondaryAction: {
|
||||
text: <Text color="error">Logout permanently</Text>,
|
||||
onClick: async () => {
|
||||
if (await showLogoutConfirmation()) {
|
||||
await showLoadingDialog({
|
||||
title: "You are being logged out",
|
||||
action: () => db.user.logout(true),
|
||||
});
|
||||
showToast("success", "You have been logged out.");
|
||||
Config.set("sessionExpired", false);
|
||||
window.location.replace("/login");
|
||||
}
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
title: "Logging you in",
|
||||
text: "Please wait while you are authenticated.",
|
||||
},
|
||||
supportsPasswordRecovery: true,
|
||||
onSubmit: async (form, onError) => {
|
||||
return await userstore
|
||||
.login(form)
|
||||
.then(async () => {
|
||||
Config.set("sessionExpired", false);
|
||||
redirectToURL(form.redirect || "/");
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
signup: {
|
||||
title: "Create an account",
|
||||
subtitle: {
|
||||
text: "Already have an account?",
|
||||
action: {
|
||||
text: "Log in",
|
||||
onClick: () => hardNavigate("/login", getQueryParams()),
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Enter email",
|
||||
autoComplete: "email",
|
||||
type: "email",
|
||||
autoFocus: true,
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
name: "password",
|
||||
label: "Set password",
|
||||
autoComplete: "new-password",
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
id: "confirm-password",
|
||||
name: "confirmPassword",
|
||||
label: "Confirm password",
|
||||
autoComplete: "confirm-password",
|
||||
type: "password",
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Agree & continue",
|
||||
},
|
||||
secondaryAction: {
|
||||
text: "Continue without creating an account",
|
||||
icon: <ArrowRight size={18} />,
|
||||
onClick: () => {
|
||||
redirectToURL("/");
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
title: "Creating your account",
|
||||
text: "Please wait while we finalize your account.",
|
||||
},
|
||||
footer: (
|
||||
<>
|
||||
By pressing "Create account" button, you agree to our{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="text"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/tos"
|
||||
>
|
||||
Terms of Service
|
||||
</Text>{" "}
|
||||
&{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="text"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/privacy"
|
||||
>
|
||||
Privacy Policy
|
||||
</Text>
|
||||
.
|
||||
</>
|
||||
),
|
||||
onSubmit: async (form, onError) => {
|
||||
if (form.password !== form.confirmPassword) {
|
||||
onError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
return await userstore
|
||||
.signup(form)
|
||||
.then(() => {
|
||||
redirectToURL("/notes/#/welcome");
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
login: {
|
||||
title: "Welcome back!",
|
||||
subtitle: {
|
||||
text: "Don't have an account?",
|
||||
action: {
|
||||
text: "Sign up!",
|
||||
onClick: () => hardNavigate("/signup", getQueryParams()),
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: "email",
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Enter email",
|
||||
autoComplete: "email",
|
||||
autoFocus: true,
|
||||
defaultValue: (_user, form) => form.email,
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
id: "password",
|
||||
name: "password",
|
||||
label: "Enter password",
|
||||
autoComplete: "current-password",
|
||||
defaultValue: (_user, form) => form.password,
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Login to your account",
|
||||
},
|
||||
loading: {
|
||||
title: "Logging you in",
|
||||
text: "Please wait while you are authenticated.",
|
||||
},
|
||||
supportsPasswordRecovery: true,
|
||||
onSubmit: async (form, onError) => {
|
||||
return await userstore
|
||||
.login(form)
|
||||
.then(async () => {
|
||||
redirectToURL(form.redirect || "/");
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
recover: {
|
||||
resetOnNavigate: false,
|
||||
title: "Recover your account",
|
||||
subtitle: {
|
||||
text: "Remembered your password?",
|
||||
action: {
|
||||
text: "Log in",
|
||||
onClick: () => hardNavigate("/login", getQueryParams()),
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: "email",
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Enter your account email",
|
||||
autoComplete: "email",
|
||||
helpText:
|
||||
"You will receive instructions on how to recover your account on this email",
|
||||
autoFocus: true,
|
||||
defaultValue: (user, form) => form?.email || user?.email,
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Send recovery email",
|
||||
},
|
||||
loading: {
|
||||
title: "Sending recovery email",
|
||||
text: "Please wait while we send you recovery instructions",
|
||||
},
|
||||
onSubmit: async (form, onError, onSuccess) => {
|
||||
return await db.user
|
||||
.recoverAccount(form.email.toLowerCase())
|
||||
.then(async (url) => {
|
||||
return redirectToURL(url);
|
||||
|
||||
// onSuccess(
|
||||
// "Recovery email sent. Please check your inbox (and spam folder)."
|
||||
// );
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function Auth(props) {
|
||||
const { type } = props;
|
||||
const [{ redirect }] = useQueryParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [success, setSuccess] = useState();
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [form, setForm] = useState({});
|
||||
const [user, setUser] = useState();
|
||||
|
||||
const data = authTypes[type];
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
setError();
|
||||
setSuccess();
|
||||
}
|
||||
}, [isSubmitting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppLoaded) return;
|
||||
(async () => {
|
||||
const user = await db.user.getUser();
|
||||
const isSessionExpired = Config.get("sessionExpired", false);
|
||||
if (user) {
|
||||
if (
|
||||
(type === "recover" || type === "sessionexpired") &&
|
||||
isSessionExpired
|
||||
)
|
||||
setUser(user);
|
||||
else redirectToURL("/");
|
||||
} else if (type === "sessionexpired") {
|
||||
redirectToURL("/");
|
||||
}
|
||||
})();
|
||||
}, [isAppLoaded, type]);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader title={data.loading.title} text={data.loading.text} />
|
||||
</>
|
||||
) : (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
sx={{
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{data.secondaryAction ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
mr={[2, 2, 4]}
|
||||
mt={[2, 2, 4]}
|
||||
alignSelf="end"
|
||||
onClick={data.secondaryAction.onClick}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "default",
|
||||
color: "icon",
|
||||
}}
|
||||
>
|
||||
<Text mr={1}>{data.secondaryAction.text}</Text>
|
||||
{data.secondaryAction.icon}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
ml={[2, 2, 4]}
|
||||
mt={[2, 2, 4]}
|
||||
alignSelf="start"
|
||||
title="Go to app"
|
||||
onClick={() => hardNavigate("/")}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "default",
|
||||
color: "icon",
|
||||
}}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
as="form"
|
||||
id="authForm"
|
||||
flexDirection="column"
|
||||
alignSelf="center"
|
||||
justifyContent={"center"}
|
||||
alignItems="center"
|
||||
flex={1}
|
||||
onSubmit={async (e) => {
|
||||
console.log(e);
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData(e.target);
|
||||
const form = Object.fromEntries(formData.entries());
|
||||
form.redirect = redirect;
|
||||
if (user) form.email = user.email;
|
||||
setForm(form);
|
||||
await data.onSubmit(
|
||||
form,
|
||||
(error) => {
|
||||
setIsSubmitting(false);
|
||||
setError(error);
|
||||
},
|
||||
(message) => {
|
||||
setSuccess(message);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text variant={"heading"} fontSize={32} textAlign="center">
|
||||
{data.title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
fontSize={"title"}
|
||||
textAlign="center"
|
||||
mt={2}
|
||||
mb={35}
|
||||
color="fontTertiary"
|
||||
>
|
||||
{data.subtitle.text}{" "}
|
||||
{data.subtitle.action && (
|
||||
<Text
|
||||
sx={{
|
||||
textDecoration: "underline",
|
||||
":hover": { color: "dimPrimary" },
|
||||
cursor: "pointer",
|
||||
}}
|
||||
as="b"
|
||||
color="text"
|
||||
onClick={data.subtitle.action.onClick}
|
||||
>
|
||||
{data.subtitle.action.text}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
{success && (
|
||||
<Flex bg="shade" p={1} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<CheckCircle size={15} color="primary" />
|
||||
<Text variant="error" color="primary" ml={1}>
|
||||
{success}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{data.fields?.map(({ defaultValue, id, autoFocus, ...rest }) => (
|
||||
<Field
|
||||
{...rest}
|
||||
id={id}
|
||||
key={id}
|
||||
required
|
||||
styles={{
|
||||
container: { mt: 2, width: 400 },
|
||||
label: { fontWeight: "normal" },
|
||||
input: {
|
||||
p: "12px",
|
||||
borderRadius: "default",
|
||||
bg: "background",
|
||||
boxShadow: "0px 0px 5px 0px #00000019",
|
||||
},
|
||||
}}
|
||||
data-test-id={id}
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={defaultValue && defaultValue(user, form)}
|
||||
/>
|
||||
))}
|
||||
{data.supportsPasswordRecovery && (
|
||||
<Button
|
||||
type="button"
|
||||
alignSelf="end"
|
||||
data-test-id="auth-forgot-password"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
color="text"
|
||||
onClick={() => hardNavigate("/recover", getQueryParams())}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-test-id="submitButton"
|
||||
display="flex"
|
||||
type="submit"
|
||||
mt={50}
|
||||
variant="primary"
|
||||
alignSelf={"center"}
|
||||
px={50}
|
||||
sx={{ borderRadius: 50 }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
disabled={!isAppLoaded}
|
||||
>
|
||||
{isAppLoaded ? (
|
||||
data.primaryAction.text
|
||||
) : (
|
||||
<Loading color="static" />
|
||||
)}
|
||||
</Button>
|
||||
{error && (
|
||||
<Flex bg="errorBg" p={1} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<Error size={15} color="error" />
|
||||
<Text variant="error" ml={1}>
|
||||
{error}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{data.footer && (
|
||||
<Text
|
||||
mt={4}
|
||||
maxWidth={350}
|
||||
variant="subBody"
|
||||
fontSize={13}
|
||||
textAlign="center"
|
||||
>
|
||||
{data.footer}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
export default Auth;
|
||||
|
||||
function redirectToURL(url) {
|
||||
Config.set("skipInitiation", true);
|
||||
hardNavigate(url);
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
if (!email) return "";
|
||||
const [username, domain] = email.split("@");
|
||||
const maskChars = "*".repeat(
|
||||
username.substring(2, username.length - 2).length
|
||||
);
|
||||
return `${username.substring(0, 2)}${maskChars}${username.substring(
|
||||
username.length - 2
|
||||
)}@${domain}`;
|
||||
}
|
||||
938
apps/web/src/views/auth.tsx
Normal file
938
apps/web/src/views/auth.tsx
Normal file
@@ -0,0 +1,938 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loading,
|
||||
Error as ErrorIcon,
|
||||
MFAAuthenticator,
|
||||
MFASMS,
|
||||
MFAEmail,
|
||||
MFARecoveryCode,
|
||||
ArrowRight,
|
||||
Logout,
|
||||
} from "../components/icons";
|
||||
import Field from "../components/field";
|
||||
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 useDatabase from "../hooks/use-database";
|
||||
import Loader from "../components/loader";
|
||||
import { showToast } from "../utils/toast";
|
||||
import AuthContainer from "../components/auth-container";
|
||||
import { isTesting } from "../utils/platform";
|
||||
import { AuthenticatorType } from "../components/dialogs/multi-factor-dialog";
|
||||
import { RequestError } from "notes-core/utils/http";
|
||||
import { useTimer } from "../hooks/use-timer";
|
||||
import { ANALYTICS_EVENTS, trackEvent } from "../utils/analytics";
|
||||
|
||||
type LoginFormData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type MFALoginFormData = LoginFormData & {
|
||||
code?: string;
|
||||
method?: MFAMethodType;
|
||||
};
|
||||
|
||||
type SignupFormData = LoginFormData & {
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
type AccountRecoveryFormData = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
type MFAFormData = LoginFormData & {
|
||||
selectedMethod: MFAMethodType;
|
||||
primaryMethod: MFAMethodType;
|
||||
code?: string;
|
||||
token: string;
|
||||
secondaryMethod?: MFAMethodType;
|
||||
phoneNumber?: string;
|
||||
};
|
||||
|
||||
type MFAErrorData = {
|
||||
primaryMethod: MFAMethodType;
|
||||
token: string;
|
||||
secondaryMethod?: MFAMethodType;
|
||||
phoneNumber?: string;
|
||||
};
|
||||
|
||||
type AuthFormData = {
|
||||
login: LoginFormData;
|
||||
signup: SignupFormData;
|
||||
sessionExpiry: LoginFormData;
|
||||
recover: AccountRecoveryFormData;
|
||||
"mfa:code": MFAFormData;
|
||||
"mfa:select": MFAFormData;
|
||||
};
|
||||
|
||||
type BaseFormData =
|
||||
| MFAFormData
|
||||
| LoginFormData
|
||||
| AccountRecoveryFormData
|
||||
| SignupFormData;
|
||||
|
||||
type NavigateFunction = <TRoute extends AuthRoutes>(
|
||||
route: TRoute,
|
||||
formData?: AuthFormData[TRoute]
|
||||
) => void;
|
||||
type BaseAuthComponentProps<TRoute extends AuthRoutes> = {
|
||||
navigate: NavigateFunction;
|
||||
formData?: AuthFormData[TRoute];
|
||||
};
|
||||
type AuthRoutes =
|
||||
| "sessionExpiry"
|
||||
| "login"
|
||||
| "signup"
|
||||
| "recover"
|
||||
| "mfa:code"
|
||||
| "mfa:select";
|
||||
type AuthProps = { route: AuthRoutes };
|
||||
|
||||
type AuthComponent<TRoute extends AuthRoutes> = (
|
||||
props: BaseAuthComponentProps<TRoute>
|
||||
) => JSX.Element;
|
||||
|
||||
function getRouteComponent<TRoute extends AuthRoutes>(
|
||||
route: TRoute
|
||||
): AuthComponent<TRoute> | undefined {
|
||||
switch (route) {
|
||||
case "login":
|
||||
return Login as AuthComponent<TRoute>;
|
||||
case "signup":
|
||||
return Signup as AuthComponent<TRoute>;
|
||||
case "sessionExpiry":
|
||||
return SessionExpiry as AuthComponent<TRoute>;
|
||||
case "recover":
|
||||
return AccountRecovery as AuthComponent<TRoute>;
|
||||
case "mfa:code":
|
||||
return MFACode as AuthComponent<TRoute>;
|
||||
case "mfa:select":
|
||||
return MFASelector as AuthComponent<TRoute>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const routePaths: Record<AuthRoutes, string> = {
|
||||
login: "/login",
|
||||
recover: "/recover",
|
||||
sessionExpiry: "/sessionexpired",
|
||||
signup: "/signup",
|
||||
"mfa:code": "/mfa/code",
|
||||
"mfa:select": "/mfa/select",
|
||||
};
|
||||
|
||||
function Auth(props: AuthProps) {
|
||||
const [route, setRoute] = useState(props.route);
|
||||
const [storedFormData, setStoredFormData] = useState<
|
||||
BaseFormData | undefined
|
||||
>();
|
||||
const Route = useMemo(() => getRouteComponent(route), [route]);
|
||||
useEffect(() => {
|
||||
window.history.replaceState({}, "", makeURL(routePaths[route]));
|
||||
}, [route]);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
sx={{
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{route === "login" || route === "signup" || route === "recover" ? (
|
||||
<Button
|
||||
sx={{
|
||||
display: "flex",
|
||||
mt: 2,
|
||||
mr: 2,
|
||||
alignSelf: "end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
variant={"secondary"}
|
||||
onClick={() => openURL("/notes/")}
|
||||
>
|
||||
Jump to app <ArrowRight size={18} sx={{ ml: 1 }} />
|
||||
</Button>
|
||||
) : route === "sessionExpiry" ? (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
sx={{
|
||||
display: "flex",
|
||||
mt: 2,
|
||||
mr: 2,
|
||||
alignSelf: "end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClick={() => db.user?.logout()}
|
||||
color="error"
|
||||
>
|
||||
<Logout size={16} sx={{ mr: 1 }} color="error" /> Logout
|
||||
permanently
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{Route && (
|
||||
<Route
|
||||
navigate={(route, formData) => {
|
||||
setStoredFormData(formData);
|
||||
setRoute(route);
|
||||
}}
|
||||
formData={storedFormData}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
export default Auth;
|
||||
|
||||
function Login(props: BaseAuthComponentProps<"login">) {
|
||||
const { navigate } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="login"
|
||||
title="Welcome back!"
|
||||
subtitle={
|
||||
<SubtitleWithAction
|
||||
text="Don't have an account?"
|
||||
action={{ text: "Sign up", onClick: () => navigate("signup") }}
|
||||
/>
|
||||
}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={(form) => login(form, navigate)}
|
||||
>
|
||||
{(form?: LoginFormData) => (
|
||||
<>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
label="Enter email"
|
||||
autoFocus={!form?.password}
|
||||
defaultValue={form?.email}
|
||||
/>
|
||||
<AuthField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
label="Enter password"
|
||||
autoFocus={!!form?.password}
|
||||
/>
|
||||
<Button
|
||||
data-test-id="auth-forgot-password"
|
||||
type="button"
|
||||
alignSelf="end"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
color="text"
|
||||
onClick={() => navigate("recover")}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
<SubmitButton
|
||||
text="Login to your account"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function Signup(props: BaseAuthComponentProps<"signup">) {
|
||||
const { navigate } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="signup"
|
||||
title="Create an account"
|
||||
subtitle={
|
||||
<SubtitleWithAction
|
||||
text="Already have an account?"
|
||||
action={{ text: "Log in", onClick: () => navigate("login") }}
|
||||
/>
|
||||
}
|
||||
loading={{
|
||||
title: "Creating your account",
|
||||
subtitle: "Please wait while we finalize your account.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
if (form.password !== form.confirmPassword) {
|
||||
throw new Error("Passwords do not match.");
|
||||
}
|
||||
|
||||
await userstore.signup(form);
|
||||
trackEvent(ANALYTICS_EVENTS.accountCreated);
|
||||
openURL("/notes/#/welcome");
|
||||
}}
|
||||
>
|
||||
{(form?: SignupFormData) => (
|
||||
<>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
label="Enter email"
|
||||
autoFocus
|
||||
defaultValue={form?.email}
|
||||
/>
|
||||
<AuthField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
label="Set password"
|
||||
defaultValue={form?.password}
|
||||
/>
|
||||
<AuthField
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
autoComplete="confirm-password"
|
||||
label="Confirm password"
|
||||
defaultValue={form?.confirmPassword}
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Create account"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
<Text mt={4} variant="subBody" fontSize={13} textAlign="center">
|
||||
By pressing "Create account" button, you agree to our{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="primary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/tos"
|
||||
>
|
||||
Terms of Service
|
||||
</Text>{" "}
|
||||
&{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="primary"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/privacy"
|
||||
>
|
||||
Privacy Policy
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
|
||||
const { navigate } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [user, setUser] = useState<User | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppLoaded) return;
|
||||
(async () => {
|
||||
const user = await db.user?.getUser();
|
||||
const isSessionExpired = Config.get("sessionExpired", false);
|
||||
if (user && isSessionExpired) {
|
||||
setUser(user);
|
||||
} else openURL("/");
|
||||
})();
|
||||
}, [isAppLoaded]);
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="sessionExpiry"
|
||||
title="Your session has expired"
|
||||
subtitle={
|
||||
<Flex bg="shade" p={1} sx={{ borderRadius: "default" }}>
|
||||
<Text as="span" fontSize="body" color="primary">
|
||||
<b>
|
||||
All your local changes are safe and will be synced after you
|
||||
login.
|
||||
</b>{" "}
|
||||
Please enter your password to continue.
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
if (!user) return;
|
||||
await login({ email: user.email, password: form.password }, navigate);
|
||||
}}
|
||||
>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete={"false"}
|
||||
label="Enter email"
|
||||
defaultValue={user ? maskEmail(user.email) : undefined}
|
||||
autoFocus
|
||||
disabled
|
||||
/>
|
||||
<AuthField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
label="Enter password"
|
||||
/>
|
||||
<Button
|
||||
data-test-id="auth-forgot-password"
|
||||
type="button"
|
||||
alignSelf="end"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
color="text"
|
||||
onClick={() => navigate("recover", { email: user!.email })}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
<SubmitButton
|
||||
text="Relogin to your account"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
|
||||
const { navigate, formData } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [success, setSuccess] = useState<string>();
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="recover"
|
||||
title="Recover your account"
|
||||
subtitle={
|
||||
<SubtitleWithAction
|
||||
text="Remembered your password?"
|
||||
action={{ text: "Log in", onClick: () => navigate("login") }}
|
||||
/>
|
||||
}
|
||||
loading={{
|
||||
title: "Sending recovery email",
|
||||
subtitle: "Please wait while we send you recovery instructions.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const url = await db.user?.recoverAccount(form.email.toLowerCase());
|
||||
if (isTesting()) return openURL(url);
|
||||
setSuccess(
|
||||
`Recovery email sent. Please check your inbox (and spam folder) for further instructions.`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{success ? (
|
||||
<Flex bg="background" p={2} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<CheckCircle size={20} color="primary" />
|
||||
<Text variant="body" color="primary" ml={2}>
|
||||
{success}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete={"email"}
|
||||
label="Enter your account email"
|
||||
helpText="You will receive instructions on how to recover your account on this email"
|
||||
defaultValue={formData ? formData.email : ""}
|
||||
autoFocus
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Send recovery email"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function getTexts(formData: MFAFormData) {
|
||||
return {
|
||||
app: {
|
||||
subtitle:
|
||||
"Please confirm your identity by entering the authentication code from your authenticator app.",
|
||||
instructions: `Open the two-factor authentication (TOTP) app to view your authentication code.`,
|
||||
selector: `Don't have access to your authenticator app?`,
|
||||
label: "Enter 6-digit code",
|
||||
},
|
||||
email: {
|
||||
subtitle:
|
||||
"Please confirm your identity by entering the authentication code sent to your email address.",
|
||||
instructions: `It may take a minute to receive your code.`,
|
||||
selector: `Don't have access to your email address?`,
|
||||
label: "Enter 6-digit code",
|
||||
},
|
||||
sms: {
|
||||
subtitle: `Please confirm your identity by entering the authentication code sent to ${
|
||||
formData.phoneNumber || "your registered phone number."
|
||||
}.`,
|
||||
instructions: `It may take a minute to receive your code.`,
|
||||
selector: `Don't have access to your phone number?`,
|
||||
label: "Enter 6-digit code",
|
||||
},
|
||||
recoveryCode: {
|
||||
subtitle: `Please confirm your identity by entering a recovery code.`,
|
||||
instructions: "",
|
||||
selector: `Don't have your recovery codes?`,
|
||||
label: "Enter recovery code",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function MFACode(props: BaseAuthComponentProps<"mfa:code">) {
|
||||
const { navigate, formData } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const { elapsed, enabled, setEnabled } = useTimer(
|
||||
`2fa.${formData?.primaryMethod}`,
|
||||
60
|
||||
);
|
||||
|
||||
const sendCode = useCallback(
|
||||
async (selectedMethod, token) => {
|
||||
setIsSending(true);
|
||||
try {
|
||||
await db.mfa!.sendCode(selectedMethod, token);
|
||||
setEnabled(false);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(error);
|
||||
showToast("error", error.message);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[setEnabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!formData ||
|
||||
formData.selectedMethod === "recoveryCode" ||
|
||||
formData.selectedMethod === "app"
|
||||
)
|
||||
return;
|
||||
|
||||
(async function () {
|
||||
await sendCode(formData.selectedMethod, formData.token);
|
||||
})();
|
||||
}, [formData, sendCode]);
|
||||
|
||||
if (!formData) {
|
||||
openURL("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { selectedMethod, token } = formData;
|
||||
const texts = getTexts(formData)[selectedMethod];
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="mfa:code"
|
||||
title="Two-factor authentication"
|
||||
subtitle={texts.subtitle}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const loginForm: MFALoginFormData = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
code: form.code,
|
||||
method: formData.selectedMethod,
|
||||
};
|
||||
await login(loginForm, navigate);
|
||||
}}
|
||||
>
|
||||
<AuthField
|
||||
id="code"
|
||||
type="number"
|
||||
autoComplete={"one-time-code"}
|
||||
label={texts.label}
|
||||
autoFocus
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
helpText={texts.instructions}
|
||||
action={
|
||||
selectedMethod === "sms" || selectedMethod === "email"
|
||||
? {
|
||||
disabled: isSending || !enabled,
|
||||
component: (
|
||||
<Text variant={"body"}>
|
||||
{isSending ? (
|
||||
<Loading size={18} />
|
||||
) : enabled ? (
|
||||
`Resend code`
|
||||
) : (
|
||||
`Resend in ${elapsed}`
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
onClick: async () => {
|
||||
await sendCode(selectedMethod, token);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Submit"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
mt={4}
|
||||
variant={"anchor"}
|
||||
color="text"
|
||||
onClick={() => navigate("mfa:select", formData)}
|
||||
>
|
||||
{texts.selector}
|
||||
</Button>
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
type MFAMethodType = AuthenticatorType | "recoveryCode";
|
||||
type MFAMethod = {
|
||||
type: MFAMethodType;
|
||||
title: string;
|
||||
icon: (props: any) => JSX.Element;
|
||||
};
|
||||
const MFAMethods: MFAMethod[] = [
|
||||
{ type: "app", title: "Use an authenticator app", icon: MFAAuthenticator },
|
||||
{ type: "sms", title: "Send code to your phone number", icon: MFASMS },
|
||||
{ type: "email", title: "Send code to your email address", icon: MFAEmail },
|
||||
{ type: "recoveryCode", title: "Use a recovery code", icon: MFARecoveryCode },
|
||||
];
|
||||
function MFASelector(props: BaseAuthComponentProps<"mfa:select">) {
|
||||
const { navigate, formData } = props;
|
||||
const [selected, setSelected] = useState(0);
|
||||
const isValidMethod = useCallback(
|
||||
(method: MFAMethodType) => {
|
||||
return (
|
||||
method === formData?.primaryMethod ||
|
||||
method === formData?.secondaryMethod ||
|
||||
method === "recoveryCode"
|
||||
);
|
||||
},
|
||||
[formData]
|
||||
);
|
||||
if (!formData) {
|
||||
openURL("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="mfa:select"
|
||||
title="Select two-factor authentication method"
|
||||
subtitle={`Where should we send you the authentication code?`}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const selectedType = MFAMethods[selected];
|
||||
formData.selectedMethod = selectedType.type;
|
||||
navigate("mfa:code", formData);
|
||||
}}
|
||||
>
|
||||
{MFAMethods.map(
|
||||
(method, index) =>
|
||||
isValidMethod(method.type) && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"secondary"}
|
||||
mt={2}
|
||||
sx={{
|
||||
":first-of-type": { mt: 2 },
|
||||
display: "flex",
|
||||
bg: "bgSecondary",
|
||||
alignSelf: "stretch",
|
||||
alignItems: "center",
|
||||
textAlign: "left",
|
||||
px: 2,
|
||||
}}
|
||||
onClick={() => setSelected(index)}
|
||||
>
|
||||
<method.icon
|
||||
sx={{
|
||||
bg: selected === index ? "shade" : "border",
|
||||
borderRadius: 100,
|
||||
width: 35,
|
||||
height: 35,
|
||||
mr: 2,
|
||||
}}
|
||||
size={16}
|
||||
color={selected === index ? "primary" : "text"}
|
||||
/>
|
||||
<Text variant={"title"} fontWeight="body">
|
||||
{method.title}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{/* <SubmitButton
|
||||
text="Submit"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/> */}
|
||||
{/* <Button type="button" mt={4} variant={"anchor"} color="text">
|
||||
Don't have access to your {mfaMethodToPhrase(formData.primaryMethod)}?
|
||||
</Button> */}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
// function MFAMethodSelector(params) {}
|
||||
|
||||
type AuthFormProps<TType extends AuthRoutes> = {
|
||||
title: string;
|
||||
subtitle: string | JSX.Element;
|
||||
loading: { title: string; subtitle: string };
|
||||
type: TType;
|
||||
onSubmit: (form: AuthFormData[TType]) => Promise<void>;
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((form?: AuthFormData[TType]) => React.ReactNode);
|
||||
};
|
||||
|
||||
function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
|
||||
const { title, subtitle, children } = props;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const formRef = useRef<HTMLFormElement>();
|
||||
const [form, setForm] = useState<AuthFormData[T] | undefined>();
|
||||
|
||||
if (isSubmitting)
|
||||
return <Loader title={props.loading.title} text={props.loading.subtitle} />;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={formRef}
|
||||
as="form"
|
||||
id="authForm"
|
||||
flexDirection="column"
|
||||
alignSelf="center"
|
||||
justifyContent={"center"}
|
||||
alignItems="center"
|
||||
width={["95%", 420]}
|
||||
flex={1}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData(formRef.current);
|
||||
const form = Object.fromEntries(formData.entries()) as AuthFormData[T];
|
||||
try {
|
||||
setForm(form);
|
||||
await props.onSubmit(form);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text variant={"heading"} fontSize={32} textAlign="center">
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
fontSize={"title"}
|
||||
textAlign="center"
|
||||
mt={2}
|
||||
mb={35}
|
||||
color="fontTertiary"
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{typeof children === "function" ? children(form) : children}
|
||||
{error && (
|
||||
<Flex bg="errorBg" p={1} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<ErrorIcon size={15} color="error" />
|
||||
<Text variant="error" ml={1}>
|
||||
{error}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
type SubtitleWithActionProps = {
|
||||
text: string;
|
||||
action: {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
function SubtitleWithAction(props: SubtitleWithActionProps) {
|
||||
return (
|
||||
<>
|
||||
{props.text}{" "}
|
||||
<Text
|
||||
sx={{
|
||||
textDecoration: "underline",
|
||||
":hover": { color: "dimPrimary" },
|
||||
cursor: "pointer",
|
||||
}}
|
||||
as="b"
|
||||
color="text"
|
||||
onClick={props.action.onClick}
|
||||
>
|
||||
{props.action.text}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type AuthFieldProps = {
|
||||
id: string;
|
||||
type: string;
|
||||
autoFocus?: boolean;
|
||||
autoComplete: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
defaultValue?: string;
|
||||
disabled?: boolean;
|
||||
inputMode?: string;
|
||||
pattern?: string;
|
||||
action?: {
|
||||
disabled?: boolean;
|
||||
component?: JSX.Element;
|
||||
onClick?: () => void | Promise<void>;
|
||||
};
|
||||
};
|
||||
function AuthField(props: AuthFieldProps) {
|
||||
return (
|
||||
<Field
|
||||
type={props.type}
|
||||
id={props.id}
|
||||
name={props.id}
|
||||
data-test-id={props.id}
|
||||
autoComplete={props.autoComplete}
|
||||
label={props.label}
|
||||
autoFocus={props.autoFocus}
|
||||
defaultValue={props.defaultValue}
|
||||
helpText={props.helpText}
|
||||
disabled={props.disabled}
|
||||
pattern={props.pattern}
|
||||
inputMode={props.inputMode}
|
||||
placeholder={props.placeholder}
|
||||
required
|
||||
action={props.action}
|
||||
styles={{
|
||||
container: { mt: 2, width: "100%" },
|
||||
// label: { fontWeight: "normal" },
|
||||
input: {
|
||||
p: "12px",
|
||||
borderRadius: "default",
|
||||
bg: "background",
|
||||
boxShadow: "0px 0px 5px 0px #00000019",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SubmitButtonProps = {
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
function SubmitButton(props: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
data-test-id="submitButton"
|
||||
display="flex"
|
||||
type="submit"
|
||||
mt={50}
|
||||
variant="primary"
|
||||
alignSelf={"center"}
|
||||
px={50}
|
||||
sx={{ borderRadius: 50 }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.loading ? <Loading color="static" /> : props.text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
async function login(
|
||||
form: LoginFormData | MFALoginFormData,
|
||||
navigate: NavigateFunction
|
||||
) {
|
||||
try {
|
||||
await userstore.login(form);
|
||||
Config.set("sessionExpired", false);
|
||||
openURL("/");
|
||||
} catch (e) {
|
||||
if (e instanceof RequestError && e.code === "mfa_required") {
|
||||
const { primaryMethod, phoneNumber, secondaryMethod, token } =
|
||||
e.data as MFAErrorData;
|
||||
|
||||
if (!primaryMethod)
|
||||
throw new Error(
|
||||
"Multi-factor is required but the server didn't send a primary MFA method."
|
||||
);
|
||||
|
||||
navigate("mfa:code", {
|
||||
...form,
|
||||
token,
|
||||
selectedMethod: primaryMethod,
|
||||
primaryMethod,
|
||||
phoneNumber,
|
||||
secondaryMethod,
|
||||
});
|
||||
} else throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function openURL(url: string, force?: boolean) {
|
||||
const queryParams = getQueryParams();
|
||||
const redirect = queryParams?.redirect;
|
||||
Config.set("skipInitiation", true);
|
||||
hardNavigate(force ? url : redirect || url);
|
||||
}
|
||||
|
||||
function maskEmail(email: string) {
|
||||
if (!email) return "";
|
||||
const [username, domain] = email.split("@");
|
||||
const maskChars = "*".repeat(
|
||||
username.substring(2, username.length - 2).length
|
||||
);
|
||||
return `${username.substring(0, 2)}${maskChars}${username.substring(
|
||||
username.length - 2
|
||||
)}@${domain}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ListContainer from "../components/list-container";
|
||||
import SearchPlaceholder from "../components/placeholders/search-placeholder";
|
||||
import { db } from "../common/db";
|
||||
@@ -40,6 +40,31 @@ function Search({ type }) {
|
||||
});
|
||||
const [results, setResults] = useState([]);
|
||||
const context = useNoteStore((store) => store.context);
|
||||
const nonce = useNoteStore((store) => store.nonce);
|
||||
const cachedQuery = useRef();
|
||||
|
||||
const onSearch = useCallback(
|
||||
async (query) => {
|
||||
if (!query) return;
|
||||
cachedQuery.current = query;
|
||||
|
||||
const [lookupType, items] = await typeToItems(type, context);
|
||||
setResults([]);
|
||||
|
||||
if (items.length <= 0) {
|
||||
showToast("error", `There are no items to search in.`);
|
||||
return;
|
||||
}
|
||||
setSearchState({ isSearching: true, totalItems: items.length });
|
||||
const results = await db.lookup[lookupType](items, query);
|
||||
setResults(results);
|
||||
setSearchState({ isSearching: false, totalItems: 0 });
|
||||
if (!results.length) {
|
||||
showToast("error", `Nothing found for "${query}".`);
|
||||
}
|
||||
},
|
||||
[context, type]
|
||||
);
|
||||
|
||||
const title = useMemo(() => {
|
||||
switch (type) {
|
||||
@@ -77,6 +102,10 @@ function Search({ type }) {
|
||||
}
|
||||
}, [type, context]);
|
||||
|
||||
useEffect(() => {
|
||||
onSearch(cachedQuery.current);
|
||||
}, [nonce, onSearch]);
|
||||
|
||||
if (!title) return hardNavigate("/");
|
||||
|
||||
return (
|
||||
@@ -84,25 +113,7 @@ function Search({ type }) {
|
||||
<Text variant="subtitle" mx={2}>
|
||||
Searching {title}
|
||||
</Text>
|
||||
<SearchBox
|
||||
onSearch={async (query) => {
|
||||
if (!query) return;
|
||||
const [lookupType, items] = await typeToItems(type, context);
|
||||
setResults([]);
|
||||
|
||||
if (items.length <= 0) {
|
||||
showToast("error", `There are no items to search in.`);
|
||||
return;
|
||||
}
|
||||
setSearchState({ isSearching: true, totalItems: items.length });
|
||||
const results = await db.lookup[lookupType](items, query);
|
||||
setResults(results);
|
||||
setSearchState({ isSearching: false, totalItems: 0 });
|
||||
if (!results.length) {
|
||||
showToast("error", `Nothing found for "${query}".`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SearchBox onSearch={onSearch} />
|
||||
{searchState.isSearching ? (
|
||||
<Flex
|
||||
flex="1"
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
showLoadingDialog,
|
||||
showBuyDialog,
|
||||
showPasswordDialog,
|
||||
showMultifactorDialog,
|
||||
showAttachmentsDialog,
|
||||
show2FARecoveryCodesDialog,
|
||||
} from "../common/dialog-controller";
|
||||
import { SUBSCRIPTION_STATUS } from "../common/constants";
|
||||
import { createBackup, verifyAccount } from "../common";
|
||||
@@ -129,6 +131,7 @@ const otherItems = [
|
||||
function Settings(props) {
|
||||
const [groups, setGroups] = useState({
|
||||
appearance: false,
|
||||
mfa: false,
|
||||
backup: false,
|
||||
importer: false,
|
||||
privacy: false,
|
||||
@@ -153,6 +156,7 @@ function Settings(props) {
|
||||
(store) => store.toggleEncryptBackups
|
||||
);
|
||||
const user = useUserStore((store) => store.user);
|
||||
const refreshUser = useUserStore((store) => store.refreshUser);
|
||||
const isLoggedIn = useUserStore((store) => store.isLoggedIn);
|
||||
const [backupReminderOffset, setBackupReminderOffset] = usePersistentState(
|
||||
"backupReminderOffset",
|
||||
@@ -284,6 +288,93 @@ function Settings(props) {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoggedIn && user.mfa && (
|
||||
<>
|
||||
<Header
|
||||
title="Two-factor authentication"
|
||||
isOpen={groups.mfa}
|
||||
onClick={() => {
|
||||
setGroups((g) => ({ ...g, mfa: !g.mfa }));
|
||||
}}
|
||||
/>
|
||||
{groups.mfa &&
|
||||
(user.mfa.isEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await showMultifactorDialog(user.mfa.primaryMethod);
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text={
|
||||
user.mfa.secondaryMethod
|
||||
? "Reconfigure fallback 2FA method"
|
||||
: "Add fallback 2FA method"
|
||||
}
|
||||
tip="You can use the fallback 2FA method in case you are unable to login via the primary method."
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await show2FARecoveryCodesDialog(
|
||||
user.mfa.primaryMethod
|
||||
);
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text="View recovery codes"
|
||||
tip={`Recovery codes can be used to login in case you cannot use any of the other 2FA methods. You have ${user.mfa.remainingValidCodes} recovery codes left.`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await db.mfa.disable();
|
||||
showToast(
|
||||
"success",
|
||||
"Two-factor authentication disabled."
|
||||
);
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text="Disable two-factor authentication"
|
||||
tip="You can disable 2FA if you want to reset or change 2FA settings."
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await showMultifactorDialog();
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text="Enable two-factor authentication"
|
||||
tip="Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to sign in."
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Header
|
||||
title="Appearance"
|
||||
isOpen={groups.appearance}
|
||||
@@ -364,6 +455,7 @@ function Settings(props) {
|
||||
setGroups((g) => ({ ...g, backup: !g.backup }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{groups.backup && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"downlevelIteration": true,
|
||||
"maxNodeModuleJsDepth": 1,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
Reference in New Issue
Block a user