diff options
author | Sebastian <sebasjm@gmail.com> | 2021-10-19 10:56:52 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2021-10-19 11:05:32 -0300 |
commit | 5883d42d800c7b444c59d626bcaa5abca7dc83d0 (patch) | |
tree | ac42ad7b9e26c4dd2145a31101305884906a543e /packages/anastasis-webui/src/pages/home | |
parent | 269022a526b670d602ca146f4df02850983bb72e (diff) | |
download | wallet-core-5883d42d800c7b444c59d626bcaa5abca7dc83d0.tar.xz |
add template from merchant backoffice
Diffstat (limited to 'packages/anastasis-webui/src/pages/home')
25 files changed, 1162 insertions, 0 deletions
diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx new file mode 100644 index 000000000..4df99db9a --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AnastasisReducerApi, ReducerStateRecovery, ReducerStateBackup } from "../../hooks/use-anastasis-reducer"; +import { AnastasisClientFrame, withProcessLabel, LabeledInput } from "./index"; + +export function AttributeEntryScreen(props: AttributeEntryProps): VNode { + const { reducer, reducerState: backupState } = props; + const [attrs, setAttrs] = useState<Record<string, string>>( + props.reducerState.identity_attributes ?? {} + ); + return ( + <AnastasisClientFrame + title={withProcessLabel(reducer, "Select Country")} + onNext={() => reducer.transition("enter_user_attributes", { + identity_attributes: attrs, + })} + > + {backupState.required_attributes.map((x: any, i: number) => { + return ( + <AttributeEntryField + key={i} + isFirst={i == 0} + setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })} + spec={x} + value={attrs[x.name]} /> + ); + })} + </AnastasisClientFrame> + ); +} + +interface AttributeEntryProps { + reducer: AnastasisReducerApi; + reducerState: ReducerStateRecovery | ReducerStateBackup; +} + +export interface AttributeEntryFieldProps { + isFirst: boolean; + value: string; + setValue: (newValue: string) => void; + spec: any; +} + +export function AttributeEntryField(props: AttributeEntryFieldProps): VNode { + return ( + <div> + <LabeledInput + grabFocus={props.isFirst} + label={props.spec.label} + bind={[props.value, props.setValue]} + /> + </div> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx new file mode 100644 index 000000000..9aa6855fe --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AuthMethodSetupProps, AnastasisClientFrame, LabeledInput } from "./index"; + +export function AuthMethodEmailSetup(props: AuthMethodSetupProps): VNode { + const [email, setEmail] = useState(""); + return ( + <AnastasisClientFrame hideNav title="Add email authentication"> + <p> + For email authentication, you need to provide an email address. When + recovering your secret, you will need to enter the code you receive by + email. + </p> + <div> + <LabeledInput + label="Email address" + grabFocus + bind={[email, setEmail]} /> + </div> + <div> + <button onClick={() => props.cancel()}>Cancel</button> + <button + onClick={() => props.addAuthMethod({ + authentication_method: { + type: "email", + instructions: `Email to ${email}`, + challenge: encodeCrock(stringToBytes(email)), + }, + })} + > + Add + </button> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx new file mode 100644 index 000000000..43dcde330 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + canonicalJson, encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AuthMethodSetupProps, LabeledInput } from "./index"; + +export function AuthMethodPostSetup(props: AuthMethodSetupProps): VNode { + const [fullName, setFullName] = useState(""); + const [street, setStreet] = useState(""); + const [city, setCity] = useState(""); + const [postcode, setPostcode] = useState(""); + const [country, setCountry] = useState(""); + + const addPostAuth = () => { + const challengeJson = { + full_name: fullName, + street, + city, + postcode, + country, + }; + props.addAuthMethod({ + authentication_method: { + type: "email", + instructions: `Letter to address in postal code ${postcode}`, + challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))), + }, + }); + }; + + return ( + <div class={style.home}> + <h1>Add {props.method} authentication</h1> + <div> + <p> + For postal letter authentication, you need to provide a postal + address. When recovering your secret, you will be asked to enter a + code that you will receive in a letter to that address. + </p> + <div> + <LabeledInput + grabFocus + label="Full Name" + bind={[fullName, setFullName]} /> + </div> + <div> + <LabeledInput label="Street" bind={[street, setStreet]} /> + </div> + <div> + <LabeledInput label="City" bind={[city, setCity]} /> + </div> + <div> + <LabeledInput label="Postal Code" bind={[postcode, setPostcode]} /> + </div> + <div> + <LabeledInput label="Country" bind={[country, setCountry]} /> + </div> + <div> + <button onClick={() => props.cancel()}>Cancel</button> + <button onClick={() => addPostAuth()}>Add</button> + </div> + </div> + </div> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx new file mode 100644 index 000000000..7a0da7ebf --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AuthMethodSetupProps, AnastasisClientFrame, LabeledInput } from "./index"; + +export function AuthMethodQuestionSetup(props: AuthMethodSetupProps): VNode { + const [questionText, setQuestionText] = useState(""); + const [answerText, setAnswerText] = useState(""); + const addQuestionAuth = (): void => props.addAuthMethod({ + authentication_method: { + type: "question", + instructions: questionText, + challenge: encodeCrock(stringToBytes(answerText)), + }, + }); + return ( + <AnastasisClientFrame hideNav title="Add Security Question"> + <div> + <p> + For security question authentication, you need to provide a question + and its answer. When recovering your secret, you will be shown the + question and you will need to type the answer exactly as you typed it + here. + </p> + <div> + <LabeledInput + label="Security question" + grabFocus + bind={[questionText, setQuestionText]} /> + </div> + <div> + <LabeledInput label="Answer" bind={[answerText, setAnswerText]} /> + </div> + <div> + <button onClick={() => props.cancel()}>Cancel</button> + <button onClick={() => addQuestionAuth()}>Add</button> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx new file mode 100644 index 000000000..d193f6eb7 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState, useRef, useLayoutEffect } from "preact/hooks"; +import { AuthMethodSetupProps, AnastasisClientFrame } from "./index"; + +export function AuthMethodSmsSetup(props: AuthMethodSetupProps): VNode { + const [mobileNumber, setMobileNumber] = useState(""); + const addSmsAuth = (): void => { + props.addAuthMethod({ + authentication_method: { + type: "sms", + instructions: `SMS to ${mobileNumber}`, + challenge: encodeCrock(stringToBytes(mobileNumber)), + }, + }); + }; + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + return ( + <AnastasisClientFrame hideNav title="Add SMS authentication"> + <div> + <p> + For SMS authentication, you need to provide a mobile number. When + recovering your secret, you will be asked to enter the code you + receive via SMS. + </p> + <label> + Mobile number:{" "} + <input + value={mobileNumber} + ref={inputRef} + style={{ display: "block" }} + autoFocus + onChange={(e) => setMobileNumber((e.target as any).value)} + type="text" /> + </label> + <div> + <button onClick={() => props.cancel()}>Cancel</button> + <button onClick={() => addSmsAuth()}>Add</button> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx new file mode 100644 index 000000000..5357891a9 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AuthMethod, ReducerStateBackup } from "anastasis-core"; +import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer"; +import { AuthMethodEmailSetup } from "./AuthMethodEmailSetup"; +import { AuthMethodPostSetup } from "./AuthMethodPostSetup"; +import { AuthMethodQuestionSetup } from "./AuthMethodQuestionSetup"; +import { AuthMethodSmsSetup } from "./AuthMethodSmsSetup"; +import { AnastasisClientFrame } from "./index"; + +export function AuthenticationEditorScreen(props: AuthenticationEditorProps): VNode { + const [selectedMethod, setSelectedMethod] = useState<string | undefined>( + undefined + ); + const { reducer, backupState } = props; + const providers = backupState.authentication_providers!; + const authAvailableSet = new Set<string>(); + for (const provKey of Object.keys(providers)) { + const p = providers[provKey]; + if ("http_status" in p && (!("error_code" in p)) && p.methods) { + for (const meth of p.methods) { + authAvailableSet.add(meth.type); + } + } + } + if (selectedMethod) { + const cancel = (): void => setSelectedMethod(undefined); + const addMethod = (args: any): void => { + reducer.transition("add_authentication", args); + setSelectedMethod(undefined); + }; + const methodMap: Record< + string, (props: AuthMethodSetupProps) => h.JSX.Element + > = { + sms: AuthMethodSmsSetup, + question: AuthMethodQuestionSetup, + email: AuthMethodEmailSetup, + post: AuthMethodPostSetup, + }; + const AuthSetup = methodMap[selectedMethod] ?? AuthMethodNotImplemented; + return ( + <AuthSetup + cancel={cancel} + addAuthMethod={addMethod} + method={selectedMethod} /> + ); + } + function MethodButton(props: { method: string; label: string }): VNode { + return ( + <button + disabled={!authAvailableSet.has(props.method)} + onClick={() => { + setSelectedMethod(props.method); + reducer.dismissError(); + }} + > + {props.label} + </button> + ); + } + const configuredAuthMethods: AuthMethod[] = backupState.authentication_methods ?? []; + const haveMethodsConfigured = configuredAuthMethods.length; + return ( + <AnastasisClientFrame title="Backup: Configure Authentication Methods"> + <div> + <MethodButton method="sms" label="SMS" /> + <MethodButton method="email" label="Email" /> + <MethodButton method="question" label="Question" /> + <MethodButton method="post" label="Physical Mail" /> + <MethodButton method="totp" label="TOTP" /> + <MethodButton method="iban" label="IBAN" /> + </div> + <h2>Configured authentication methods</h2> + {haveMethodsConfigured ? ( + configuredAuthMethods.map((x, i) => { + return ( + <p key={i}> + {x.type} ({x.instructions}){" "} + <button + onClick={() => reducer.transition("delete_authentication", { + authentication_method: i, + })} + > + Delete + </button> + </p> + ); + }) + ) : ( + <p>No authentication methods configured yet.</p> + )} + </AnastasisClientFrame> + ); +} + +interface AuthMethodSetupProps { + method: string; + addAuthMethod: (x: any) => void; + cancel: () => void; +} + +function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { + return ( + <AnastasisClientFrame hideNav title={`Add ${props.method} authentication`}> + <p>This auth method is not implemented yet, please choose another one.</p> + <button onClick={() => props.cancel()}>Cancel</button> + </AnastasisClientFrame> + ); +} + +interface AuthenticationEditorProps { + reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; +} + diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx new file mode 100644 index 000000000..6c2770947 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx @@ -0,0 +1,23 @@ +import { h, VNode } from "preact"; +import { BackupReducerProps, AnastasisClientFrame } from "./index"; + +export function BackupFinishedScreen(props: BackupReducerProps): VNode { + return (<AnastasisClientFrame hideNext title="Backup finished"> + <p> + Your backup of secret "{props.backupState.secret_name ?? "??"}" was + successful. + </p> + <p>The backup is stored by the following providers:</p> + <ul> + {Object.keys(props.backupState.success_details!).map((x, i) => { + const sd = props.backupState.success_details![x]; + return ( + <li key={i}> + {x} (Policy version {sd.policy_version}) + </li> + ); + })} + </ul> + <button onClick={() => props.reducer.reset()}>Back to start</button> + </AnastasisClientFrame>); +} diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx new file mode 100644 index 000000000..1f108ce6d --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx @@ -0,0 +1,63 @@ +import { h, VNode } from "preact"; +import { RecoveryReducerProps, AnastasisClientFrame } from "./index"; + +export function ChallengeOverviewScreen(props: RecoveryReducerProps): VNode { + const { recoveryState, reducer } = props; + const policies = recoveryState.recovery_information!.policies; + const chArr = recoveryState.recovery_information!.challenges; + const challenges: { + [uuid: string]: { + type: string; + instructions: string; + cost: string; + }; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = { + type: ch.type, + cost: ch.cost, + instructions: ch.instructions, + }; + } + return ( + <AnastasisClientFrame title="Recovery: Solve challenges"> + <h2>Policies</h2> + {policies.map((x, i) => { + return ( + <div key={i}> + <h3>Policy #{i + 1}</h3> + {x.map((x, j) => { + const ch = challenges[x.uuid]; + const feedback = recoveryState.challenge_feedback?.[x.uuid]; + return ( + <div key={j} + style={{ + borderLeft: "2px solid gray", + paddingLeft: "0.5em", + borderRadius: "0.5em", + marginTop: "0.5em", + marginBottom: "0.5em", + }} + > + <h4> + {ch.type} ({ch.instructions}) + </h4> + <p>Status: {feedback?.state ?? "unknown"}</p> + {feedback?.state !== "solved" ? ( + <button + onClick={() => reducer.transition("select_challenge", { + uuid: x.uuid, + })} + > + Solve + </button> + ) : null} + </div> + ); + })} + </div> + ); + })} + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx new file mode 100644 index 000000000..2fed23d4e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx @@ -0,0 +1,19 @@ +import { h, VNode } from "preact"; +import { CommonReducerProps, AnastasisClientFrame, withProcessLabel } from "./index"; + +export function ContinentSelectionScreen(props: CommonReducerProps): VNode { + const { reducer, reducerState } = props; + const sel = (x: string): void => reducer.transition("select_continent", { continent: x }); + return ( + <AnastasisClientFrame + hideNext + title={withProcessLabel(reducer, "Select Continent")} + > + {reducerState.continents.map((x: any) => ( + <button onClick={() => sel(x.name)} key={x.name}> + {x.name} + </button> + ))} + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx new file mode 100644 index 000000000..dbe4b7616 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { h, VNode } from "preact"; +import { CommonReducerProps, AnastasisClientFrame, withProcessLabel } from "./index"; + +export function CountrySelectionScreen(props: CommonReducerProps): VNode { + const { reducer, reducerState } = props; + const sel = (x: any): void => reducer.transition("select_country", { + country_code: x.code, + currencies: [x.currency], + }); + return ( + <AnastasisClientFrame + hideNext + title={withProcessLabel(reducer, "Select Country")} + > + {reducerState.countries.map((x: any) => ( + <button onClick={() => sel(x)} key={x.name}> + {x.name} ({x.currency}) + </button> + ))} + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx new file mode 100644 index 000000000..be74729eb --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx @@ -0,0 +1,27 @@ +import { h, VNode } from "preact"; +import { BackupReducerProps, AnastasisClientFrame } from "./index"; + +export function PoliciesPayingScreen(props: BackupReducerProps): VNode { + const payments = props.backupState.policy_payment_requests ?? []; + + return ( + <AnastasisClientFrame hideNext title="Backup: Recovery Document Payments"> + <p> + Some of the providers require a payment to store the encrypted + recovery document. + </p> + <ul> + {payments.map((x, i) => { + return ( + <li key={i}> + {x.provider}: {x.payto} + </li> + ); + })} + </ul> + <button onClick={() => props.reducer.transition("pay", {})}> + Check payment status now + </button> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx new file mode 100644 index 000000000..7ef9f345c --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx @@ -0,0 +1,17 @@ +import { + bytesToString, + decodeCrock +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { RecoveryReducerProps, AnastasisClientFrame } from "./index"; + +export function RecoveryFinishedScreen(props: RecoveryReducerProps): VNode { + return ( + <AnastasisClientFrame title="Recovery Finished" hideNext> + <h1>Recovery Finished</h1> + <p> + Secret: {bytesToString(decodeCrock(props.recoveryState.core_secret?.value!))} + </p> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx new file mode 100644 index 000000000..b898bb39e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { h, VNode } from "preact"; +import { BackupReducerProps, AnastasisClientFrame } from "./index"; + +export function ReviewPoliciesScreen(props: BackupReducerProps): VNode { + const { reducer, backupState } = props; + const authMethods = backupState.authentication_methods!; + return ( + <AnastasisClientFrame title="Backup: Review Recovery Policies"> + {backupState.policies?.map((p, i) => { + const policyName = p.methods + .map((x, i) => authMethods[x.authentication_method].type) + .join(" + "); + return ( + <div key={i}> + {/* <div key={i} class={style.policy}> */} + <h3> + Policy #{i + 1}: {policyName} + </h3> + Required Authentications: + <ul> + {p.methods.map((x, i) => { + const m = authMethods[x.authentication_method]; + return ( + <li key={i}> + {m.type} ({m.instructions}) at provider {x.provider} + </li> + ); + })} + </ul> + <div> + <button + onClick={() => reducer.transition("delete_policy", { policy_index: i })} + > + Delete Policy + </button> + </div> + </div> + ); + })} + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx new file mode 100644 index 000000000..2963930fd --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { BackupReducerProps, AnastasisClientFrame, LabeledInput } from "./index"; + +export function SecretEditorScreen(props: BackupReducerProps): VNode { + const { reducer } = props; + const [secretName, setSecretName] = useState( + props.backupState.secret_name ?? "" + ); + const [secretValue, setSecretValue] = useState( + props.backupState.core_secret?.value ?? "" ?? "" + ); + const secretNext = (): void => { + reducer.runTransaction(async (tx) => { + await tx.transition("enter_secret_name", { + name: secretName, + }); + await tx.transition("enter_secret", { + secret: { + value: encodeCrock(stringToBytes(secretValue)), + mime: "text/plain", + }, + expiration: { + t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5, + }, + }); + await tx.transition("next", {}); + }); + }; + return ( + <AnastasisClientFrame + title="Backup: Provide secret" + onNext={() => secretNext()} + > + <div> + <LabeledInput + label="Secret Name:" + grabFocus + bind={[secretName, setSecretName]} /> + </div> + <div> + <LabeledInput + label="Secret Value:" + bind={[secretValue, setSecretValue]} /> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx new file mode 100644 index 000000000..bbdcf8c2e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { RecoveryReducerProps, AnastasisClientFrame } from "./index"; + +export function SecretSelectionScreen(props: RecoveryReducerProps): VNode { + const { reducer, recoveryState } = props; + const [selectingVersion, setSelectingVersion] = useState<boolean>(false); + const [otherVersion, setOtherVersion] = useState<number>( + recoveryState.recovery_document?.version ?? 0 + ); + const recoveryDocument = recoveryState.recovery_document!; + const [otherProvider, setOtherProvider] = useState<string>(""); + function selectVersion(p: string, n: number): void { + reducer.runTransaction(async (tx) => { + await tx.transition("change_version", { + version: n, + provider_url: p, + }); + setSelectingVersion(false); + }); + } + if (selectingVersion) { + return ( + <AnastasisClientFrame hideNav title="Recovery: Select secret"> + <p>Select a different version of the secret</p> + <select onChange={(e) => setOtherProvider((e.target as any).value)}> + {Object.keys(recoveryState.authentication_providers ?? {}).map( + (x, i) => ( + <option key={i} selected={x === recoveryDocument.provider_url} value={x}> + {x} + </option> + ) + )} + </select> + <div> + <input + value={otherVersion} + onChange={(e) => setOtherVersion(Number((e.target as HTMLInputElement).value))} + type="number" /> + <button onClick={() => selectVersion(otherProvider, otherVersion)}> + Use this version + </button> + </div> + <div> + <button onClick={() => selectVersion(otherProvider, 0)}> + Use latest version + </button> + </div> + <div> + <button onClick={() => setSelectingVersion(false)}>Cancel</button> + </div> + </AnastasisClientFrame> + ); + } + return ( + <AnastasisClientFrame title="Recovery: Select secret"> + <p>Provider: {recoveryDocument.provider_url}</p> + <p>Secret version: {recoveryDocument.version}</p> + <p>Secret name: {recoveryDocument.version}</p> + <button onClick={() => setSelectingVersion(true)}> + Select different secret + </button> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx new file mode 100644 index 000000000..6296dc022 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx @@ -0,0 +1,22 @@ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AnastasisClientFrame, LabeledInput } from "./index"; +import { SolveEntryProps } from "./SolveScreen"; + +export function SolveEmailEntry(props: SolveEntryProps): VNode { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = (): void => reducer.transition("solve_challenge", { + answer, + }); + return ( + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <p>Feedback: {JSON.stringify(feedback)}</p> + <p>{challenge.instructions}</p> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx b/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx new file mode 100644 index 000000000..b11ceed27 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx @@ -0,0 +1,22 @@ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AnastasisClientFrame, LabeledInput } from "./index"; +import { SolveEntryProps } from "./SolveScreen"; + +export function SolvePostEntry(props: SolveEntryProps): VNode { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = (): void => reducer.transition("solve_challenge", { + answer, + }); + return ( + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <p>Feedback: {JSON.stringify(feedback)}</p> + <p>{challenge.instructions}</p> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx new file mode 100644 index 000000000..6393958b3 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx @@ -0,0 +1,22 @@ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AnastasisClientFrame, LabeledInput } from "./index"; +import { SolveEntryProps } from "./SolveScreen"; + +export function SolveQuestionEntry(props: SolveEntryProps): VNode { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = (): void => reducer.transition("solve_challenge", { + answer, + }); + return ( + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <p>Feedback: {JSON.stringify(feedback)}</p> + <p>Question: {challenge.instructions}</p> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx new file mode 100644 index 000000000..46ff8227d --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx @@ -0,0 +1,41 @@ +import { h, VNode } from "preact"; +import { AnastasisReducerApi, ChallengeFeedback, ChallengeInfo } from "../../hooks/use-anastasis-reducer"; +import { SolveEmailEntry } from "./SolveEmailEntry"; +import { SolvePostEntry } from "./SolvePostEntry"; +import { SolveQuestionEntry } from "./SolveQuestionEntry"; +import { SolveSmsEntry } from "./SolveSmsEntry"; +import { SolveUnsupportedEntry } from "./SolveUnsupportedEntry"; +import { RecoveryReducerProps } from "./index"; + +export function SolveScreen(props: RecoveryReducerProps): VNode { + const chArr = props.recoveryState.recovery_information!.challenges; + const challengeFeedback = props.recoveryState.challenge_feedback ?? {}; + const selectedUuid = props.recoveryState.selected_challenge_uuid!; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const dialogMap: Record<string, (p: SolveEntryProps) => h.JSX.Element> = { + question: SolveQuestionEntry, + sms: SolveSmsEntry, + email: SolveEmailEntry, + post: SolvePostEntry, + }; + const SolveDialog = dialogMap[selectedChallenge.type] ?? SolveUnsupportedEntry; + return ( + <SolveDialog + challenge={selectedChallenge} + reducer={props.reducer} + feedback={challengeFeedback[selectedUuid]} /> + ); +} + +export interface SolveEntryProps { + reducer: AnastasisReducerApi; + challenge: ChallengeInfo; + feedback?: ChallengeFeedback; +} + diff --git a/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx new file mode 100644 index 000000000..d0cd41332 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx @@ -0,0 +1,22 @@ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AnastasisClientFrame, LabeledInput } from "./index"; +import { SolveEntryProps } from "./SolveScreen"; + +export function SolveSmsEntry(props: SolveEntryProps): VNode { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = (): void => reducer.transition("solve_challenge", { + answer, + }); + return ( + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <p>Feedback: {JSON.stringify(feedback)}</p> + <p>{challenge.instructions}</p> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx new file mode 100644 index 000000000..7f538d249 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx @@ -0,0 +1,12 @@ +import { h, VNode } from "preact"; +import { AnastasisClientFrame } from "./index"; +import { SolveEntryProps } from "./SolveScreen"; + +export function SolveUnsupportedEntry(props: SolveEntryProps): VNode { + return ( + <AnastasisClientFrame hideNext title="Recovery: Solve challenge"> + <p>{JSON.stringify(props.challenge)}</p> + <p>Challenge not supported.</p> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.tsx new file mode 100644 index 000000000..38124887c --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/StartScreen.tsx @@ -0,0 +1,14 @@ +import { h, VNode } from "preact"; +import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer"; +import { AnastasisClientFrame } from "./index"; + +export function StartScreen(props: { reducer: AnastasisReducerApi; }): VNode { + return ( + <AnastasisClientFrame hideNav title="Home"> + <button autoFocus onClick={() => props.reducer.startBackup()}> + Backup + </button> + <button onClick={() => props.reducer.startRecover()}>Recover</button> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx new file mode 100644 index 000000000..5b8a835b8 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx @@ -0,0 +1,25 @@ +import { h, VNode } from "preact"; +import { BackupReducerProps, AnastasisClientFrame } from "./index"; + +export function TruthsPayingScreen(props: BackupReducerProps): VNode { + const payments = props.backupState.payments ?? []; + return ( + <AnastasisClientFrame + hideNext + title="Backup: Authentication Storage Payments" + > + <p> + Some of the providers require a payment to store the encrypted + authentication information. + </p> + <ul> + {payments.map((x, i) => { + return <li key={i}>{x}</li>; + })} + </ul> + <button onClick={() => props.reducer.transition("pay", {})}> + Check payment status now + </button> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx new file mode 100644 index 000000000..ab63553c1 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/index.tsx @@ -0,0 +1,248 @@ +import { + ComponentChildren, createContext, + Fragment, FunctionalComponent, h, VNode +} from "preact"; +import { useContext, useLayoutEffect, useRef } from "preact/hooks"; +import { Menu } from "../../components/menu"; +import { + BackupStates, RecoveryStates, + ReducerStateBackup, + ReducerStateRecovery, +} from "anastasis-core"; +import { + AnastasisReducerApi, + useAnastasisReducer +} from "../../hooks/use-anastasis-reducer"; +import { AttributeEntryScreen } from "./AttributeEntryScreen"; +import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen"; +import { BackupFinishedScreen } from "./BackupFinishedScreen"; +import { ChallengeOverviewScreen } from "./ChallengeOverviewScreen"; +import { ContinentSelectionScreen } from "./ContinentSelectionScreen"; +import { CountrySelectionScreen } from "./CountrySelectionScreen"; +import { PoliciesPayingScreen } from "./PoliciesPayingScreen"; +import { RecoveryFinishedScreen } from "./RecoveryFinishedScreen"; +import { ReviewPoliciesScreen } from "./ReviewPoliciesScreen"; +import { SecretEditorScreen } from "./SecretEditorScreen"; +import { SecretSelectionScreen } from "./SecretSelectionScreen"; +import { SolveScreen } from "./SolveScreen"; +import { StartScreen } from "./StartScreen"; +import { TruthsPayingScreen } from "./TruthsPayingScreen"; + +const WithReducer = createContext<AnastasisReducerApi | undefined>(undefined); + +function isBackup(reducer: AnastasisReducerApi): boolean { + return !!reducer.currentReducerState?.backup_state; +} + +export interface CommonReducerProps { + reducer: AnastasisReducerApi; + reducerState: ReducerStateBackup | ReducerStateRecovery; +} + +export function withProcessLabel(reducer: AnastasisReducerApi, text: string): string { + if (isBackup(reducer)) { + return `Backup: ${text}`; + } + return `Recovery: ${text}`; +} + +export interface BackupReducerProps { + reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; +} + +export interface RecoveryReducerProps { + reducer: AnastasisReducerApi; + recoveryState: ReducerStateRecovery; +} + +interface AnastasisClientFrameProps { + onNext?(): void; + title: string; + children: ComponentChildren; + /** + * Should back/next buttons be provided? + */ + hideNav?: boolean; + /** + * Hide only the "next" button. + */ + hideNext?: boolean; +} + +export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { + const reducer = useContext(WithReducer); + if (!reducer) { + return <p>Fatal: Reducer must be in context.</p>; + } + const next = (): void => { + if (props.onNext) { + props.onNext(); + } else { + reducer.transition("next", {}); + } + }; + const handleKeyPress = (e: h.JSX.TargetedKeyboardEvent<HTMLDivElement>): void => { + console.log("Got key press", e.key); + // FIXME: By default, "next" action should be executed here + }; + return (<Fragment> + <Menu title="Anastasis" /> + <section class="section"> + <div onKeyPress={(e) => handleKeyPress(e)}> {/* class={style.home} */} + <button onClick={() => reducer.reset()}>Reset session</button> + <h1>{props.title}</h1> + <ErrorBanner reducer={reducer} /> + {props.children} + {!props.hideNav ? ( + <div> + <button onClick={() => reducer.back()}>Back</button> + {!props.hideNext ? ( + <button onClick={next}>Next</button> + ) : null} + </div> + ) : null} + </div> + </section> + </Fragment> + ); +} + +const AnastasisClient: FunctionalComponent = () => { + const reducer = useAnastasisReducer(); + return ( + <WithReducer.Provider value={reducer}> + <AnastasisClientImpl /> + </WithReducer.Provider> + ); +}; + +const AnastasisClientImpl: FunctionalComponent = () => { + const reducer = useContext(WithReducer)!; + const reducerState = reducer.currentReducerState; + if (!reducerState) { + return <StartScreen reducer={reducer} />; + } + console.log("state", reducer.currentReducerState); + + if ( + reducerState.backup_state === BackupStates.ContinentSelecting || + reducerState.recovery_state === RecoveryStates.ContinentSelecting + ) { + return <ContinentSelectionScreen reducer={reducer} reducerState={reducerState} />; + } + if ( + reducerState.backup_state === BackupStates.CountrySelecting || + reducerState.recovery_state === RecoveryStates.CountrySelecting + ) { + return <CountrySelectionScreen reducer={reducer} reducerState={reducerState} />; + } + if ( + reducerState.backup_state === BackupStates.UserAttributesCollecting || + reducerState.recovery_state === RecoveryStates.UserAttributesCollecting + ) { + return <AttributeEntryScreen reducer={reducer} reducerState={reducerState} />; + } + if (reducerState.backup_state === BackupStates.AuthenticationsEditing) { + return ( + <AuthenticationEditorScreen backupState={reducerState} reducer={reducer} /> + ); + } + if (reducerState.backup_state === BackupStates.PoliciesReviewing) { + return <ReviewPoliciesScreen reducer={reducer} backupState={reducerState} />; + } + if (reducerState.backup_state === BackupStates.SecretEditing) { + return <SecretEditorScreen reducer={reducer} backupState={reducerState} />; + } + + if (reducerState.backup_state === BackupStates.BackupFinished) { + const backupState: ReducerStateBackup = reducerState; + return <BackupFinishedScreen reducer={reducer} backupState={backupState} />; + } + + if (reducerState.backup_state === BackupStates.TruthsPaying) { + return <TruthsPayingScreen reducer={reducer} backupState={reducerState} /> + + } + + if (reducerState.backup_state === BackupStates.PoliciesPaying) { + const backupState: ReducerStateBackup = reducerState; + return <PoliciesPayingScreen reducer={reducer} backupState={backupState} /> + } + + if (reducerState.recovery_state === RecoveryStates.SecretSelecting) { + return <SecretSelectionScreen reducer={reducer} recoveryState={reducerState} />; + } + + if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) { + return <ChallengeOverviewScreen reducer={reducer} recoveryState={reducerState} />; + } + + if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) { + return <SolveScreen reducer={reducer} recoveryState={reducerState} /> + } + + if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { + return <RecoveryFinishedScreen reducer={reducer} recoveryState={reducerState} /> + } + + console.log("unknown state", reducer.currentReducerState); + return ( + <AnastasisClientFrame hideNav title="Bug"> + <p>Bug: Unknown state.</p> + <button onClick={() => reducer.reset()}>Reset</button> + </AnastasisClientFrame> + ); +}; + + +interface LabeledInputProps { + label: string; + grabFocus?: boolean; + bind: [string, (x: string) => void]; +} + +export function LabeledInput(props: LabeledInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + return ( + <label> + {props.label} + <input + value={props.bind[0]} + onChange={(e) => props.bind[1]((e.target as HTMLInputElement).value)} + ref={inputRef} + style={{ display: "block" }} + /> + </label> + ); +} + + +interface ErrorBannerProps { + reducer: AnastasisReducerApi; +} + +/** + * Show a dismissable error banner if there is a current error. + */ +function ErrorBanner(props: ErrorBannerProps): VNode | null { + const currentError = props.reducer.currentError; + if (currentError) { + return ( + <div id="error"> {/* style.error */} + <p>Error: {JSON.stringify(currentError)}</p> + <button onClick={() => props.reducer.dismissError()}> + Dismiss Error + </button> + </div> + ); + } + return null; +} + +export default AnastasisClient; diff --git a/packages/anastasis-webui/src/pages/home/style.css b/packages/anastasis-webui/src/pages/home/style.css new file mode 100644 index 000000000..e70f11a59 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/style.css @@ -0,0 +1,25 @@ +.home { + padding: 1em 1em; + min-height: 100%; + width: 100%; + max-width: 40em; +} + +.home div { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.policy { + padding: 0.5em; + border: 1px solid black; + border-radius: 0.5em; + border-radius: 0.5em; +} + +.home > #error { + padding: 0.5em; + border: 1px solid black; + background-color: rgb(228, 189, 197); + border-radius: 0.5em; +} |