summaryrefslogtreecommitdiff
path: root/packages/anastasis-webui/src/routes/home/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/anastasis-webui/src/routes/home/index.tsx')
-rw-r--r--packages/anastasis-webui/src/routes/home/index.tsx518
1 files changed, 470 insertions, 48 deletions
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;
}