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

View File

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

View File

@ -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);

View File

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

View File

@ -12,7 +12,7 @@ export function Input(props: {
/>
<input
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}
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";
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, {

View File

@ -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 | string>(null);
const [account, setAccount] = useState<null | AccountProfile>(null);
const reCaptchaKey = conf().RECAPTCHA_SITE_KEY;
return (
<SubPageLayout>
{step === 0 ? (
<TrustBackendPart
onNext={() => {
setStep(1);
}}
/>
) : null}
{step === 1 ? (
<PassphraseGeneratePart
onNext={(n) => {
setMnemonic(n);
setStep(2);
}}
/>
) : null}
{step === 2 ? (
<AccountCreatePart
onNext={(v) => {
setAccount(v);
setStep(3);
}}
/>
) : null}
{step === 3 ? (
<VerifyPassphrase
mnemonic={mnemonic}
userData={account}
onNext={() => {
setStep(4);
}}
/>
) : null}
{step === 4 ? <p>Success, account now exists</p> : null}
</SubPageLayout>
<GoogleReCaptchaProvider reCaptchaKey={reCaptchaKey}>
<SubPageLayout>
{step === 0 ? (
<TrustBackendPart
onNext={() => {
setStep(1);
}}
/>
) : null}
{step === 1 ? (
<PassphraseGeneratePart
onNext={(m) => {
setMnemonic(m);
setStep(2);
}}
/>
) : null}
{step === 2 ? (
<AccountCreatePart
onNext={(a) => {
setAccount(a);
setStep(3);
}}
/>
) : null}
{step === 3 ? (
<VerifyPassphrase
mnemonic={mnemonic}
userData={account}
onNext={() => {
setStep(4);
}}
/>
) : null}
{step === 4 ? <p>Success, account now exists</p> : null}
</SubPageLayout>
</GoogleReCaptchaProvider>
);
}

View File

@ -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 (
<div>
<p>Account name</p>
<Input value={account} onInput={setAccount} />
<p>Device name</p>
<Input value={device} onInput={setDevice} />
<Button onClick={() => nextStep()}>Next</Button>
</div>
<LargeCard>
<LargeCardText
icon={<Icon icon={Icons.USER} />}
title="Account information"
>
Set up your account.... OR ELSE!
</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 { 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 (
<div>
<p>passphrase</p>
<Input value={mnemonic} onInput={setMnemonic} />
<p>Device name</p>
<Input value={device} onInput={setDevice} />
{result.loading ? <p>Loading...</p> : null}
{result.error ? <p>error: {result.error.toString()}</p> : null}
<Button onClick={() => execute(mnemonic, device)}>Login</Button>
</div>
<LargeCard top={<BrandPill />}>
<LargeCardText title="Login to your account">
Oh, you&apos;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!
</LargeCardText>
<div className="space-y-4">
<AuthInputBox
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 { 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 (
<div>
<p>verify passphrase</p>
<Input value={mnemonic} onInput={setMnemonic} />
{result.loading ? <p>Loading...</p> : null}
{result.error ? <p>error: {result.error.toString()}</p> : null}
<Button onClick={() => execute(mnemonic)}>Register</Button>
</div>
<LargeCard>
<LargeCardText
icon={<Icon icon={Icons.CIRCLE_CHECK} />}
title="Enter your passphrase"
>
If you&apos;ve already lost it, how will you ever be able to take care
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;
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<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,
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"),
};
}

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 */
width: 13px;
}
.grecaptcha-badge {
display: none !important;
}

View File

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