Add "top" part to large card, create auth input, add captcha things, put rest of auth flow in cards

Co-authored-by: William Oldham <github@binaryoverload.co.uk>
This commit is contained in:
Jip Fr 2023-11-17 17:38:52 +01:00
parent a25b3dee54
commit a5512b95e5
14 changed files with 216 additions and 73 deletions

View File

@ -27,6 +27,7 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-ga4": "^2.0.0", "react-ga4": "^2.0.0",
"react-google-recaptcha-v3": "^1.10.1",
"react-helmet-async": "^1.3.0", "react-helmet-async": "^1.3.0",
"react-i18next": "^12.1.1", "react-i18next": "^12.1.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",

View File

@ -80,6 +80,9 @@ dependencies:
react-ga4: react-ga4:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.1.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: react-helmet-async:
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0(react-dom@17.0.2)(react@17.0.2) 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==} resolution: {integrity: sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==}
dev: false 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): /react-helmet-async@1.3.0(react-dom@17.0.2)(react@17.0.2):
resolution: {integrity: sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==} resolution: {integrity: sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==}
peerDependencies: peerDependencies:

View File

@ -6,7 +6,7 @@ import { Icon, Icons } from "./Icon";
export function PassphaseDisplay(props: { mnemonic: string }) { export function PassphaseDisplay(props: { mnemonic: string }) {
const individualWords = props.mnemonic.split(" "); const individualWords = props.mnemonic.split(" ");
const [_, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const [hasCopied, setHasCopied] = useState(false); const [hasCopied, setHasCopied] = useState(false);

View File

@ -1,7 +1,17 @@
export function LargeCard(props: { children: React.ReactNode }) { export function LargeCard(props: {
children: React.ReactNode;
top?: React.ReactNode;
}) {
return ( return (
<div className="rounded-xl bg-largeCard-background bg-opacity-50 max-w-[600px] mx-auto p-[3rem]"> <div className="flex flex-col items-center">
{props.children} {props.top ? (
<div className="inline-block transform translate-y-1/2">
{props.top}
</div>
) : null}
<div className="w-full rounded-xl bg-largeCard-background bg-opacity-50 max-w-[600px] mx-auto p-[3rem]">
{props.children}
</div>
</div> </div>
); );
} }

View File

@ -12,7 +12,7 @@ export function Input(props: {
/> />
<input <input
placeholder="Search" placeholder="Search"
className="w-full py-2 px-3 pl-[calc(0.75rem+24px)] bg-video-context-inputBg rounded placeholder:text-video-context-inputPlaceholder" className="w-full py-2 px-3 pl-[calc(0.75rem+24px)] focus:outline-none bg-video-context-inputBg rounded placeholder:text-video-context-inputPlaceholder"
value={props.value} value={props.value}
onInput={(e) => props.onInput(e.currentTarget.value)} onInput={(e) => props.onInput(e.currentTarget.value)}
/> />

View File

@ -0,0 +1,22 @@
import { TextInputControl } from "./TextInputControl";
export function AuthInputBox(props: {
value?: string;
label?: string;
placeholder?: string;
onChange?: (data: string) => void;
}) {
return (
<div className="space-y-3">
{props.label ? (
<p className="font-bold text-white">{props.label}</p>
) : null}
<TextInputControl
value={props.value}
onChange={props.onChange}
placeholder={props.placeholder}
className="w-full flex-1 bg-authentication-inputBg px-4 py-3 text-search-text focus:outline-none rounded-lg placeholder:text-gray-700"
/>
</div>
);
}

View File

@ -19,6 +19,7 @@ import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
export interface RegistrationData { export interface RegistrationData {
recaptchaToken: string;
mnemonic: string; mnemonic: string;
userData: { userData: {
device: string; device: string;
@ -89,7 +90,10 @@ export function useAuth() {
const register = useCallback( const register = useCallback(
async (registerData: RegistrationData) => { async (registerData: RegistrationData) => {
const { challenge } = await getRegisterChallengeToken(backendUrl); const { challenge } = await getRegisterChallengeToken(
backendUrl,
registerData.recaptchaToken
);
const keys = await keysFromMnemonic(registerData.mnemonic); const keys = await keysFromMnemonic(registerData.mnemonic);
const signature = await signChallenge(keys, challenge); const signature = await signChallenge(keys, challenge);
const registerResult = await registerAccount(backendUrl, { const registerResult = await registerAccount(backendUrl, {

View File

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
import { import {
@ -8,47 +9,51 @@ import {
import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePart"; import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePart";
import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart"; import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart";
import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart"; import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart";
import { conf } from "@/setup/config";
export function RegisterPage() { export function RegisterPage() {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [mnemonic, setMnemonic] = useState<null | string>(null); const [mnemonic, setMnemonic] = useState<null | string>(null);
const [account, setAccount] = useState<null | AccountProfile>(null); const [account, setAccount] = useState<null | AccountProfile>(null);
const reCaptchaKey = conf().RECAPTCHA_SITE_KEY;
return ( return (
<SubPageLayout> <GoogleReCaptchaProvider reCaptchaKey={reCaptchaKey}>
{step === 0 ? ( <SubPageLayout>
<TrustBackendPart {step === 0 ? (
onNext={() => { <TrustBackendPart
setStep(1); onNext={() => {
}} setStep(1);
/> }}
) : null} />
{step === 1 ? ( ) : null}
<PassphraseGeneratePart {step === 1 ? (
onNext={(n) => { <PassphraseGeneratePart
setMnemonic(n); onNext={(m) => {
setStep(2); setMnemonic(m);
}} setStep(2);
/> }}
) : null} />
{step === 2 ? ( ) : null}
<AccountCreatePart {step === 2 ? (
onNext={(v) => { <AccountCreatePart
setAccount(v); onNext={(a) => {
setStep(3); setAccount(a);
}} setStep(3);
/> }}
) : null} />
{step === 3 ? ( ) : null}
<VerifyPassphrase {step === 3 ? (
mnemonic={mnemonic} <VerifyPassphrase
userData={account} mnemonic={mnemonic}
onNext={() => { userData={account}
setStep(4); onNext={() => {
}} setStep(4);
/> }}
) : null} />
{step === 4 ? <p>Success, account now exists</p> : null} ) : null}
</SubPageLayout> {step === 4 ? <p>Success, account now exists</p> : null}
</SubPageLayout>
</GoogleReCaptchaProvider>
); );
} }

View File

@ -1,11 +1,16 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Button } from "@/components/Button"; 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 { export interface AccountProfile {
device: string; device: string;
account: string;
profile: { profile: {
colorA: string; colorA: string;
colorB: string; colorB: string;
@ -18,13 +23,11 @@ interface AccountCreatePartProps {
} }
export function AccountCreatePart(props: AccountCreatePartProps) { export function AccountCreatePart(props: AccountCreatePartProps) {
const [account, setAccount] = useState("");
const [device, setDevice] = useState(""); const [device, setDevice] = useState("");
// TODO validate device and account before next step // TODO validate device and account before next step
const nextStep = useCallback(() => { const nextStep = useCallback(() => {
props.onNext?.({ props.onNext?.({
account,
device, device,
profile: { profile: {
colorA: "#fff", colorA: "#fff",
@ -32,15 +35,27 @@ export function AccountCreatePart(props: AccountCreatePartProps) {
icon: "brush", icon: "brush",
}, },
}); });
}, [account, device, props]); }, [device, props]);
return ( return (
<div> <LargeCard>
<p>Account name</p> <LargeCardText
<Input value={account} onInput={setAccount} /> icon={<Icon icon={Icons.USER} />}
<p>Device name</p> title="Account information"
<Input value={device} onInput={setDevice} /> >
<Button onClick={() => nextStep()}>Next</Button> Set up your account.... OR ELSE!
</div> </LargeCardText>
<AuthInputBox
label="Device name"
value={device}
onChange={setDevice}
placeholder="Muad'Dib's Nintendo Switch"
/>
<LargeCardButtons>
<Button theme="purple" onClick={() => nextStep()}>
Next
</Button>
</LargeCardButtons>
</LargeCard>
); );
} }

View File

@ -3,7 +3,13 @@ import { useAsyncFn } from "react-use";
import { verifyValidMnemonic } from "@/backend/accounts/crypto"; import { verifyValidMnemonic } from "@/backend/accounts/crypto";
import { Button } from "@/components/Button"; 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"; import { useAuth } from "@/hooks/auth/useAuth";
interface LoginFormPartProps { interface LoginFormPartProps {
@ -39,14 +45,38 @@ export function LoginFormPart(props: LoginFormPartProps) {
); );
return ( return (
<div> <LargeCard top={<BrandPill />}>
<p>passphrase</p> <LargeCardText title="Login to your account">
<Input value={mnemonic} onInput={setMnemonic} /> Oh, you&apos;re asking for the key to my top-secret lair, also known as
<p>Device name</p> The Fortress of Wordsmithery, accessed only by reciting the sacred
<Input value={device} onInput={setDevice} /> incantation of the 12-word passphrase!
{result.loading ? <p>Loading...</p> : null} </LargeCardText>
{result.error ? <p>error: {result.error.toString()}</p> : null} <div className="space-y-4">
<Button onClick={() => execute(mnemonic, device)}>Login</Button> <AuthInputBox
</div> label="12-Word Passphrase"
value={mnemonic}
onChange={setMnemonic}
placeholder="Passphrase"
/>
<AuthInputBox
label="Device name"
value={device}
onChange={setDevice}
placeholder="Device"
/>
{result.loading ? <p>Loading...</p> : null}
{result.error && !result.loading ? (
<p className="text-authentication-errorText">
{result.error.message}
</p>
) : null}
</div>
<LargeCardButtons>
<Button theme="purple" onClick={() => execute(mnemonic, device)}>
LET ME IN!
</Button>
</LargeCardButtons>
</LargeCard>
); );
} }

View File

@ -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 { useAsyncFn } from "react-use";
import { Button } from "@/components/Button"; 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 { useAuth } from "@/hooks/auth/useAuth";
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart"; import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
@ -16,10 +23,17 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
const [mnemonic, setMnemonic] = useState(""); const [mnemonic, setMnemonic] = useState("");
const { register, restore } = useAuth(); const { register, restore } = useAuth();
const { executeRecaptcha } = useGoogleReCaptcha();
const [result, execute] = useAsyncFn( const [result, execute] = useAsyncFn(
async (inputMnemonic: string) => { async (inputMnemonic: string) => {
const recaptchaToken = executeRecaptcha
? await executeRecaptcha()
: undefined;
if (!props.mnemonic || !props.userData) 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) if (inputMnemonic !== props.mnemonic)
throw new Error("Passphrase doesn't match"); throw new Error("Passphrase doesn't match");
@ -28,6 +42,7 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
await register({ await register({
mnemonic: inputMnemonic, mnemonic: inputMnemonic,
userData: props.userData, userData: props.userData,
recaptchaToken,
}); });
// TODO import (and sort out conflicts) // TODO import (and sort out conflicts)
@ -40,12 +55,29 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
); );
return ( return (
<div> <LargeCard>
<p>verify passphrase</p> <LargeCardText
<Input value={mnemonic} onInput={setMnemonic} /> icon={<Icon icon={Icons.CIRCLE_CHECK} />}
{result.loading ? <p>Loading...</p> : null} title="Enter your passphrase"
{result.error ? <p>error: {result.error.toString()}</p> : null} >
<Button onClick={() => execute(mnemonic)}>Register</Button> If you&apos;ve already lost it, how will you ever be able to take care
</div> of a child?
</LargeCardText>
<AuthInputBox
label="Your passphrase"
value={mnemonic}
onChange={setMnemonic}
/>
{result.error ? (
<p className="mt-3 text-authentication-errorText">
{result.error.message}
</p>
) : null}
<LargeCardButtons>
<Button theme="purple" onClick={() => execute(mnemonic)}>
Register
</Button>
</LargeCardButtons>
</LargeCard>
); );
} }

View File

@ -8,6 +8,7 @@ interface Config {
CORS_PROXY_URL: string; CORS_PROXY_URL: string;
NORMAL_ROUTER: boolean; NORMAL_ROUTER: boolean;
BACKEND_URL: string; BACKEND_URL: string;
RECAPTCHA_SITE_KEY: string;
} }
export interface RuntimeConfig { export interface RuntimeConfig {
@ -18,6 +19,7 @@ export interface RuntimeConfig {
NORMAL_ROUTER: boolean; NORMAL_ROUTER: boolean;
PROXY_URLS: string[]; PROXY_URLS: string[];
BACKEND_URL: string; BACKEND_URL: string;
RECAPTCHA_SITE_KEY: string;
} }
const env: Record<keyof Config, undefined | string> = { const env: Record<keyof Config, undefined | string> = {
@ -28,6 +30,7 @@ const env: Record<keyof Config, undefined | string> = {
CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL, CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
BACKEND_URL: import.meta.env.VITE_BACKEND_URL, 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) // loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
@ -53,5 +56,6 @@ export function conf(): RuntimeConfig {
.split(",") .split(",")
.map((v) => v.trim()), .map((v) => v.trim()),
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true", NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
RECAPTCHA_SITE_KEY: getKey("RECAPTCHA_SITE_KEY"),
}; };
} }

View File

@ -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 */ /* For some reason the styles don't get applied without the width */
width: 13px; width: 13px;
} }
.grecaptcha-badge {
display: none !important;
}

View File

@ -120,9 +120,11 @@ module.exports = {
// Passphrase // Passphrase
authentication: { authentication: {
border: "#393954", border: "#393954",
inputBg: "#171728",
wordBackground: "#171728", wordBackground: "#171728",
copyText: "#58587A", copyText: "#58587A",
copyTextHover: "#8888AA", copyTextHover: "#8888AA",
errorText: "#DB3D62",
}, },
// Settings page // Settings page