added apis; added widgets

This commit is contained in:
Myzel394 2022-10-13 22:04:39 +02:00
parent 550baf1b66
commit 55fddaf0d9
22 changed files with 735 additions and 10 deletions

View File

@ -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",

View File

@ -0,0 +1,9 @@
import axios from "axios"
export default async function checkIsDomainDisposable(
domain: string,
): Promise<boolean> {
const {data} = await axios.get(`https://api.mailcheck.ai/domain/${domain}`)
return !data.mx || data.disposable
}

View File

@ -0,0 +1,8 @@
import axios from "axios"
import {ServerSettings} from "~/types";
export default async function getServerSettings(): Promise<ServerSettings> {
return (
await axios.get(`${process.env.NEXT_PUBLIC_SERVER_BASE_URL}/settings`)
).data
}

6
src/apis/index.ts Normal file
View File

@ -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"

1
src/apis/login.ts Normal file
View File

@ -0,0 +1 @@
export default function login() {}

16
src/apis/signup.ts Normal file
View File

@ -0,0 +1,16 @@
import axios from "axios"
export interface SignupResult {
normalized_email: string
}
export default async function signup(email: string): Promise<SignupResult> {
const {data} = await axios.post(
`${process.env.NEXT_PUBLIC_SERVER_BASE_URL}/auth/signup`,
{
email,
},
)
return data
}

View File

@ -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<AuthenticationDetails> {
const {data} = await axios.post(
`${process.env.NEXT_PUBLIC_SERVER_BASE_URL}/auth/verify-email`,
{
email: email,
token: token,
},
)
return data
}

View File

@ -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)[]

View File

@ -1,5 +1,5 @@
import {Box, Container} from "@mui/material"
import {ReactElement} from "react"
import React, {ReactElement} from "react"
export interface MultiStepFormElementProps {
children: ReactElement

View File

@ -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 {

12
src/components/index.ts Normal file
View File

@ -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"

View File

@ -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: <div>Hello world!</div>,
element: <RootRoute />,
},
]);

View File

@ -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, string> = {
[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<boolean>(false)
const [type, setType] = useState<AliasType | null>(null)
const [hasShownModal, setHasShownModal] = useSessionStorage<boolean>(
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 (
<Dialog open={type !== null} onClose={() => setType(null)}>
<DialogTitle>Email relay service detected</DialogTitle>
<DialogContent>
<Grid container spacing={2} justifyContent="center">
<Grid item>
<DialogContentText>
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.
</DialogContentText>
</Grid>
<Grid item>
<DialogContentText>
Detected email relay:
</DialogContentText>
</Grid>
<Grid item>
<Alert severity="info">{TYPE_NAME_MAP[type!]}</Alert>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button
autoFocus
startIcon={<MdCheck />}
onClick={() => setType(null)}
>
Got it
</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -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<Form>({
validationSchema: schema,
initialValues: {
email: "",
},
onSubmit: (values, {setErrors}) =>
handleErrors(
values.email,
setErrors,
)(signup).then(({normalized_email}) => onSignUp(normalized_email)),
})
return (
<>
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<SimpleForm
title="Sign up"
description="We only need your email and you are ready to go!"
continueActionLabel="Sign up"
nonFieldError={formik.errors.detail}
isSubmitting={formik.isSubmitting}
>
{[
<TextField
key="email"
fullWidth
name="email"
id="email"
label="Email"
inputMode="email"
value={formik.values.email}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.email &&
Boolean(formik.errors.email)
}
helperText={
formik.touched.email && formik.errors.email
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdEmail />
</InputAdornment>
),
}}
/>,
]}
</SimpleForm>
</form>
</MultiStepFormElement>
{!serverSettings.other_relays_enabled && (
<DetectEmailAutofillService
domains={serverSettings.other_relay_domains}
/>
)}
</>
)
}

View File

@ -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
}
},
),
})
}

View File

@ -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 (
<Box width="80vw">
<Grid
container
direction="column"
spacing={4}
paddingX={2}
paddingTop={4}
paddingBottom={1}
alignItems="end"
justifyContent="center"
>
<Grid item>
<Grid
container
direction="column"
spacing={4}
alignItems="center"
>
<Grid item>
<Grid container spacing={2} direction="column">
<Grid item>
<Typography
variant="h6"
component="h2"
align="center"
>
Generate Email Reports?
</Typography>
</Grid>
<Grid item>
<Typography
variant="subtitle1"
component="p"
>
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.
</Typography>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container spacing={2} direction="row">
<Grid item>
<Button
startIcon={<TiCancel />}
color="secondary"
onClick={onNo}
>
No
</Button>
</Grid>
<Grid item>
<Button
endIcon={<FaLongArrowAltRight />}
color="primary"
onClick={onYes}
>
Yes
</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</Box>
)
}

View File

@ -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<Form>({
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 (
<Box width="80vw">
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
paddingX={2}
paddingY={4}
alignItems="center"
justifyContent="center"
>
<Grid item>
<Grid container spacing={2} direction="column">
<Grid item>
<Typography
variant="h6"
component="h2"
align="center"
>
Set up your password
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p">
Please enter a safe password so that we can
encrypt your data.
</Typography>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container spacing={2} justifyContent="center">
<Grid item>
<PasswordField
fullWidth
id="password"
name="password"
label="Password"
autoComplete="new-password"
value={formik.values.password}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.password &&
Boolean(formik.errors.password)
}
helperText={
formik.touched.password &&
formik.errors.password
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdLock />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item>
<PasswordField
fullWidth
id="passwordConfirmation"
name="passwordConfirmation"
label="Confirm Password"
value={formik.values.passwordConfirmation}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.passwordConfirmation &&
Boolean(
formik.errors.passwordConfirmation,
)
}
helperText={
formik.touched.passwordConfirmation &&
formik.errors.passwordConfirmation
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdCheckCircle />
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
</Grid>
<Grid item>
<LoadingButton
type="submit"
variant="contained"
loading={formik.isSubmitting}
startIcon={<MdChevronRight />}
>
Continue
</LoadingButton>
</Grid>
</Grid>
</form>
</Box>
)
}

View File

@ -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 (
<MultiStepFormElement>
<Grid
container
direction="column"
spacing={4}
paddingX={2}
paddingY={4}
alignItems="center"
justifyContent="center"
>
<Grid item>
<Typography variant="h6" component="h2" align="center">
You got mail!
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p">
We sent you an email with a link to confirm your email
address. Please check your inbox and click on the link
to continue.
</Typography>
</Grid>
<Grid item>
<OpenMailButton domain={domain} />
</Grid>
</Grid>
</MultiStepFormElement>
)
}

View File

@ -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 (
<SingleElementWrapper>
<MultiStepForm
steps={[
() => (
<EmailForm
serverSettings={{}}
onSignUp={() => null}
/>
),
() => <YouGotMail domain={""} />,
]}
index={0}
/>
</SingleElementWrapper>
);
}

49
src/types.d.ts vendored Normal file
View File

@ -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<string>
email_verification_chars: string
email_verification_length: number
}

10
src/utils/index.ts Normal file
View File

@ -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"

View File

@ -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"