aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-10-13 10:48:25 +0200
committerFlorian Dold <florian@dold.me>2021-10-13 10:49:20 +0200
commit0f1ef7eca1f1ab3c5a1787b19a6caec13fb30dec (patch)
treeae634d8a89388169d5093807fb9bdebf704ff67e
parentb8d03b6b2aef630c0fafd7f6ab0fe317abfe1d93 (diff)
anastasis-webui: finish backup flow
-rw-r--r--packages/anastasis-webui/package.json1
-rw-r--r--packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts185
-rw-r--r--packages/anastasis-webui/src/routes/home/index.tsx518
-rw-r--r--packages/anastasis-webui/src/routes/home/style.css25
-rw-r--r--pnpm-lock.yaml51
5 files changed, 692 insertions, 88 deletions
diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json
index ddbd9ef20..fe332be03 100644
--- a/packages/anastasis-webui/package.json
+++ b/packages/anastasis-webui/package.json
@@ -21,6 +21,7 @@
]
},
"dependencies": {
+ "@gnu-taler/taler-util": "workspace:^0.8.3",
"preact": "^10.3.1",
"preact-render-to-string": "^5.1.4",
"preact-router": "^3.2.1"
diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
index 30bab96d1..d578d1418 100644
--- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
+++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
@@ -1,6 +1,58 @@
import { useState } from "preact/hooks";
-type ReducerState = any;
+export type ReducerState =
+ | ReducerStateBackup
+ | ReducerStateRecovery
+ | ReducerStateError;
+
+export interface ReducerStateBackup {
+ recovery_state: undefined;
+ backup_state: BackupStates;
+ code: undefined;
+ continents: any;
+ countries: any;
+ authentication_providers: any;
+ authentication_methods?: AuthMethod[];
+ required_attributes: any;
+ secret_name?: string;
+ policies?: {
+ methods: {
+ authentication_method: number;
+ provider: string;
+ }[];
+ }[];
+ success_details: {
+ [provider_url: string]: {
+ policy_version: number;
+ };
+ };
+ payments?: string[];
+ policy_payment_requests?: {
+ payto: string;
+ provider: string;
+ }[];
+}
+
+export interface AuthMethod {
+ type: string;
+ instructions: string;
+ challenge: string;
+}
+
+export interface ReducerStateRecovery {
+ backup_state: undefined;
+ recovery_state: RecoveryStates;
+ code: undefined;
+
+ continents: any;
+ countries: any;
+}
+
+export interface ReducerStateError {
+ backup_state: undefined;
+ recovery_state: undefined;
+ code: number;
+}
interface AnastasisState {
reducerState: ReducerState | undefined;
@@ -10,6 +62,13 @@ interface AnastasisState {
export enum BackupStates {
ContinentSelecting = "CONTINENT_SELECTING",
CountrySelecting = "COUNTRY_SELECTING",
+ UserAttributesCollecting = "USER_ATTRIBUTES_COLLECTING",
+ AuthenticationsEditing = "AUTHENTICATIONS_EDITING",
+ PoliciesReviewing = "POLICIES_REVIEWING",
+ SecretEditing = "SECRET_EDITING",
+ TruthsPaying = "TRUTHS_PAYING",
+ PoliciesPaying = "POLICIES_PAYING",
+ BackupFinished = "BACKUP_FINISHED",
}
export enum RecoveryStates {
@@ -49,20 +108,62 @@ async function reduceState(
return resp.json();
}
+export interface ReducerTransactionHandle {
+ transactionState: ReducerState;
+ transition(action: string, args: any): Promise<ReducerState>;
+}
+
export interface AnastasisReducerApi {
- currentReducerState: ReducerState;
+ currentReducerState: ReducerState | undefined;
currentError: any;
+ dismissError: () => void;
startBackup: () => void;
startRecover: () => void;
+ reset: () => void;
back: () => void;
transition(action: string, args: any): void;
+ /**
+ * Run multiple reducer steps in a transaction without
+ * affecting the UI-visible transition state in-between.
+ */
+ runTransaction(f: (h: ReducerTransactionHandle) => Promise<void>): void;
+}
+
+function restoreState(): any {
+ let state: any;
+ try {
+ let s = localStorage.getItem("anastasisReducerState");
+ if (s === "undefined") {
+ state = undefined;
+ } else if (s) {
+ console.log("restoring state from", s);
+ state = JSON.parse(s);
+ }
+ } catch (e) {
+ console.log(e);
+ }
+ return state ?? undefined;
}
export function useAnastasisReducer(): AnastasisReducerApi {
- const [anastasisState, setAnastasisState] = useState<AnastasisState>({
- reducerState: undefined,
- currentError: undefined,
- });
+ const [anastasisState, setAnastasisStateInternal] = useState<AnastasisState>(
+ () => ({
+ reducerState: restoreState(),
+ currentError: undefined,
+ }),
+ );
+
+ const setAnastasisState = (newState: AnastasisState) => {
+ try {
+ localStorage.setItem(
+ "anastasisReducerState",
+ JSON.stringify(newState.reducerState),
+ );
+ } catch (e) {
+ console.log(e);
+ }
+ setAnastasisStateInternal(newState);
+ };
async function doTransition(action: string, args: any) {
console.log("reducing with", action, args);
@@ -102,30 +203,74 @@ export function useAnastasisReducer(): AnastasisReducerApi {
doTransition(action, args);
},
back() {
+ const reducerState = anastasisState.reducerState;
+ if (!reducerState) {
+ return;
+ }
if (
- anastasisState.reducerState.backup_state ===
- BackupStates.ContinentSelecting ||
- anastasisState.reducerState.recovery_state ===
- RecoveryStates.ContinentSelecting
+ reducerState.backup_state === BackupStates.ContinentSelecting ||
+ reducerState.recovery_state === RecoveryStates.ContinentSelecting
) {
setAnastasisState({
...anastasisState,
currentError: undefined,
reducerState: undefined,
});
- } else if (
- anastasisState.reducerState.backup_state ===
- BackupStates.CountrySelecting
- ) {
- doTransition("unselect_continent", {});
- } else if (
- anastasisState.reducerState.recovery_state ===
- RecoveryStates.CountrySelecting
- ) {
- doTransition("unselect_continent", {});
} else {
doTransition("back", {});
}
},
+ dismissError() {
+ setAnastasisState({ ...anastasisState, currentError: undefined });
+ },
+ reset() {
+ setAnastasisState({
+ ...anastasisState,
+ currentError: undefined,
+ reducerState: undefined,
+ });
+ },
+ runTransaction(f) {
+ async function run() {
+ const txHandle = new ReducerTxImpl(anastasisState.reducerState!);
+ try {
+ await f(txHandle);
+ } catch (e) {
+ console.log("exception during reducer transaction", e);
+ }
+ const s = txHandle.transactionState;
+ console.log("transaction finished, new state", s);
+ if (s.code !== undefined) {
+ setAnastasisState({
+ ...anastasisState,
+ currentError: txHandle.transactionState,
+ });
+ } else {
+ setAnastasisState({
+ ...anastasisState,
+ reducerState: txHandle.transactionState,
+ currentError: undefined,
+ });
+ }
+ }
+ run();
+ },
};
}
+
+class ReducerTxImpl implements ReducerTransactionHandle {
+ constructor(public transactionState: ReducerState) {}
+ async transition(action: string, args: any): Promise<ReducerState> {
+ console.log("making transition in transaction", action);
+ this.transactionState = await reduceState(
+ this.transactionState,
+ action,
+ args,
+ );
+ // Abort transaction as soon as we transition into an error state.
+ if (this.transactionState.code !== undefined) {
+ throw Error("transition resulted in error");
+ }
+ return this.transactionState;
+ }
+}
diff --git a/packages/anastasis-webui/src/routes/home/index.tsx b/packages/anastasis-webui/src/routes/home/index.tsx
index ee3399503..f61897682 100644
--- a/packages/anastasis-webui/src/routes/home/index.tsx
+++ b/packages/anastasis-webui/src/routes/home/index.tsx
@@ -1,80 +1,290 @@
+import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util";
import { FunctionalComponent, h } from "preact";
import { useState } from "preact/hooks";
import {
AnastasisReducerApi,
+ AuthMethod,
+ BackupStates,
+ ReducerStateBackup,
+ ReducerStateRecovery,
useAnastasisReducer,
} from "../../hooks/use-anastasis-reducer";
import style from "./style.css";
+interface ContinentSelectionProps {
+ reducer: AnastasisReducerApi;
+ reducerState: ReducerStateBackup | ReducerStateRecovery;
+}
+
+function isBackup(reducer: AnastasisReducerApi) {
+ return !!reducer.currentReducerState?.backup_state;
+}
+
+function ContinentSelection(props: ContinentSelectionProps) {
+ const { reducer, reducerState } = props;
+ return (
+ <div class={style.home}>
+ <h1>{isBackup(reducer) ? "Backup" : "Recovery"}: Select Continent</h1>
+ <ErrorBanner reducer={reducer} />
+ <div>
+ {reducerState.continents.map((x: any) => {
+ const sel = (x: string) =>
+ reducer.transition("select_continent", { continent: x });
+ return (
+ <button onClick={() => sel(x.name)} key={x.name}>
+ {x.name}
+ </button>
+ );
+ })}
+ </div>
+ <div>
+ <button onClick={() => reducer.back()}>Back</button>
+ </div>
+ </div>
+ );
+}
+
+interface CountrySelectionProps {
+ reducer: AnastasisReducerApi;
+ reducerState: ReducerStateBackup | ReducerStateRecovery;
+}
+
+function CountrySelection(props: CountrySelectionProps) {
+ const { reducer, reducerState } = props;
+ return (
+ <div class={style.home}>
+ <h1>Backup: Select Country</h1>
+ <ErrorBanner reducer={reducer} />
+ <div>
+ {reducerState.countries.map((x: any) => {
+ const sel = (x: any) =>
+ reducer.transition("select_country", {
+ country_code: x.code,
+ currencies: [x.currency],
+ });
+ return (
+ <button onClick={() => sel(x)} key={x.name}>
+ {x.name} ({x.currency})
+ </button>
+ );
+ })}
+ </div>
+ <div>
+ <button onClick={() => reducer.back()}>Back</button>
+ </div>
+ </div>
+ );
+}
+
const Home: FunctionalComponent = () => {
const reducer = useAnastasisReducer();
- if (!reducer.currentReducerState) {
+ const reducerState = reducer.currentReducerState;
+ if (!reducerState) {
return (
<div class={style.home}>
<h1>Home</h1>
<p>
- <button onClick={() => reducer.startBackup()}>Backup</button>
- <button>Recover</button>
+ <button autoFocus onClick={() => reducer.startBackup()}>
+ Backup
+ </button>
+ <button onClick={() => reducer.startRecover()}>Recover</button>
</p>
</div>
);
}
console.log("state", reducer.currentReducerState);
- if (reducer.currentReducerState.backup_state === "CONTINENT_SELECTING") {
+
+ if (reducerState.backup_state === BackupStates.ContinentSelecting) {
+ return <ContinentSelection reducer={reducer} reducerState={reducerState} />;
+ }
+ if (reducerState.backup_state === BackupStates.CountrySelecting) {
+ return <CountrySelection reducer={reducer} reducerState={reducerState} />;
+ }
+ if (reducerState.backup_state === BackupStates.UserAttributesCollecting) {
+ return <AttributeEntry reducer={reducer} backupState={reducerState} />;
+ }
+ if (reducerState.backup_state === BackupStates.AuthenticationsEditing) {
+ return (
+ <AuthenticationEditor backupState={reducerState} reducer={reducer} />
+ );
+ }
+
+ if (reducerState.backup_state === BackupStates.PoliciesReviewing) {
+ const backupState: ReducerStateBackup = reducerState;
+ const authMethods = backupState.authentication_methods!;
return (
<div class={style.home}>
- <h1>Backup: Select Continent</h1>
+ <h1>Backup: Review Recovery Policies</h1>
<ErrorBanner reducer={reducer} />
<div>
- {reducer.currentReducerState.continents.map((x: any) => {
- const sel = (x: string) =>
- reducer.transition("select_continent", { continent: x });
+ {backupState.policies?.map((p, i) => {
+ const policyName = p.methods
+ .map((x) => authMethods[x.authentication_method].type)
+ .join(" + ");
return (
- <button onClick={() => sel(x.name)} key={x.name}>
- {x.name}
- </button>
+ <div class={style.policy}>
+ <h3>
+ Policy #{i + 1}: {policyName}
+ </h3>
+ Required Authentications:
+ <ul>
+ {p.methods.map((x) => {
+ const m = authMethods[x.authentication_method];
+ return (
+ <li>
+ {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>
);
})}
</div>
<div>
<button onClick={() => reducer.back()}>Back</button>
+ <button onClick={() => reducer.transition("next", {})}>Next</button>
</div>
</div>
);
}
- if (reducer.currentReducerState.backup_state === "COUNTRY_SELECTING") {
+
+ if (reducerState.backup_state === BackupStates.SecretEditing) {
+ const [secretName, setSecretName] = useState("");
+ const [secretValue, setSecretValue] = useState("");
+ const secretNext = () => {
+ reducer.runTransaction(async (tx) => {
+ await tx.transition("enter_secret_name", {
+ name: secretName,
+ });
+ await tx.transition("enter_secret", {
+ secret: {
+ value: "EDJP6WK5EG50",
+ mime: "text/plain",
+ },
+ expiration: {
+ t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5,
+ },
+ });
+ await tx.transition("next", {});
+ });
+ };
return (
<div class={style.home}>
- <h1>Backup: Select Continent</h1>
+ <h1>Backup: Provide secret</h1>
<ErrorBanner reducer={reducer} />
<div>
- {reducer.currentReducerState.countries.map((x: any) => {
- const sel = (x: any) =>
- reducer.transition("select_country", {
- country_code: x.code,
- currencies: [x.currency],
- });
+ <label>
+ Secret name: <input type="text" />
+ </label>
+ </div>
+ <div>
+ <label>
+ Secret value: <input type="text" />
+ </label>
+ </div>
+ or:
+ <div>
+ <label>
+ File Upload: <input type="file" />
+ </label>
+ </div>
+ <div>
+ <button onClick={() => reducer.back()}>Back</button>
+ <button onClick={() => secretNext()}>Next</button>
+ </div>
+ </div>
+ );
+ }
+
+ if (reducerState.backup_state === BackupStates.BackupFinished) {
+ const backupState: ReducerStateBackup = reducerState;
+ return (
+ <div class={style.home}>
+ <h1>Backup finished</h1>
+ <p>
+ Your backup of secret "{backupState.secret_name ?? "??"}" was
+ successful.
+ </p>
+ <p>The backup is stored by the following providers:</p>
+ <ul>
+ {Object.keys(backupState.success_details).map((x, i) => {
+ const sd = backupState.success_details[x];
return (
- <button onClick={() => sel(x)} key={x.name}>
- {x.name} ({x.currency})
- </button>
+ <li>
+ {x} (Policy version {sd.policy_version})
+ </li>
);
})}
- </div>
+ </ul>
+ <button onClick={() => reducer.reset()}>
+ Start a new backup/recovery
+ </button>
+ </div>
+ );
+ }
+
+ if (reducerState.backup_state === BackupStates.TruthsPaying) {
+ const backupState: ReducerStateBackup = reducerState;
+ const payments = backupState.payments ?? [];
+ return (
+ <div class={style.home}>
+ <h1>Backup: Authentication Storage Payments</h1>
+ <p>
+ Some of the providers require a payment to store the encrypted
+ authentication information.
+ </p>
+ <ul>
+ {payments.map((x) => {
+ return <li>{x}</li>;
+ })}
+ </ul>
<div>
<button onClick={() => reducer.back()}>Back</button>
+ <button onClick={() => reducer.transition("pay", {})}>
+ Check payment(s)
+ </button>
</div>
</div>
);
}
- if (
- reducer.currentReducerState.backup_state === "USER_ATTRIBUTES_COLLECTING"
- ) {
- return <AttributeEntry reducer={reducer} />;
- }
- if (reducer.currentReducerState.backup_state === "AUTHENTICATIONS_EDITING") {
- return <AuthenticationEditor reducer={reducer} />;
+ if (reducerState.backup_state === BackupStates.PoliciesPaying) {
+ const backupState: ReducerStateBackup = reducerState;
+ const payments = backupState.policy_payment_requests ?? [];
+ return (
+ <div class={style.home}>
+ <h1>Backup: Recovery Document Payments</h1>
+ <p>
+ Some of the providers require a payment to store the encrypted
+ recovery document.
+ </p>
+ <ul>
+ {payments.map((x) => {
+ return (
+ <li>
+ {x.provider}: {x.payto}
+ </li>
+ );
+ })}
+ </ul>
+ <div>
+ <button onClick={() => reducer.back()}>Back</button>
+ <button onClick={() => reducer.transition("pay", {})}>
+ Check payment(s)
+ </button>
+ </div>
+ </div>
+ );
}
console.log("unknown state", reducer.currentReducerState);
@@ -82,31 +292,232 @@ const Home: FunctionalComponent = () => {
<div class={style.home}>
<h1>Home</h1>
<p>Bug: Unknown state.</p>
+ <button onClick={() => reducer.reset()}>Reset</button>
</div>
);
};
+interface AuthMethodSetupProps {
+ method: string;
+ addAuthMethod: (x: any) => void;
+ cancel: () => void;
+}
+
+function AuthMethodSmsSetup(props: AuthMethodSetupProps) {
+ const [mobileNumber, setMobileNumber] = useState("");
+ return (
+ <div class={style.home}>
+ <h1>Add {props.method} authentication</h1>
+ <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}
+ autoFocus
+ onChange={(e) => setMobileNumber((e.target as any).value)}
+ type="text"
+ />
+ </label>
+ <div>
+ <button onClick={() => props.cancel()}>Cancel</button>
+ <button
+ onClick={() =>
+ props.addAuthMethod({
+ authentication_method: {
+ type: "sms",
+ instructions: `SMS to ${mobileNumber}`,
+ challenge: "E1QPPS8A",
+ },
+ })
+ }
+ >
+ Add
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function AuthMethodQuestionSetup(props: AuthMethodSetupProps) {
+ const [questionText, setQuestionText] = useState("");
+ const [answerText, setAnswerText] = useState("");
+ return (
+ <div class={style.home}>
+ <h1>Add {props.method} authentication</h1>
+ <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>
+ <label>
+ Security question
+ <input
+ value={questionText}
+ autoFocus
+ onChange={(e) => setQuestionText((e.target as any).value)}
+ type="text"
+ />
+ </label>
+ </div>
+ <div>
+ <label>
+ Answer
+ <input
+ value={answerText}
+ autoFocus
+ onChange={(e) => setAnswerText((e.target as any).value)}
+ type="text"
+ />
+ </label>
+ </div>
+ <div>
+ <button onClick={() => props.cancel()}>Cancel</button>
+ <button
+ onClick={() =>
+ props.addAuthMethod({
+ authentication_method: {
+ type: "question",
+ instructions: questionText,
+ challenge: encodeCrock(stringToBytes(answerText)),
+ },
+ })
+ }
+ >
+ Add
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function AuthMethodNotImplemented(props: AuthMethodSetupProps) {
+ return (
+ <div class={style.home}>
+ <h1>Add {props.method} authentication</h1>
+ <div>
+ <p>
+ This auth method is not implemented yet, please choose another one.
+ </p>
+ <button onClick={() => props.cancel()}>Cancel</button>
+ </div>
+ </div>
+ );
+}
+
export interface AuthenticationEditorProps {
reducer: AnastasisReducerApi;
+ backupState: ReducerStateBackup;
}
function AuthenticationEditor(props: AuthenticationEditorProps) {
- const { reducer } = props;
- const providers = reducer.currentReducerState.authentication_providers;
- const authAvailable = new Set<string>();
+ 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];
for (const meth of p.methods) {
- authAvailable.add(meth.type);
+ authAvailableSet.add(meth.type);
+ }
+ }
+ if (selectedMethod) {
+ const cancel = () => setSelectedMethod(undefined);
+ const addMethod = (args: any) => {
+ reducer.transition("add_authentication", args);
+ setSelectedMethod(undefined);
+ };
+ switch (selectedMethod) {
+ case "sms":
+ return (
+ <AuthMethodSmsSetup
+ cancel={cancel}
+ addAuthMethod={addMethod}
+ method="sms"
+ />
+ );
+ case "question":
+ return (
+ <AuthMethodQuestionSetup
+ cancel={cancel}
+ addAuthMethod={addMethod}
+ method="sms"
+ />
+ );
+ default:
+ return (
+ <AuthMethodNotImplemented
+ cancel={cancel}
+ addAuthMethod={addMethod}
+ method={selectedMethod}
+ />
+ );
}
}
+ function MethodButton(props: { method: string; label: String }) {
+ 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 (
<div class={style.home}>
<h1>Backup: Configure Authentication Methods</h1>
- <p>Auths available: {JSON.stringify(Array.from(authAvailable))}</p>
- <button>Next</button>
+ <ErrorBanner reducer={reducer} />
+ <h2>Add authentication method</h2>
+ <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>
+ {x.type} ({x.instructions}){" "}
+ <button
+ onClick={() =>
+ reducer.transition("delete_authentication", {
+ authentication_method: i,
+ })
+ }
+ >
+ Delete
+ </button>
+ </p>
+ );
+ })
+ ) : (
+ <p>No authentication methods configured yet.</p>
+ )}
<div>
<button onClick={() => reducer.back()}>Back</button>
+ <button onClick={() => reducer.transition("next", {})}>Next</button>
</div>
</div>
);
@@ -114,19 +525,21 @@ function AuthenticationEditor(props: AuthenticationEditorProps) {
export interface AttributeEntryProps {
reducer: AnastasisReducerApi;
+ backupState: ReducerStateBackup;
}
function AttributeEntry(props: AttributeEntryProps) {
- const reducer = props.reducer;
+ const { reducer, backupState } = props;
const [attrs, setAttrs] = useState<Record<string, string>>({});
return (
<div class={style.home}>
<h1>Backup: Enter Basic User Attributes</h1>
<ErrorBanner reducer={reducer} />
<div>
- {reducer.currentReducerState.required_attributes.map((x: any) => {
+ {backupState.required_attributes.map((x: any, i: number) => {
return (
<AttributeEntryField
+ isFirst={i == 0}
setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })}
spec={x}
value={attrs[x.name]}
@@ -134,23 +547,24 @@ function AttributeEntry(props: AttributeEntryProps) {
);
})}
</div>
- <button
- onClick={() =>
- reducer.transition("enter_user_attributes", {
- identity_attributes: attrs,
- })
- }
- >
- Next
- </button>
<div>
<button onClick={() => reducer.back()}>Back</button>
+ <button
+ onClick={() =>
+ reducer.transition("enter_user_attributes", {
+ identity_attributes: attrs,
+ })
+ }
+ >
+ Next
+ </button>
</div>
</div>
);
}
export interface AttributeEntryFieldProps {
+ isFirst: boolean;
value: string;
setValue: (newValue: string) => void;
spec: any;
@@ -161,6 +575,7 @@ function AttributeEntryField(props: AttributeEntryFieldProps) {
<div>
<label>{props.spec.label}</label>
<input
+ autoFocus={props.isFirst}
type="text"
value={props.value}
onChange={(e) => props.setValue((e as any).target.value)}
@@ -179,7 +594,14 @@ interface ErrorBannerProps {
function ErrorBanner(props: ErrorBannerProps) {
const currentError = props.reducer.currentError;
if (currentError) {
- return <div>Error: {JSON.stringify(currentError)}</div>;
+ return (
+ <div id={style.error}>
+ <p>Error: {JSON.stringify(currentError)}</p>
+ <button onClick={() => props.reducer.dismissError()}>
+ Dismiss Error
+ </button>
+ </div>
+ );
}
return null;
}
diff --git a/packages/anastasis-webui/src/routes/home/style.css b/packages/anastasis-webui/src/routes/home/style.css
index f052d2546..c9f34e6c8 100644
--- a/packages/anastasis-webui/src/routes/home/style.css
+++ b/packages/anastasis-webui/src/routes/home/style.css
@@ -1,5 +1,24 @@
.home {
- padding: 56px 20px;
- min-height: 100%;
- width: 100%;
+ padding: 56px 20px;
+ min-height: 100%;
+ width: 100%;
+}
+
+.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;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b8f1fd547..fbd3c7e98 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,6 +27,7 @@ importers:
packages/anastasis-webui:
specifiers:
+ '@gnu-taler/taler-util': workspace:^0.8.3
'@types/enzyme': ^3.10.5
'@types/jest': ^26.0.8
'@typescript-eslint/eslint-plugin': ^2.25.0
@@ -44,6 +45,7 @@ importers:
sirv-cli: ^1.0.0-next.3
typescript: ^3.7.5
dependencies:
+ '@gnu-taler/taler-util': link:../taler-util
preact: 10.5.14
preact-render-to-string: 5.1.19_preact@10.5.14
preact-router: 3.2.1_preact@10.5.14
@@ -4599,7 +4601,7 @@ packages:
dependencies:
'@types/estree': 0.0.39
estree-walker: 1.0.1
- picomatch: 2.2.2
+ picomatch: 2.3.0
rollup: 2.56.2
dev: true
@@ -7681,7 +7683,7 @@ packages:
/axios/0.21.1:
resolution: {integrity: sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==}
dependencies:
- follow-redirects: 1.14.2
+ follow-redirects: 1.14.2_debug@4.3.2
transitivePeerDependencies:
- debug
@@ -10771,18 +10773,18 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7
dependencies:
- array-includes: 3.1.2
+ array-includes: 3.1.3
array.prototype.flatmap: 1.2.4
doctrine: 2.1.0
eslint: 6.8.0
has: 1.0.3
jsx-ast-utils: 3.2.0
- object.entries: 1.1.3
- object.fromentries: 2.0.3
- object.values: 1.1.2
+ object.entries: 1.1.4
+ object.fromentries: 2.0.4
+ object.values: 1.1.4
prop-types: 15.7.2
- resolve: 1.19.0
- string.prototype.matchall: 4.0.3
+ resolve: 1.20.0
+ string.prototype.matchall: 4.0.5
dev: true
/eslint-plugin-react/7.22.0_eslint@7.18.0:
@@ -11444,7 +11446,7 @@ packages:
readable-stream: 2.3.7
dev: true
- /follow-redirects/1.14.2:
+ /follow-redirects/1.14.2_debug@4.3.2:
resolution: {integrity: sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==}
engines: {node: '>=4.0'}
peerDependencies:
@@ -11452,6 +11454,8 @@ packages:
peerDependenciesMeta:
debug:
optional: true
+ dependencies:
+ debug: 4.3.2_supports-color@6.1.0
/for-each/0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -12485,7 +12489,7 @@ packages:
engines: {node: '>=8.0.0'}
dependencies:
eventemitter3: 4.0.7
- follow-redirects: 1.14.2
+ follow-redirects: 1.14.2_debug@4.3.2
requires-port: 1.0.0
transitivePeerDependencies:
- debug
@@ -14131,7 +14135,7 @@ packages:
resolution: {integrity: sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==}
engines: {node: '>=4.0'}
dependencies:
- array-includes: 3.1.2
+ array-includes: 3.1.3
object.assign: 4.1.2
dev: true
@@ -15962,11 +15966,11 @@ packages:
- typescript
dev: true
- /pnp-webpack-plugin/1.7.0_typescript@4.3.5:
+ /pnp-webpack-plugin/1.7.0_typescript@4.4.3:
resolution: {integrity: sha512-2Rb3vm+EXble/sMXNSu6eoBx8e79gKqhNq9F5ZWW6ERNCTE/Q0wQNne5541tE5vKjfM8hpNCYL+LGc1YTfI0dg==}
engines: {node: '>=6'}
dependencies:
- ts-pnp: 1.2.0_typescript@4.3.5
+ ts-pnp: 1.2.0_typescript@4.4.3
transitivePeerDependencies:
- typescript
dev: true
@@ -16770,7 +16774,7 @@ packages:
native-url: 0.3.4
optimize-css-assets-webpack-plugin: 6.0.1_webpack@4.46.0
ora: 5.4.1
- pnp-webpack-plugin: 1.7.0_typescript@4.3.5
+ pnp-webpack-plugin: 1.7.0_typescript@4.4.3
postcss: 8.3.6
postcss-load-config: 3.1.0
postcss-loader: 4.3.0_postcss@8.3.6+webpack@4.46.0
@@ -16788,7 +16792,7 @@ packages:
stack-trace: 0.0.10
style-loader: 2.0.0_webpack@4.46.0
terser-webpack-plugin: 4.2.3_webpack@4.46.0
- typescript: 4.3.5
+ typescript: 4.4.3
update-notifier: 5.1.0
url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
validate-npm-package-name: 3.0.0
@@ -18039,11 +18043,11 @@ packages:
peerDependencies:
rollup: ^2.0.0
dependencies:
- '@babel/code-frame': 7.12.13
+ '@babel/code-frame': 7.14.5
jest-worker: 26.6.2
rollup: 2.56.2
serialize-javascript: 4.0.0
- terser: 5.4.0
+ terser: 5.7.1
dev: true
/rollup/2.37.1:
@@ -19167,6 +19171,7 @@ packages:
/svgo/1.3.2:
resolution: {integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==}
engines: {node: '>=4.0.0'}
+ deprecated: This SVGO version is no longer supported. Upgrade to v2.x.x.
hasBin: true
dependencies:
chalk: 2.4.2
@@ -19588,6 +19593,18 @@ packages:
typescript: 4.3.5
dev: true
+ /ts-pnp/1.2.0_typescript@4.4.3:
+ resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==}
+ engines: {node: '>=6'}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ typescript: 4.4.3
+ dev: true
+
/tsconfig-paths/3.9.0:
resolution: {integrity: sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==}
dependencies: