diff --git a/package.json b/package.json index d280a82f..4e3634d2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-ga4": "^2.0.0", + "react-google-recaptcha-v3": "^1.10.1", "react-helmet-async": "^1.3.0", "react-i18next": "^12.1.1", "react-router-dom": "^5.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e647a67f..587bf9a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ dependencies: react-ga4: specifier: ^2.0.0 version: 2.1.0 + react-google-recaptcha-v3: + specifier: ^1.10.1 + version: 1.10.1(react-dom@17.0.2)(react@17.0.2) react-helmet-async: specifier: ^1.3.0 version: 1.3.0(react-dom@17.0.2)(react@17.0.2) @@ -5187,6 +5190,17 @@ packages: resolution: {integrity: sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==} dev: false + /react-google-recaptcha-v3@1.10.1(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-K3AYzSE0SasTn+XvV2tq+6YaxM+zQypk9rbCgG4OVUt7Rh4ze9basIKefoBz9sC0CNslJj9N1uwTTgRMJQbQJQ==} + peerDependencies: + react: ^16.3 || ^17.0 || ^18.0 + react-dom: ^17.0 || ^18.0 + dependencies: + hoist-non-react-statics: 3.3.2 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + dev: false + /react-helmet-async@1.3.0(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==} peerDependencies: diff --git a/src/components/PassphraseDisplay.tsx b/src/components/PassphraseDisplay.tsx index 4a1235d9..c0567854 100644 --- a/src/components/PassphraseDisplay.tsx +++ b/src/components/PassphraseDisplay.tsx @@ -6,7 +6,7 @@ import { Icon, Icons } from "./Icon"; export function PassphaseDisplay(props: { mnemonic: string }) { const individualWords = props.mnemonic.split(" "); - const [_, copy] = useCopyToClipboard(); + const [, copy] = useCopyToClipboard(); const [hasCopied, setHasCopied] = useState(false); diff --git a/src/components/layout/LargeCard.tsx b/src/components/layout/LargeCard.tsx index 1367a85f..ba756ab0 100644 --- a/src/components/layout/LargeCard.tsx +++ b/src/components/layout/LargeCard.tsx @@ -1,7 +1,17 @@ -export function LargeCard(props: { children: React.ReactNode }) { +export function LargeCard(props: { + children: React.ReactNode; + top?: React.ReactNode; +}) { return ( -
- {props.children} +
+ {props.top ? ( +
+ {props.top} +
+ ) : null} +
+ {props.children} +
); } diff --git a/src/components/player/internals/ContextMenu/Input.tsx b/src/components/player/internals/ContextMenu/Input.tsx index df3bedc5..79023f88 100644 --- a/src/components/player/internals/ContextMenu/Input.tsx +++ b/src/components/player/internals/ContextMenu/Input.tsx @@ -12,7 +12,7 @@ export function Input(props: { /> props.onInput(e.currentTarget.value)} /> diff --git a/src/components/text-inputs/AuthInputBox.tsx b/src/components/text-inputs/AuthInputBox.tsx new file mode 100644 index 00000000..be0cee19 --- /dev/null +++ b/src/components/text-inputs/AuthInputBox.tsx @@ -0,0 +1,22 @@ +import { TextInputControl } from "./TextInputControl"; + +export function AuthInputBox(props: { + value?: string; + label?: string; + placeholder?: string; + onChange?: (data: string) => void; +}) { + return ( +
+ {props.label ? ( +

{props.label}

+ ) : null} + +
+ ); +} diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 2a7e6b2d..2024b4d5 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -19,6 +19,7 @@ import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useAuthStore } from "@/stores/auth"; export interface RegistrationData { + recaptchaToken: string; mnemonic: string; userData: { device: string; @@ -89,7 +90,10 @@ export function useAuth() { const register = useCallback( async (registerData: RegistrationData) => { - const { challenge } = await getRegisterChallengeToken(backendUrl); + const { challenge } = await getRegisterChallengeToken( + backendUrl, + registerData.recaptchaToken + ); const keys = await keysFromMnemonic(registerData.mnemonic); const signature = await signChallenge(keys, challenge); const registerResult = await registerAccount(backendUrl, { diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 95ff5f2e..e7458f0a 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { @@ -8,47 +9,51 @@ import { import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePart"; import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart"; import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart"; +import { conf } from "@/setup/config"; export function RegisterPage() { const [step, setStep] = useState(0); const [mnemonic, setMnemonic] = useState(null); const [account, setAccount] = useState(null); + const reCaptchaKey = conf().RECAPTCHA_SITE_KEY; return ( - - {step === 0 ? ( - { - setStep(1); - }} - /> - ) : null} - {step === 1 ? ( - { - setMnemonic(n); - setStep(2); - }} - /> - ) : null} - {step === 2 ? ( - { - setAccount(v); - setStep(3); - }} - /> - ) : null} - {step === 3 ? ( - { - setStep(4); - }} - /> - ) : null} - {step === 4 ?

Success, account now exists

: null} -
+ + + {step === 0 ? ( + { + setStep(1); + }} + /> + ) : null} + {step === 1 ? ( + { + setMnemonic(m); + setStep(2); + }} + /> + ) : null} + {step === 2 ? ( + { + setAccount(a); + setStep(3); + }} + /> + ) : null} + {step === 3 ? ( + { + setStep(4); + }} + /> + ) : null} + {step === 4 ?

Success, account now exists

: null} +
+
); } diff --git a/src/pages/parts/auth/AccountCreatePart.tsx b/src/pages/parts/auth/AccountCreatePart.tsx index bdaff578..ef4be090 100644 --- a/src/pages/parts/auth/AccountCreatePart.tsx +++ b/src/pages/parts/auth/AccountCreatePart.tsx @@ -1,11 +1,16 @@ import { useCallback, useState } from "react"; import { Button } from "@/components/Button"; -import { Input } from "@/components/player/internals/ContextMenu/Input"; +import { Icon, Icons } from "@/components/Icon"; +import { + LargeCard, + LargeCardButtons, + LargeCardText, +} from "@/components/layout/LargeCard"; +import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; export interface AccountProfile { device: string; - account: string; profile: { colorA: string; colorB: string; @@ -18,13 +23,11 @@ interface AccountCreatePartProps { } export function AccountCreatePart(props: AccountCreatePartProps) { - const [account, setAccount] = useState(""); const [device, setDevice] = useState(""); // TODO validate device and account before next step const nextStep = useCallback(() => { props.onNext?.({ - account, device, profile: { colorA: "#fff", @@ -32,15 +35,27 @@ export function AccountCreatePart(props: AccountCreatePartProps) { icon: "brush", }, }); - }, [account, device, props]); + }, [device, props]); return ( -
-

Account name

- -

Device name

- - -
+ + } + title="Account information" + > + Set up your account.... OR ELSE! + + + + + + ); } diff --git a/src/pages/parts/auth/LoginFormPart.tsx b/src/pages/parts/auth/LoginFormPart.tsx index 72aabeb0..f5082826 100644 --- a/src/pages/parts/auth/LoginFormPart.tsx +++ b/src/pages/parts/auth/LoginFormPart.tsx @@ -3,7 +3,13 @@ import { useAsyncFn } from "react-use"; import { verifyValidMnemonic } from "@/backend/accounts/crypto"; import { Button } from "@/components/Button"; -import { Input } from "@/components/player/internals/ContextMenu/Input"; +import { BrandPill } from "@/components/layout/BrandPill"; +import { + LargeCard, + LargeCardButtons, + LargeCardText, +} from "@/components/layout/LargeCard"; +import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { useAuth } from "@/hooks/auth/useAuth"; interface LoginFormPartProps { @@ -39,14 +45,38 @@ export function LoginFormPart(props: LoginFormPartProps) { ); return ( -
-

passphrase

- -

Device name

- - {result.loading ?

Loading...

: null} - {result.error ?

error: {result.error.toString()}

: null} - -
+ }> + + Oh, you're asking for the key to my top-secret lair, also known as + The Fortress of Wordsmithery, accessed only by reciting the sacred + incantation of the 12-word passphrase! + +
+ + + {result.loading ?

Loading...

: null} + {result.error && !result.loading ? ( +

+ {result.error.message} +

+ ) : null} +
+ + + + +
); } diff --git a/src/pages/parts/auth/VerifyPassphrasePart.tsx b/src/pages/parts/auth/VerifyPassphrasePart.tsx index 3bd58d55..270be190 100644 --- a/src/pages/parts/auth/VerifyPassphrasePart.tsx +++ b/src/pages/parts/auth/VerifyPassphrasePart.tsx @@ -1,8 +1,15 @@ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { GoogleReCaptcha, useGoogleReCaptcha } from "react-google-recaptcha-v3"; import { useAsyncFn } from "react-use"; import { Button } from "@/components/Button"; -import { Input } from "@/components/player/internals/ContextMenu/Input"; +import { Icon, Icons } from "@/components/Icon"; +import { + LargeCard, + LargeCardButtons, + LargeCardText, +} from "@/components/layout/LargeCard"; +import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { useAuth } from "@/hooks/auth/useAuth"; import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart"; @@ -16,10 +23,17 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { const [mnemonic, setMnemonic] = useState(""); const { register, restore } = useAuth(); + const { executeRecaptcha } = useGoogleReCaptcha(); + const [result, execute] = useAsyncFn( async (inputMnemonic: string) => { + const recaptchaToken = executeRecaptcha + ? await executeRecaptcha() + : undefined; + if (!props.mnemonic || !props.userData) - throw new Error("invalid input data"); + throw new Error("Data is not valid"); + if (!recaptchaToken) throw new Error("ReCaptcha validation failed"); if (inputMnemonic !== props.mnemonic) throw new Error("Passphrase doesn't match"); @@ -28,6 +42,7 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { await register({ mnemonic: inputMnemonic, userData: props.userData, + recaptchaToken, }); // TODO import (and sort out conflicts) @@ -40,12 +55,29 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { ); return ( -
-

verify passphrase

- - {result.loading ?

Loading...

: null} - {result.error ?

error: {result.error.toString()}

: null} - -
+ + } + title="Enter your passphrase" + > + If you've already lost it, how will you ever be able to take care + of a child? + + + {result.error ? ( +

+ {result.error.message} +

+ ) : null} + + + +
); } diff --git a/src/setup/config.ts b/src/setup/config.ts index 36b63c2c..ffc57ffa 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -8,6 +8,7 @@ interface Config { CORS_PROXY_URL: string; NORMAL_ROUTER: boolean; BACKEND_URL: string; + RECAPTCHA_SITE_KEY: string; } export interface RuntimeConfig { @@ -18,6 +19,7 @@ export interface RuntimeConfig { NORMAL_ROUTER: boolean; PROXY_URLS: string[]; BACKEND_URL: string; + RECAPTCHA_SITE_KEY: string; } const env: Record = { @@ -28,6 +30,7 @@ const env: Record = { CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL, NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, BACKEND_URL: import.meta.env.VITE_BACKEND_URL, + RECAPTCHA_SITE_KEY: import.meta.env.VITE_RECAPTCHA_SITE_KEY, }; // loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js) @@ -53,5 +56,6 @@ export function conf(): RuntimeConfig { .split(",") .map((v) => v.trim()), NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true", + RECAPTCHA_SITE_KEY: getKey("RECAPTCHA_SITE_KEY"), }; } diff --git a/src/setup/index.css b/src/setup/index.css index d656ae3e..cbf38334 100644 --- a/src/setup/index.css +++ b/src/setup/index.css @@ -210,3 +210,7 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower { /* For some reason the styles don't get applied without the width */ width: 13px; } + +.grecaptcha-badge { + display: none !important; +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 883d49a3..8e79202b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -120,9 +120,11 @@ module.exports = { // Passphrase authentication: { border: "#393954", + inputBg: "#171728", wordBackground: "#171728", copyText: "#58587A", copyTextHover: "#8888AA", + errorText: "#DB3D62", }, // Settings page