aboutsummaryrefslogtreecommitdiff
path: root/packages/anastasis-webui/src/pages/home
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2021-10-19 10:56:52 -0300
committerSebastian <sebasjm@gmail.com>2021-10-19 11:05:32 -0300
commit5883d42d800c7b444c59d626bcaa5abca7dc83d0 (patch)
treeac42ad7b9e26c4dd2145a31101305884906a543e /packages/anastasis-webui/src/pages/home
parent269022a526b670d602ca146f4df02850983bb72e (diff)
downloadwallet-core-5883d42d800c7b444c59d626bcaa5abca7dc83d0.tar.xz
add template from merchant backoffice
Diffstat (limited to 'packages/anastasis-webui/src/pages/home')
-rw-r--r--packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx55
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx41
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx68
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx45
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx50
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx116
-rw-r--r--packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx23
-rw-r--r--packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx63
-rw-r--r--packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx19
-rw-r--r--packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx23
-rw-r--r--packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx27
-rw-r--r--packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx17
-rw-r--r--packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx43
-rw-r--r--packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx53
-rw-r--r--packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx66
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx22
-rw-r--r--packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx22
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx22
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveScreen.tsx41
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx22
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx12
-rw-r--r--packages/anastasis-webui/src/pages/home/StartScreen.tsx14
-rw-r--r--packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx25
-rw-r--r--packages/anastasis-webui/src/pages/home/index.tsx248
-rw-r--r--packages/anastasis-webui/src/pages/home/style.css25
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;
+}