diff --git a/package.json b/package.json index fff7836..3ff6dc9 100755 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "openpgp": "^5.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^4.4.0", "react-router-dom": "^6.4.2", "react-use": "^17.4.0", "ua-parser-js": "^1.0.2", @@ -25,6 +26,7 @@ "@types/openpgp": "^4.4.18", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", + "@types/react-icons": "^3.0.0", "@types/react-router": "^5.1.19", "@types/react-router-dom": "^5.3.3", "@types/ua-parser-js": "^0.7.36", diff --git a/src/apis/check-is-domain-disposable.ts b/src/apis/check-is-domain-disposable.ts new file mode 100644 index 0000000..fa06fa8 --- /dev/null +++ b/src/apis/check-is-domain-disposable.ts @@ -0,0 +1,9 @@ +import axios from "axios" + +export default async function checkIsDomainDisposable( + domain: string, +): Promise { + const {data} = await axios.get(`https://api.mailcheck.ai/domain/${domain}`) + + return !data.mx || data.disposable +} diff --git a/src/apis/get-server-settings.ts b/src/apis/get-server-settings.ts new file mode 100644 index 0000000..cdacf05 --- /dev/null +++ b/src/apis/get-server-settings.ts @@ -0,0 +1,8 @@ +import axios from "axios" +import {ServerSettings} from "~/types"; + +export default async function getServerSettings(): Promise { + return ( + await axios.get(`${process.env.NEXT_PUBLIC_SERVER_BASE_URL}/settings`) + ).data +} diff --git a/src/apis/index.ts b/src/apis/index.ts new file mode 100644 index 0000000..36b6f00 --- /dev/null +++ b/src/apis/index.ts @@ -0,0 +1,6 @@ +export * from "./check-is-domain-disposable" +export {default as checkIsDomainDisposable} from "./check-is-domain-disposable" +export * from "./get-server-settings" +export {default as getServerSettings} from "./get-server-settings" +export * from "./signup" +export {default as signup} from "./signup" diff --git a/src/apis/login.ts b/src/apis/login.ts new file mode 100644 index 0000000..63ce312 --- /dev/null +++ b/src/apis/login.ts @@ -0,0 +1 @@ +export default function login() {} diff --git a/src/apis/signup.ts b/src/apis/signup.ts new file mode 100644 index 0000000..7c8aecf --- /dev/null +++ b/src/apis/signup.ts @@ -0,0 +1,16 @@ +import axios from "axios" + +export interface SignupResult { + normalized_email: string +} + +export default async function signup(email: string): Promise { + const {data} = await axios.post( + `${process.env.NEXT_PUBLIC_SERVER_BASE_URL}/auth/signup`, + { + email, + }, + ) + + return data +} diff --git a/src/apis/validate-token.ts b/src/apis/validate-token.ts new file mode 100644 index 0000000..d7a1e88 --- /dev/null +++ b/src/apis/validate-token.ts @@ -0,0 +1,22 @@ +import {AuthenticationDetails} from "~/types" +import axios from "axios" + +export interface ValidateTokenData { + email: string + token: string +} + +export default async function validateToken({ + email, + token, +}: ValidateTokenData): Promise { + const {data} = await axios.post( + `${process.env.NEXT_PUBLIC_SERVER_BASE_URL}/auth/verify-email`, + { + email: email, + token: token, + }, + ) + + return data +} diff --git a/src/components/MultiStepForm.tsx b/src/components/MultiStepForm.tsx index 994eeb8..17dfdd3 100644 --- a/src/components/MultiStepForm.tsx +++ b/src/components/MultiStepForm.tsx @@ -1,6 +1,6 @@ -import {forwardRef, ReactElement, useEffect, useRef, useState} from "react" +import React, {forwardRef, ReactElement, useEffect, useRef, useState} from "react" import {Paper} from "@mui/material" -import whenElementHasBounds from "~/utils/when-element-has-bounds" +import {whenElementHasBounds} from "~/utils" export interface MultiStepFormProps { steps: (() => ReactElement)[] diff --git a/src/components/MultiStepFormElement.tsx b/src/components/MultiStepFormElement.tsx index 1418851..9f90603 100644 --- a/src/components/MultiStepFormElement.tsx +++ b/src/components/MultiStepFormElement.tsx @@ -1,5 +1,5 @@ import {Box, Container} from "@mui/material" -import {ReactElement} from "react" +import React, {ReactElement} from "react" export interface MultiStepFormElementProps { children: ReactElement diff --git a/src/components/OpenMailButton.tsx b/src/components/OpenMailButton.tsx index f4baeee..8654327 100644 --- a/src/components/OpenMailButton.tsx +++ b/src/components/OpenMailButton.tsx @@ -1,7 +1,7 @@ import {ReactElement} from "react" import UAParser from "ua-parser-js" import {Button} from "@mui/material" -import APP_LINK_MAP from "utils/app-url-links" +import {APP_LINK_MAP} from "utils" import {IoMdMailOpen} from "react-icons/io" export interface OpenMailButtonProps { diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..d6b1efc --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,12 @@ +export * from "./MultiStepForm" +export { default as MultiStepForm } from "./MultiStepForm" +export * from "./MultiStepFormElement" +export { default as MultiStepFormElement } from "./MultiStepFormElement" +export * from "./OpenMailButton" +export { default as OpenMailButton } from "./OpenMailButton" +export * from "./PasswordField" +export { default as PasswordField } from "./PasswordField" +export * from "./SimpleForm" +export { default as SimpleForm } from "./SimpleForm" +export * from "./SingleElementWrapper" +export { default as SingleElementWrapper } from "./SingleElementWrapper" diff --git a/src/main.tsx b/src/main.tsx index dbbe87f..e57dd4b 100755 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,15 +1,12 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { - createBrowserRouter, - RouterProvider, - Route, -} from "react-router-dom"; +import {createBrowserRouter, RouterProvider,} from "react-router-dom"; +import RootRoute from "~/routes/Root"; const router = createBrowserRouter([ { path: "/", - element:
Hello world!
, + element: , }, ]); diff --git a/src/route-widgets/root/EmailForm/DetectEmailAutofillService.tsx b/src/route-widgets/root/EmailForm/DetectEmailAutofillService.tsx new file mode 100644 index 0000000..d71726d --- /dev/null +++ b/src/route-widgets/root/EmailForm/DetectEmailAutofillService.tsx @@ -0,0 +1,140 @@ +import {ReactElement, useCallback, useEffect, useRef, useState} from "react" +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, +} from "@mui/material" +import {MdCheck} from "react-icons/md" +import useSessionStorage from "hooks/use-session-storage" + +export interface DetectEmailAutofillServiceProps { + domains: string[] +} + +enum AliasType { + DuckDuckGo = "duck.com", + SimpleLogin = "simplelogin.com", +} + +const TYPE_NAME_MAP: Record = { + [AliasType.DuckDuckGo]: "DuckDuckGo's Email Tracking Protection", + [AliasType.SimpleLogin]: "SimpleLogin", +} + +const STORAGE_KEY = "has-shown-email-autofill-service" + +export default function DetectEmailAutofillService({ + domains, +}: DetectEmailAutofillServiceProps): ReactElement { + const $hasDetected = useRef(false) + + const [type, setType] = useState(null) + const [hasShownModal, setHasShownModal] = useSessionStorage( + STORAGE_KEY, + false, + ) + + const handleFound = useCallback( + (type: AliasType) => { + if (domains.includes(type)) { + if (hasShownModal) { + setType(type) + + setHasShownModal(true) + } + } + }, + [domains.length, hasShownModal], + ) + + useEffect(() => { + const checkDuckDuckGo = () => { + const $element = document.querySelector("body > ddg-autofill") + + if ($element) { + $hasDetected.current = true + handleFound(AliasType.DuckDuckGo) + return true + } + + return false + } + const checkSimpleLogin = () => { + const $element = document.querySelector( + "body > div.sl-button-wrapper", + ) + + if ( + $element && + $element.children[0].nodeName === "DIV" && + $element.children[0].className === "sl-button" + ) { + $hasDetected.current = true + handleFound(AliasType.SimpleLogin) + return true + } + + return false + } + + if (checkDuckDuckGo() || checkSimpleLogin()) { + return + } + + const observer = new MutationObserver(() => { + if ($hasDetected.current) { + return + } + + checkDuckDuckGo() + checkSimpleLogin() + }) + + observer.observe(document.body, {subtree: false, childList: true}) + + return () => { + observer.disconnect() + } + }, [handleFound]) + + return ( + setType(null)}> + Email relay service detected + + + + + We detected that you are using an email relay + service to sign up. This KleckRelay instance does + not support relaying to another email relay service. + You can either choose a different instance or sign + up with a different email address. + + + + + Detected email relay: + + + + {TYPE_NAME_MAP[type!]} + + + + + + + + ) +} diff --git a/src/route-widgets/root/EmailForm/index.tsx b/src/route-widgets/root/EmailForm/index.tsx new file mode 100644 index 0000000..5c9a778 --- /dev/null +++ b/src/route-widgets/root/EmailForm/index.tsx @@ -0,0 +1,82 @@ +import {ReactElement} from "react" +import {useFormik} from "formik" +import useSchema, {Form} from "./use-schema" +import {ServerSettings} from "apis/get-server-settings" +import signup from "apis/signup" +import {MdEmail} from "react-icons/md" +import {InputAdornment, TextField} from "@mui/material" +import DetectEmailAutofillService from "./DetectEmailAutofillService" +import handleErrors from "utils/handle-errors" +import {MultiStepFormElement, SimpleForm} from "components" + +interface EmailFormProps { + serverSettings: ServerSettings + onSignUp: (email: string) => void +} + +export default function EmailForm({ + serverSettings, + onSignUp, +}: EmailFormProps): ReactElement { + const schema = useSchema(serverSettings) + const formik = useFormik
({ + validationSchema: schema, + initialValues: { + email: "", + }, + onSubmit: (values, {setErrors}) => + handleErrors( + values.email, + setErrors, + )(signup).then(({normalized_email}) => onSignUp(normalized_email)), + }) + + return ( + <> + + + + {[ + + + + ), + }} + />, + ]} + + +
+ {!serverSettings.other_relays_enabled && ( + + )} + + ) +} diff --git a/src/route-widgets/root/EmailForm/use-schema.ts b/src/route-widgets/root/EmailForm/use-schema.ts new file mode 100644 index 0000000..f34eaae --- /dev/null +++ b/src/route-widgets/root/EmailForm/use-schema.ts @@ -0,0 +1,43 @@ +import * as yup from "yup" +import {ServerSettings} from "apis/get-server-settings" +import checkIsDomainDisposable from "apis/check-is-domain-disposable" + +export interface Form { + email: string + detail?: string +} + +export default function useSchema( + serverSettings: ServerSettings, +): yup.BaseSchema { + return yup.object().shape({ + email: yup + .string() + .email() + .required() + .test( + "notDisposable", + "Disposable email addresses are not allowed", + async (value, context) => { + if (serverSettings.disposable_emails_enabled) { + return true + } + + try { + await yup.string().email().validate(value, { + strict: true, + }) + const isDisposable = await checkIsDomainDisposable( + value!.split("@")[1], + ) + + return !isDisposable + } catch ({message}) { + // @ts-ignore + context.createError({message}) + return false + } + }, + ), + }) +} diff --git a/src/route-widgets/root/GenerateEmailReportsForm.tsx b/src/route-widgets/root/GenerateEmailReportsForm.tsx new file mode 100644 index 0000000..3a55947 --- /dev/null +++ b/src/route-widgets/root/GenerateEmailReportsForm.tsx @@ -0,0 +1,85 @@ +import {ReactElement} from "react" +import {Box, Button, Grid, Typography} from "@mui/material" +import {FaLongArrowAltRight} from "react-icons/fa" +import {TiCancel} from "react-icons/ti" + +export interface GenerateEmailReportsFormProps { + onYes: () => void + onNo: () => void +} + +export default function GenerateEmailReportsForm({ + onNo, + onYes, +}: GenerateEmailReportsFormProps): ReactElement { + return ( + + + + + + + + + Generate Email Reports? + + + + + Would you like to create fully encrypted + email reports for your mails? Only you + will be able to access it. Not even we + can decrypt it. + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/route-widgets/root/PasswordForm.tsx b/src/route-widgets/root/PasswordForm.tsx new file mode 100644 index 0000000..7f85c3d --- /dev/null +++ b/src/route-widgets/root/PasswordForm.tsx @@ -0,0 +1,168 @@ +import {ReactElement, useMemo} from "react" +import * as yup from "yup" +import {useFormik} from "formik" +import {Box, Grid, InputAdornment, Typography} from "@mui/material" +import {MdCheckCircle, MdChevronRight, MdLock} from "react-icons/md" +import {LoadingButton} from "@mui/lab" +import {PasswordField} from "components" +import handleErrors from "utils/handle-errors" +import {generateKey} from "openpgp" +import {isDev} from "constants/values" +import encryptString from "utils/encrypt-string" + +export interface PasswordFormProps { + email: string +} + +interface Form { + password: string + passwordConfirmation: string +} + +const schema = yup.object().shape({ + password: yup.string().required(), + passwordConfirmation: yup + .string() + .required() + .oneOf([yup.ref("password"), null], "Passwords must match"), +}) + +export default function PasswordForm({email}: PasswordFormProps): ReactElement { + const awaitGenerateKey = useMemo( + () => + generateKey({ + type: "rsa", + format: "armored", + curve: "curve25519", + userIDs: [{name: "John Smith", email: "john@example.com"}], + passphrase: "", + rsaBits: isDev ? 2048 : 4096, + }), + [], + ) + const formik = useFormik
({ + validationSchema: schema, + initialValues: { + password: "", + passwordConfirmation: "", + }, + onSubmit: (values, {setErrors}) => + handleErrors( + values, + setErrors, + )(async () => { + const keyPair = await awaitGenerateKey + + const encryptedPrivateKey = encryptString( + keyPair.privateKey, + `${values.password}-${email}`, + ) + + console.log(encryptedPrivateKey) + }), + }) + + return ( + + + + + + + + Set up your password + + + + + Please enter a safe password so that we can + encrypt your data. + + + + + + + + + + + ), + }} + /> + + + + + + ), + }} + /> + + + + + } + > + Continue + + + + +
+ ) +} diff --git a/src/route-widgets/root/YouGotMail.tsx b/src/route-widgets/root/YouGotMail.tsx new file mode 100644 index 0000000..bd0d405 --- /dev/null +++ b/src/route-widgets/root/YouGotMail.tsx @@ -0,0 +1,39 @@ +import {ReactElement} from "react" +import {Grid, Typography} from "@mui/material" +import {MultiStepFormElement, OpenMailButton} from "~/components" + +export interface YouGotMailProps { + domain: string +} + +export default function YouGotMail({domain}: YouGotMailProps): ReactElement { + return ( + + + + + You got mail! + + + + + We sent you an email with a link to confirm your email + address. Please check your inbox and click on the link + to continue. + + + + + + + + ) +} diff --git a/src/routes/Root.tsx b/src/routes/Root.tsx index e69de29..8471d79 100644 --- a/src/routes/Root.tsx +++ b/src/routes/Root.tsx @@ -0,0 +1,24 @@ +import {ReactElement} from "react"; +import SingleElementWrapper from "~/components/SingleElementWrapper"; +import MultiStepForm from "~/components/MultiStepForm"; +import EmailForm from "~/route-widgets/root/EmailForm"; +import YouGotMail from "~/route-widgets/root/YouGotMail"; + +export default function RootRoute(): ReactElement { + return ( + + ( + null} + /> + ), + () => , + ]} + index={0} + /> + + ); +} diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..dd85dc3 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,49 @@ +export enum ImageProxyFormatType { + WEBP = "webp", + PNG = "png", + JPEG = "jpeg", +} + +export enum ProxyUserAgentType { + APPLE_MAIL = "apple-mail", + GOOGLE_MAIL = "google-mail", + OUTLOOK_WINDOWS = "outlook-windows", + OUTLOOK_MACOS = "outlook-macos", + FIREFOX = "firefox", + CHROME = "chrome", +} + +export interface User { + id: string + createdAt: Date + email: { + address: string + isVerified: boolean + } + preferences: { + aliasRemoveTrackers: boolean + aliasCreateMailReport: boolean + aliasProxyImages: boolean + aliasImageProxyFormat: ImageProxyFormatType + aliasImageProxyUserAgent: ProxyUserAgentType + } +} + +export interface AuthenticationDetails { + access_token: string + refresh_token: string +} + +export interface ServerSettings { + mail_domain: string + random_email_id_min_length: number + random_email_id_chars: string + image_proxy_enabled: boolean + image_proxy_life_time: number + disposable_emails_enabled: boolean + other_relays_enabled: boolean + other_relay_domains: Array + email_verification_chars: string + email_verification_length: number +} + diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..53da680 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,10 @@ +export * from "./app-url-links" +export {default as APP_LINK_MAP} from "./app-url-links" +export * from "./encrypt-string" +export { default as encryptString } from "./encrypt-string" +export * from "./handle-errors" +export {default as handleErrors} from "./handle-errors" +export * from "./parse-fastapi-error" +export {default as parseFastapiError} from "./parse-fastapi-error" +export * from "./when-element-has-bounds" +export {default as whenElementHasBounds} from "./when-element-has-bounds" diff --git a/yarn.lock b/yarn.lock index 256be74..fda1f64 100755 --- a/yarn.lock +++ b/yarn.lock @@ -561,6 +561,13 @@ dependencies: "@types/react" "*" +"@types/react-icons@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/react-icons/-/react-icons-3.0.0.tgz#27ca2823a6add881d06a371bfff093afc1b9c829" + integrity sha512-Vefs6LkLqF61vfV7AiAqls+vpR94q67gunhMueDznG+msAkrYgRxl7gYjNem/kZ+as2l2mNChmF1jRZzzQQtMg== + dependencies: + react-icons "*" + "@types/react-is@^16.7.1 || ^17.0.0": version "17.0.3" resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-17.0.3.tgz#2d855ba575f2fc8d17ef9861f084acc4b90a137a" @@ -2201,6 +2208,11 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== +react-icons@*, react-icons@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.4.0.tgz#a13a8a20c254854e1ec9aecef28a95cdf24ef703" + integrity sha512-fSbvHeVYo/B5/L4VhB7sBA1i2tS8MkT0Hb9t2H1AVPkwGfVHLJCqyr2Py9dKMxsyM63Eng1GkdZfbWj+Fmv8Rg== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"