diff options
Diffstat (limited to 'packages/anastasis-webui/src/pages')
81 files changed, 7638 insertions, 1460 deletions
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts new file mode 100644 index 000000000..ed8301d65 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts @@ -0,0 +1,131 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { AuthenticationProviderStatus } from "@gnu-taler/anastasis-core"; +import InvalidState from "../../../components/InvalidState.js"; +import NoReducer from "../../../components/NoReducer.js"; +import { Notification } from "../../../components/Notifications.js"; +import { compose, StateViewMap } from "../../../utils/index.js"; +import useComponentState from "./state.js"; +import { WithoutProviderType, WithProviderType } from "./views.js"; + +export type AuthProvByStatusMap = Record< + AuthenticationProviderStatus["status"], + (AuthenticationProviderStatus & { url: string })[] +>; + +export type State = NoReducer | InvalidState | WithType | WithoutType; + +export interface NoReducer { + status: "no-reducer"; +} +export interface InvalidState { + status: "invalid-state"; +} + +interface CommonProps { + addProvider?: () => Promise<void>; + deleteProvider: (url: string) => Promise<void>; + authProvidersByStatus: AuthProvByStatusMap; + error: string | undefined; + onCancel: () => Promise<void>; + testing: boolean; + setProviderURL: (url: string) => Promise<void>; + providerURL: string; + errors: string | undefined; + notifications: Notification[]; +} + +export interface WithType extends CommonProps { + status: "with-type"; + providerLabel: string; +} +export interface WithoutType extends CommonProps { + status: "without-type"; +} + +const map: StateViewMap<State> = { + "no-reducer": NoReducer, + "invalid-state": InvalidState, + "with-type": WithProviderType, + "without-type": WithoutProviderType, +}; + +export default compose("AddingProviderScreen", useComponentState, map); + +const providerResponseCache = new Map<string, any>(); // `any` is the return type of res.json() +export async function testProvider( + url: string, + expectedMethodType?: string, +): Promise<void> { + const testFatalPrefix = `Encountered a fatal error whilst testing the provider ${url}`; + let configUrl = ""; + try { + configUrl = new URL("config", url).href; + } catch (error) { + throw new Error(`${testFatalPrefix}: Invalid Provider URL: ${url} +Error: ${error}`); + } + // TODO: look into using core.getProviderInfo :) + const providerHasUrl = providerResponseCache.has(url); + const json = providerHasUrl + ? providerResponseCache.get(url) + : await fetch(configUrl) + .catch((error) => { + throw new Error(`${testFatalPrefix}: Could not connect: ${error} +Please check the URL.`); + }) + .then(async (response) => { + if (!response.ok) + throw new Error( + `${testFatalPrefix}: The server ${response.url} responded with a non-2xx response.`, + ); + try { + return await response.json(); + } catch (error) { + throw new Error( + `${testFatalPrefix}: The server responded with malformed JSON.\nError: ${error}`, + ); + } + }); + if (typeof json !== "object") + throw new Error( + `${testFatalPrefix}: Did not get an object after decoding.`, + ); + if (!("name" in json) || json.name !== "anastasis") { + throw new Error( + `${testFatalPrefix}: The provider does not appear to be an Anastasis provider. Please check the provider's URL.`, + ); + } + if (!("methods" in json) || !Array.isArray(json.methods)) { + throw new Error( + "This provider doesn't have authentication method. Please check the provider's URL and ensure it is properly configured.", + ); + } + if (!providerHasUrl) providerResponseCache.set(url, json); + if (!expectedMethodType) { + return; + } + let found = false; + for (let i = 0; i < json.methods.length && !found; i++) { + found = json.methods[i].type === expectedMethodType; + } + if (!found) { + throw new Error( + `${testFatalPrefix}: This provider does not support authentication method ${expectedMethodType}`, + ); + } + return; +} diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts new file mode 100644 index 000000000..30e4d750d --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts @@ -0,0 +1,167 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { useEffect, useRef, useState } from "preact/hooks"; +import { Notification } from "../../../components/Notifications.js"; +import { useAnastasisContext } from "../../../context/anastasis.js"; +import { authMethods, KnownAuthMethods } from "../authMethod/index.jsx"; +import { AuthProvByStatusMap, State, testProvider } from "./index.js"; + +interface Props { + providerType?: KnownAuthMethods; + onCancel: () => Promise<void>; + notifications?: Notification[]; +} + +export default function useComponentState({ + providerType, + onCancel, + notifications = [], +}: Props): State { + const reducer = useAnastasisContext(); + + const [providerURL, setProviderURL] = useState(""); + + const [error, setError] = useState<string | undefined>(); + const [testing, setTesting] = useState(false); + + const providerLabel = providerType + ? authMethods[providerType].label + : undefined; + + const allAuthProviders = + !reducer || + !reducer.currentReducerState || + reducer.currentReducerState.reducer_type === "error" || + !reducer.currentReducerState.authentication_providers + ? {} + : reducer.currentReducerState.authentication_providers; + + const authProvidersByStatus = Object.keys(allAuthProviders).reduce( + (prev, url) => { + const p = allAuthProviders[url]; + if ( + providerLabel && + p.status === "ok" && + p.methods.findIndex((m) => m.type === providerType) !== -1 + ) { + return prev; + } + prev[p.status].push({ ...p, url }); + return prev; + }, + { + "not-contacted": [], + disabled: [], + error: [], + ok: [], + } as AuthProvByStatusMap, + ); + const authProviders = authProvidersByStatus["ok"].map((p) => p.url); + + //FIXME: move this timeout logic into a hook + const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); + useEffect(() => { + if (timeout.current) clearTimeout(timeout.current); + timeout.current = setTimeout(async () => { + let url = providerURL; + if (!url || authProviders.includes(url)) return; + if (url && !url.match(/^(https?:)\/\/.+\/(?:config)?$/iu)) + return setError( + "Malformed URL: Must be an HTTP(S) URL ending with a /", + ); + if (url.endsWith("/config")) url = url.substring(0, url.length - 6); + try { + setTesting(true); + await testProvider(url, providerType); + setError(""); + } catch (e) { + if (e instanceof Error) setError(e.message); + else + throw new Error( + `Unexpected Error Type: ${typeof e} - Cannot handle. Error: ${e}`, + ); + } + setTesting(false); + }, 200); + }, [providerURL, reducer]); + + if (!reducer) { + return { + status: "no-reducer", + }; + } + + if ( + !reducer.currentReducerState || + !("authentication_providers" in reducer.currentReducerState) + ) { + return { + status: "invalid-state", + }; + } + + const addProvider = async (provider_url: string): Promise<void> => { + await reducer.transition("add_provider", { provider_url }); + onCancel(); + }; + const deleteProvider = async (provider_url: string): Promise<void> => { + reducer.transition("delete_provider", { provider_url }); + }; + + let errors = !providerURL ? "Add provider URL" : undefined; + let url: string | undefined; + // We'll validate it in testProvider & via a regex above - there's no need in this :) + // try { + // url = new URL("", providerURL).href; + // } catch { + // errors = "Check the URL"; + // } + const _url = url; + + if (!!error && !errors) { + errors = error; + } + if (!errors && authProviders.includes(url!)) { + errors = "That provider is already known"; + } + + const commonState = { + addProvider: !_url ? undefined : async () => addProvider(_url), + deleteProvider: async (url: string) => deleteProvider(url), + allAuthProviders, + authProvidersByStatus, + onCancel, + providerURL, + testing, + setProviderURL: async (s: string) => setProviderURL(s), + errors, + error, + notifications, + }; + + if (!providerLabel) { + return { + status: "without-type", + ...commonState, + }; + } else { + return { + status: "with-type", + providerLabel, + ...commonState, + }; + } +} diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/stories.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/stories.tsx new file mode 100644 index 000000000..548fc01a5 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/stories.tsx @@ -0,0 +1,93 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { WithoutProviderType, WithProviderType } from "./views.jsx"; + +export default { + title: "Adding Provider Screen", + args: { + order: 1, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +export const NewProvider = tests.createExample(WithoutProviderType, { + authProvidersByStatus: { + ok: [ + { + business_name: "X provider", + status: "ok", + storage_limit_in_megabytes: 5, + methods: [ + { + type: "question", + usage_fee: "KUDOS:1", + }, + ], + url: "", + } as AuthenticationProviderStatusOk & { url: string }, + ], + "not-contacted": [], + disabled: [], + error: [], + }, + notifications: [], +}); + +export const NewProviderWithoutProviderList = tests.createExample( + WithoutProviderType, + { + authProvidersByStatus: { + ok: [], + "not-contacted": [], + disabled: [], + error: [], + }, + notifications: [], + }, +); + +export const NewSmsProvider = tests.createExample(WithProviderType, { + authProvidersByStatus: { + ok: [], + "not-contacted": [], + disabled: [], + error: [], + }, + providerLabel: "sms", + notifications: [], +}); + +export const NewIBANProvider = tests.createExample(WithProviderType, { + authProvidersByStatus: { + ok: [], + "not-contacted": [], + disabled: [], + error: [], + }, + providerLabel: "IBAN", + notifications: [], +}); diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/test.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/test.ts new file mode 100644 index 000000000..0aebbdc6c --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/test.ts @@ -0,0 +1,45 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { expect } from "chai"; +import useComponentState from "./state.js"; +import * as tests from "@gnu-taler/web-util/testing"; + +describe("AddingProviderScreen states", () => { + it("should not load more if has reach the end", async () => { + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + return useComponentState({ + providerType: "email", + async onCancel() {}, + }); + }, + {}, + [ + ({ status }) => { + expect(status).eq("no-reducer"); + }, + ], + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + }); +}); diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx new file mode 100644 index 000000000..00a42a949 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx @@ -0,0 +1,309 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AuthenticationProviderStatusError, + AuthenticationProviderStatusOk, +} from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { Notifications } from "../../../components/Notifications.js"; +import { AnastasisClientFrame } from "../index.js"; +import { testProvider, WithoutType, WithType } from "./index.js"; +import { useTranslationContext } from "../../../context/translation.js"; + +export function WithProviderType(props: WithType): VNode { + const { i18n } = useTranslationContext(); + return ( + <AnastasisClientFrame + hideNav + title="Backup: Manage providers1" + hideNext={props.errors} + > + <div> + <Notifications notifications={props.notifications} /> + <p>{i18n.str`Add a provider url for a ${props.providerLabel} service`}</p> + <div class="container"> + <TextInput + label="Provider URL" + placeholder="https://provider.com" + grabFocus + error={props.errors} + bind={[props.providerURL, props.setProviderURL]} + /> + </div> + <p class="block">Example: https://kudos.demo.anastasis.lu</p> + {props.testing && <p class="has-text-info">Testing</p>} + + <div + class="block" + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={props.onCancel}> + Cancel + </button> + <span data-tooltip={props.errors}> + <button + class="button is-info" + disabled={props.error !== "" || props.testing} + onClick={props.addProvider} + > + Add + </button> + </span> + </div> + + {props.authProvidersByStatus["ok"].length > 0 ? ( + <p class="subtitle"> + Current providers for {props.providerLabel} service + </p> + ) : ( + <p class="subtitle"> + No known providers for {props.providerLabel} service + </p> + )} + + {props.authProvidersByStatus["ok"].map((k, i) => { + const p = k as AuthenticationProviderStatusOk; + return ( + <TableRow + key={i} + url={k.url} + info={p} + onDelete={props.deleteProvider} + /> + ); + })} + <p class="subtitle">Providers with errors</p> + {props.authProvidersByStatus["error"].map((k, i) => { + const p = k as AuthenticationProviderStatusError; + return ( + <TableRowError + key={i} + url={k.url} + info={p} + onDelete={props.deleteProvider} + /> + ); + })} + </div> + </AnastasisClientFrame> + ); +} + +export function WithoutProviderType(props: WithoutType): VNode { + return ( + <AnastasisClientFrame + hideNav + title="Backup: Manage providers" + hideNext={props.errors} + > + <div> + <Notifications notifications={props.notifications} /> + <p>Add a provider url</p> + <div class="container"> + <TextInput + label="Provider URL" + placeholder="https://provider.com/" + grabFocus + error={props.errors} + bind={[props.providerURL, props.setProviderURL]} + /> + </div> + <p class="block">Example: https://kudos.demo.anastasis.lu/</p> + {props.testing && <p class="has-text-info">Testing</p>} + + <div + class="block" + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={props.onCancel}> + Cancel + </button> + <span data-tooltip={props.errors}> + <button + class="button is-info" + disabled={props.error !== "" || props.testing} + onClick={props.addProvider} + > + Add + </button> + </span> + </div> + + {props.authProvidersByStatus["ok"].length > 0 ? ( + <p class="subtitle">Current providers</p> + ) : ( + <p class="subtitle">No known providers, add one.</p> + )} + + {props.authProvidersByStatus["ok"].map((k, i) => { + const p = k as AuthenticationProviderStatusOk; + return ( + <TableRow + key={i} + url={k.url} + info={p} + onDelete={props.deleteProvider} + /> + ); + })} + <p class="subtitle">Providers with errors</p> + {props.authProvidersByStatus["error"].map((k, i) => { + const p = k as AuthenticationProviderStatusError; + return ( + <TableRowError + key={i} + url={k.url} + info={p} + onDelete={props.deleteProvider} + /> + ); + })} + </div> + </AnastasisClientFrame> + ); +} + +function TableRow({ + url, + info, + onDelete, +}: { + onDelete: (s: string) => Promise<void>; + url: string; + info: AuthenticationProviderStatusOk; +}): VNode { + const [status, setStatus] = useState("checking"); + useEffect(function () { + testProvider(url.endsWith("/") ? url.substring(0, url.length - 1) : url) + .then(function () { + setStatus("responding"); + }) + .catch(function () { + setStatus("failed to contact"); + }); + }); + return ( + <div + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div> + <div class="subtitle">{url}</div> + <dl> + <dt> + <b>Business Name</b> + </dt> + <dd>{info.business_name}</dd> + <dt> + <b>Supported methods</b> + </dt> + <dd>{info.methods.map((m) => m.type).join(",")}</dd> + <dt> + <b>Maximum storage</b> + </dt> + <dd>{info.storage_limit_in_megabytes} Mb</dd> + <dt> + <b>Status</b> + </dt> + <dd>{status}</dd> + </dl> + </div> + <div + class="block" + style={{ + marginTop: "auto", + marginBottom: "auto", + display: "flex", + justifyContent: "space-between", + flexDirection: "column", + }} + > + <button class="button is-danger" onClick={() => onDelete(url)}> + Remove + </button> + </div> + </div> + ); +} + +function TableRowError({ + url, + info, + onDelete, +}: { + onDelete: (s: string) => void; + url: string; + info: AuthenticationProviderStatusError; +}): VNode { + const [status, setStatus] = useState("checking"); + useEffect(function () { + testProvider(url.endsWith("/") ? url.substring(0, url.length - 1) : url) + .then(function () { + setStatus("responding"); + }) + .catch(function () { + setStatus("failed to contact"); + }); + }); + return ( + <div + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div> + <div class="subtitle">{url}</div> + <dl> + <dt> + <b>Error</b> + </dt> + <dd>{info.hint}</dd> + <dt> + <b>Code</b> + </dt> + <dd>{info.code}</dd> + <dt> + <b>Status</b> + </dt> + <dd>{status}</dd> + </dl> + </div> + <div + class="block" + style={{ + marginTop: "auto", + marginBottom: "auto", + display: "flex", + justifyContent: "space-between", + flexDirection: "column", + }} + > + <button class="button is-danger" onClick={() => onDelete(url)}> + Remove + </button> + </div> + </div> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx index d28a6df43..e6bc5f340 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx @@ -1,63 +1,161 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { AttributeEntryScreen as TestedComponent } from './AttributeEntryScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../utils/index.js"; +import { AttributeEntryScreen as TestedComponent } from "./AttributeEntryScreen.js"; export default { - title: 'Pages/AttributeEntryScreen', + title: "Attribute Entry Screen", component: TestedComponent, + args: { + order: 3, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const WithSomeAttributes = createExample(TestedComponent, { - ...reducerStatesExample.attributeEditing, - required_attributes: [{ - name: 'first', - label: 'first', - type: 'type', - uuid: 'asdasdsa1', - widget: 'wid', - }, { - name: 'pepe', - label: 'second', - type: 'type', - uuid: 'asdasdsa2', - widget: 'wid', - }, { - name: 'pepe2', - label: 'third', - type: 'type', - uuid: 'asdasdsa3', - widget: 'calendar', - }] +export const Backup = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.backupAttributeEditing, + required_attributes: [ + { + name: "full_name", + label: "Full name", + type: "string", + uuid: "asdasdsa1", + widget: "wid", + }, + { + name: "birthplace", + label: "Birthplace", + type: "string", + uuid: "asdasdsa2", + widget: "wid", + }, + { + name: "birthdate", + label: "birthdate", + type: "date", + uuid: "asdasdsa3", + widget: "calendar", + }, + ], } as ReducerState); -export const Empty = createExample(TestedComponent, { - ...reducerStatesExample.attributeEditing, - required_attributes: undefined +export const Recovery = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.recoveryAttributeEditing, + required_attributes: [ + { + name: "full_name", + label: "Full name", + type: "string", + uuid: "asdasdsa1", + widget: "wid", + }, + { + name: "birthplace", + label: "Birthplace", + type: "string", + uuid: "asdasdsa2", + widget: "wid", + }, + { + name: "pepe2", + label: "third", + type: "date", + uuid: "asdasdsa3", + widget: "calendar", + }, + ], } as ReducerState); + +export const WithNoRequiredAttribute = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.backupAttributeEditing, + required_attributes: undefined, + } as ReducerState, +); + +const allWidgets = [ + "anastasis_gtk_ia_aadhar_in", + "anastasis_gtk_ia_ahv", + "anastasis_gtk_ia_birthdate", + "anastasis_gtk_ia_birthnumber_cz", + "anastasis_gtk_ia_birthnumber_sk", + "anastasis_gtk_ia_birthplace", + "anastasis_gtk_ia_cf_it", + "anastasis_gtk_ia_cpr_dk", + "anastasis_gtk_ia_es_dni", + "anastasis_gtk_ia_es_ssn", + "anastasis_gtk_ia_full_name", + "anastasis_gtk_ia_my_jp", + "anastasis_gtk_ia_nid_al", + "anastasis_gtk_ia_nid_be", + "anastasis_gtk_ia_ssn_de", + "anastasis_gtk_ia_ssn_us", + "anastasis_gtk_ia_tax_de", + "anastasis_gtk_xx_prime", + "anastasis_gtk_xx_square", +]; + +function typeForWidget(name: string): string { + if (["anastasis_gtk_xx_prime", "anastasis_gtk_xx_square"].includes(name)) + return "number"; + if (["anastasis_gtk_ia_birthdate"].includes(name)) return "date"; + return "string"; +} + +export const WithAllPosibleWidget = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.backupAttributeEditing, + required_attributes: allWidgets.map((w) => ({ + name: w, + label: `widget: ${w}`, + type: typeForWidget(w), + uuid: `uuid-${w}`, + widget: w, + })), +} as ReducerState); + +export const WithAutocompleteFeature = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.backupAttributeEditing, + required_attributes: [ + { + name: "ahv_number", + label: "AHV Number", + type: "string", + uuid: "asdasdsa1", + widget: "wid", + "validation-regex": + "^(756)\\.[0-9]{4}\\.[0-9]{4}\\.[0-9]{2}|(756)[0-9]{10}$", + "validation-logic": "CH_AHV_check", + autocomplete: "???.????.????.??", + }, + ], + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx index 2f804f940..1f8cea7aa 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx @@ -1,65 +1,270 @@ -/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { UserAttributeSpec, validators } from "@gnu-taler/anastasis-core"; +import { isAfter, parse } from "date-fns"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { ReducerStateRecovery, ReducerStateBackup, UserAttributeSpec } from "anastasis-core/lib"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer"; -import { AnastasisClientFrame, withProcessLabel, LabeledInput } from "./index"; +import { DateInput } from "../../components/fields/DateInput.js"; +import { PhoneNumberInput } from "../../components/fields/NumberInput.js"; +import { TextInput } from "../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { ConfirmModal } from "./ConfirmModal.js"; +import { AnastasisClientFrame, withProcessLabel } from "./index.js"; export function AttributeEntryScreen(): VNode { - const reducer = useAnastasisContext() - const state = reducer?.currentReducerState - const currentIdentityAttributes = state && "identity_attributes" in state ? (state.identity_attributes || {}) : {} - const [attrs, setAttrs] = useState<Record<string, string>>(currentIdentityAttributes); + const reducer = useAnastasisContext(); + const state = reducer?.currentReducerState; + const currentIdentityAttributes = + state && "identity_attributes" in state + ? state.identity_attributes || {} + : {}; + const [attrs, setAttrs] = useState<Record<string, string>>( + currentIdentityAttributes, + ); + const isBackup = state?.reducer_type === "backup"; + const [askUserIfSure, setAskUserIfSure] = useState(false); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || !("required_attributes" in reducer.currentReducerState)) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + !("required_attributes" in reducer.currentReducerState) + ) { + return <div>invalid state</div>; + } + const reqAttr = reducer.currentReducerState.required_attributes || []; + let hasErrors = false; + + const fieldList: VNode[] = reqAttr.map((spec, i: number) => { + const value = attrs[spec.name]; + const error = checkIfValid(value, spec); + + function addAutocomplete(newValue: string): string { + const ac = spec.autocomplete; + if (!ac || ac.length <= newValue.length || ac[newValue.length] === "?") + return newValue; + + if (!value || newValue.length < value.length) { + return newValue.slice(0, -1); + } + + return newValue + ac[newValue.length]; + } + + hasErrors = hasErrors || error !== undefined; + return ( + <AttributeEntryField + key={i} + isFirst={i == 0} + setValue={(v: string) => + setAttrs({ ...attrs, [spec.name]: addAutocomplete(v) }) + } + spec={spec} + errorMessage={error} + onConfirm={() => { + if (!hasErrors) { + setAskUserIfSure(true); + } + }} + value={value} + /> + ); + }); + + const doConfirm = async () => { + await reducer.transition("enter_user_attributes", { + identity_attributes: { + application_id: "anastasis-standalone", + ...attrs, + }, + }); + }; + + function saveAsPDF(): void { + const printWindow = window.open("", "", "height=400,width=800"); + const divContents = document.getElementById("printThis"); + + if (!printWindow || !divContents) return; + printWindow.document.write( + `<html><head><link rel="stylesheet" href="index.css" /><title>Anastasis Recovery Document</title><style>`, + ); + printWindow.document.write("</style></head><body> </body></html>"); + printWindow.document.close(); + printWindow.document.body.appendChild(divContents.cloneNode(true)); + printWindow.addEventListener("load", () => { + printWindow.print(); + printWindow.close(); + }); } - + return ( <AnastasisClientFrame - title={withProcessLabel(reducer, "Select Country")} - onNext={() => reducer.transition("enter_user_attributes", { - identity_attributes: attrs, - })} + title={withProcessLabel(reducer, "Who are you?")} + hideNext={hasErrors ? "Complete the form." : undefined} + onNext={async () => (isBackup ? setAskUserIfSure(true) : doConfirm())} > - {reducer.currentReducerState.required_attributes?.map((x, i: number) => { - return ( - <AttributeEntryField - key={i} - isFirst={i == 0} - setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })} - spec={x} - value={attrs[x.name]} /> - ); - })} + {askUserIfSure ? ( + <ConfirmModal + active + onCancel={() => setAskUserIfSure(false)} + description="The values in the form must be correct" + label="I am sure" + cancelLabel="Wait, I want to check" + onConfirm={() => doConfirm().then(() => setAskUserIfSure(false))} + > + You personal information is used to define the location where your + secret will be safely stored. If you forget what you have entered or + if there is a misspell you will be unable to recover your secret. + <p> + {/* TODO: make this actually work reliably cross-browser lol (opens about:blank for me) */} + <a onClick={saveAsPDF}>Save the personal information as PDF</a> + </p> + </ConfirmModal> + ) : undefined} + + <div class="columns" style={{ maxWidth: "unset" }}> + <div class="column" id="printThis"> + {fieldList} + </div> + <div class="column"> + <p>This personal information will help to locate your secret.</p> + <h1 class="title">This stays private</h1> + <p>The information you have entered here:</p> + <ul> + <li> + <span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span> + Will be hashed, and therefore unreadable + </li> + <li> + <span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span> + The non-hashed version is not shared + </li> + </ul> + </div> + </div> </AnastasisClientFrame> ); } -interface AttributeEntryProps { - reducer: AnastasisReducerApi; - reducerState: ReducerStateRecovery | ReducerStateBackup; -} - -export interface AttributeEntryFieldProps { +interface AttributeEntryFieldProps { isFirst: boolean; value: string; setValue: (newValue: string) => void; spec: UserAttributeSpec; + errorMessage: string | undefined; + onConfirm: () => void; } - -export function AttributeEntryField(props: AttributeEntryFieldProps): VNode { +const possibleBirthdayYear: Array<number> = []; +for (let i = 0; i < 100; i++) { + possibleBirthdayYear.push(2020 - i); +} +function AttributeEntryField(props: AttributeEntryFieldProps): VNode { return ( - <div> - <LabeledInput - grabFocus={props.isFirst} - label={props.spec.label} - bind={[props.value, props.setValue]} - /> + <div style={{ marginTop: 16 }}> + {props.spec.type === "date" && ( + <DateInput + grabFocus={props.isFirst} + label={props.spec.label} + years={possibleBirthdayYear} + onConfirm={props.onConfirm} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + )} + {props.spec.type === "number" && ( + <PhoneNumberInput + grabFocus={props.isFirst} + label={props.spec.label} + onConfirm={props.onConfirm} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + )} + {props.spec.type === "string" && ( + <TextInput + grabFocus={props.isFirst} + label={props.spec.label} + onConfirm={props.onConfirm} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + )} + {props.spec.type === "string" && ( + <div> + This field is case-sensitive. You must enter exactly the same value + during recovery. + </div> + )} + {props.spec.name === "full_name" && ( + <div> + If possible, use "LASTNAME, Firstname(s)" without + abbreviations. + </div> + )} + <div class="block"> + This stays private + <span class="icon is-right"> + <i class="mdi mdi-eye-off" /> + </span> + </div> </div> ); } +const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/; + +function checkIfValid( + value: string, + spec: UserAttributeSpec, +): string | undefined { + const pattern = spec["validation-regex"]; + if (pattern) { + const re = new RegExp(pattern); + if (!re.test(value)) return "The value is invalid"; + } + const logic = spec["validation-logic"]; + if (logic) { + const func = (validators as any)[logic]; + if (func && typeof func === "function" && !func(value)) + return "Please check the value"; + } + const optional = spec.optional; + if (!optional && !value) { + return "This value is required"; + } + if ("date" === spec.type) { + if (!YEAR_REGEX.test(value)) { + return "The date doesn't follow the format"; + } + + try { + const v = parse(value, "yyyy-MM-dd", new Date()); + if (Number.isNaN(v.getTime())) { + return "Some numeric values seems out of range for a date"; + } + if ("birthdate" === spec.name && isAfter(v, new Date())) { + return "A birthdate cannot be in the future"; + } + } catch (e) { + return "Could not parse the date"; + } + } + return undefined; +} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx deleted file mode 100644 index 9567e0ef7..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* 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 } from "./AuthenticationEditorScreen"; -import { 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 deleted file mode 100644 index 55e37a968..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* 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 } from "./AuthenticationEditorScreen"; -import { 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="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 deleted file mode 100644 index 7699cdf34..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* 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 } from "./AuthenticationEditorScreen"; -import { 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 deleted file mode 100644 index 6f4797275..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* 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 } from "./AuthenticationEditorScreen"; -import { 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.stories.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx index 44d3795b2..22f8dd697 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx @@ -1,35 +1,108 @@ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { AuthenticationEditorScreen as TestedComponent } from './AuthenticationEditorScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../utils/index.js"; +import { AuthenticationEditorScreen as TestedComponent } from "./AuthenticationEditorScreen.js"; export default { - title: 'Pages/AuthenticationEditorScreen', + title: "Authentication Editor Screen", component: TestedComponent, + args: { + order: 4, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.authEditing); +export const InitialState = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.authEditing, +); +export const OneAuthMethodConfigured = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.authEditing, + authentication_methods: [ + { + type: "question", + instructions: "what time is it?", + challenge: "asd", + }, + ], + } as ReducerState, +); + +export const SomeMoreAuthMethodConfigured = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.authEditing, + authentication_methods: [ + { + type: "question", + instructions: "what time is it?", + challenge: "asd", + }, + { + type: "question", + instructions: "what time is it?", + challenge: "qwe", + }, + { + type: "sms", + instructions: "what time is it?", + challenge: "asd", + }, + { + type: "email", + instructions: "what time is it?", + challenge: "asd", + }, + { + type: "email", + instructions: "what time is it?", + challenge: "asd", + }, + { + type: "email", + instructions: "what time is it?", + challenge: "asd", + }, + { + type: "email", + instructions: "what time is it?", + challenge: "asd", + }, + ], + } as ReducerState, +); + +export const NoAuthMethodProvided = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.authEditing, + authentication_providers: {}, + authentication_methods: [], +} as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx index e9ffccbac..54bbc626d 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx @@ -1,112 +1,256 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { AuthMethod, ReducerStateBackup } from "anastasis-core"; -import { h, VNode } from "preact"; +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { AuthMethod, ReducerStateBackup } from "@gnu-taler/anastasis-core"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -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"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import AddingProviderScreen from "./AddingProviderScreen/index.js"; +import { + authMethods, + AuthMethodSetupProps, + AuthMethodWithRemove, + isKnownAuthMethods, + KnownAuthMethods, +} from "./authMethod/index.js"; +import { ConfirmModal } from "./ConfirmModal.js"; +import { AnastasisClientFrame } from "./index.js"; + +const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>; export function AuthenticationEditorScreen(): VNode { - const [selectedMethod, setSelectedMethod] = useState<string | undefined>( - undefined + const [noProvidersAck, setNoProvidersAck] = useState(false); + const [selectedMethod, setSelectedMethod] = useState< + KnownAuthMethods | undefined + >(undefined); + const [tooFewAuths, setTooFewAuths] = useState(false); + const [manageProvider, setManageProvider] = useState<string | undefined>( + undefined, ); - const reducer = useAnastasisContext() + + // const [addingProvider, setAddingProvider] = useState<string | undefined>(undefined) + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "backup") { + return <div>invalid state</div>; } + const configuredAuthMethods: AuthMethod[] = + reducer.currentReducerState.authentication_methods ?? []; + + function removeByIndex(index: number): void { + if (reducer) + reducer.transition("delete_authentication", { + authentication_method: index, + }); + } + + const camByType: { [s: string]: AuthMethodWithRemove[] } = {}; + for (let index = 0; index < configuredAuthMethods.length; index++) { + const cam = { + ...configuredAuthMethods[index], + remove: () => removeByIndex(index), + }; + const prevValue = camByType[cam.type] || []; + prevValue.push(cam); + camByType[cam.type] = prevValue; + } + const providers = reducer.currentReducerState.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) { + if (p.status === "ok") { for (const meth of p.methods) { authAvailableSet.add(meth.type); } } } + + if (manageProvider !== undefined) { + return ( + <AddingProviderScreen + onCancel={async () => setManageProvider(undefined)} + providerType={ + isKnownAuthMethods(manageProvider) ? manageProvider : undefined + } + /> + ); + } + 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; + + const AuthSetup = + authMethods[selectedMethod].setup ?? AuthMethodNotImplemented; return ( - <AuthSetup - cancel={cancel} - addAuthMethod={addMethod} - method={selectedMethod} /> + <Fragment> + <AuthSetup + cancel={cancel} + configured={camByType[selectedMethod] || []} + addAuthMethod={addMethod} + method={selectedMethod} + /> + + {!authAvailableSet.has(selectedMethod) && ( + <ConfirmModal + active + onCancel={cancel} + description="No providers found" + label="Add a provider manually" + onConfirm={async () => { + setManageProvider(selectedMethod); + }} + > + <p> + We have found no Anastasis providers that support this + authentication method. You can add a provider manually. To add a + provider you must know the provider URL (e.g. + https://provider.com) + </p> + <p> + <a>Learn more about Anastasis providers</a> + </p> + </ConfirmModal> + )} + </Fragment> ); } - function MethodButton(props: { method: string; label: string }): VNode { + + function MethodButton(props: { method: KnownAuthMethods }): VNode { + if (authMethods[props.method].skip) return <div />; + return ( - <button - disabled={!authAvailableSet.has(props.method)} - onClick={() => { - setSelectedMethod(props.method); - if (reducer) reducer.dismissError(); - }} - > - {props.label} - </button> + <div class="block"> + <button + style={{ justifyContent: "space-between" }} + class="button is-fullwidth" + onClick={() => { + setSelectedMethod(props.method); + }} + > + <div style={{ display: "flex" }}> + <span class="icon ">{authMethods[props.method].icon}</span> + {authAvailableSet.has(props.method) ? ( + <span>Add a {authMethods[props.method].label} challenge</span> + ) : ( + <span>Add a {authMethods[props.method].label} provider</span> + )} + </div> + {!authAvailableSet.has(props.method) && ( + <span class="icon has-text-danger"> + <i class="mdi mdi-exclamation-thick" /> + </span> + )} + {camByType[props.method] && ( + <span class="tag is-info">{camByType[props.method].length}</span> + )} + </button> + </div> ); } - const configuredAuthMethods: AuthMethod[] = reducer.currentReducerState.authentication_methods ?? []; - const haveMethodsConfigured = configuredAuthMethods.length; + const errors = + configuredAuthMethods.length < 2 + ? "There is not enough authentication methods." + : undefined; + const handleNext = async () => { + const st = reducer.currentReducerState as ReducerStateBackup; + if ((st.authentication_methods ?? []).length <= 2) { + setTooFewAuths(true); + } else { + await reducer.transition("next", {}); + } + }; 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> + <AnastasisClientFrame + title="Backup: Configure Authentication Methods" + hideNext={errors} + onNext={handleNext} + > + <div class="columns"> + <div class="column"> + <div> + {getKeys(authMethods).map((method) => ( + <MethodButton key={method} method={method} /> + ))} + </div> + {tooFewAuths ? ( + <ConfirmModal + active={tooFewAuths} + onCancel={() => setTooFewAuths(false)} + description="Too few auth methods configured" + label="Proceed anyway" + onConfirm={() => reducer.transition("next", {})} + > + You have selected fewer than 3 authentication methods. We + recommend that you add at least 3. + </ConfirmModal> + ) : null} + {authAvailableSet.size === 0 && ( + <ConfirmModal + active={!noProvidersAck} + onCancel={() => setNoProvidersAck(true)} + description="No providers found" + label="Add a provider manually" + onConfirm={async () => { + setManageProvider(""); + }} + > + <p> + We have found no Anastasis providers for your chosen country / + currency. You can add a providers manually. To add a provider + you must know the provider URL (e.g. https://provider.com) + </p> + <p> + <a>Learn more about Anastasis providers</a> + </p> + </ConfirmModal> + )} + </div> + <div class="column"> + <p class="block"> + When recovering your secret data, you will be asked to verify your + identity via the methods you configure here. The list of + authentication method is defined by the backup provider list. + </p> + <p class="block"> + <button + class="button is-info" + onClick={() => setManageProvider("")} + > + Manage backup providers + </button> + </p> + {authAvailableSet.size > 0 && ( + <p class="block"> + We couldn't find provider for some of the authentication + methods. </p> - ); - }) - ) : ( - <p>No authentication methods configured yet.</p> - )} + )} + </div> + </div> </AnastasisClientFrame> ); } -export interface AuthMethodSetupProps { - method: string; - addAuthMethod: (x: any) => void; - cancel: () => void; -} - function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { return ( <AnastasisClientFrame hideNav title={`Add ${props.method} authentication`}> @@ -115,9 +259,3 @@ function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { </AnastasisClientFrame> ); } - -interface AuthenticationEditorProps { - reducer: AnastasisReducerApi; - backupState: ReducerStateBackup; -} - diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx index 65a2b7e13..a51940615 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx @@ -1,60 +1,67 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { BackupFinishedScreen as TestedComponent } from './BackupFinishedScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { BackupFinishedScreen as TestedComponent } from "./BackupFinishedScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/BackupFinishedScreen', + title: "Backup finish", component: TestedComponent, + args: { + order: 8, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Simple = createExample(TestedComponent, reducerStatesExample.backupFinished); +export const WithoutName = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.backupFinished, +); -export const WithName = createExample(TestedComponent, {...reducerStatesExample.backupFinished, - secret_name: 'super_secret', +export const WithName = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.backupFinished, + secret_name: "super_secret", } as ReducerState); -export const WithDetails = createExample(TestedComponent, { +export const WithDetails = tests.createExample(TestedComponent, {}, { ...reducerStatesExample.backupFinished, - secret_name: 'super_secret', + secret_name: "super_secret", success_details: { - 'http://anastasis.net': { + "https://anastasis.demo.taler.net/": { policy_expiration: { - t_ms: 'never' + t_s: "never", }, - policy_version: 0 + policy_version: 0, }, - 'http://taler.net': { + "https://kudos.demo.anastasis.lu/": { policy_expiration: { - t_ms: new Date().getTime() + 60*60*24*1000 + t_s: new Date().getTime() + 60 * 60 * 24, }, - policy_version: 1 + policy_version: 1, }, - } + }, } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx index 218f1d1fd..9b63c9887 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx @@ -1,33 +1,81 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core"; +import { format } from "date-fns"; import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame } from "./index.js"; export function BackupFinishedScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "backup") { + return <div>invalid state</div>; } - const details = reducer.currentReducerState.success_details - return (<AnastasisClientFrame hideNext title="Backup finished"> - <p> - Your backup of secret "{reducer.currentReducerState.secret_name ?? "??"}" was - successful. - </p> - <p>The backup is stored by the following providers:</p> + const details = reducer.currentReducerState.success_details; + const providers = reducer.currentReducerState.authentication_providers ?? {}; + + return ( + <AnastasisClientFrame hideNav title="Backup success!"> + <p>Your backup is complete.</p> - {details && <ul> - {Object.keys(details).map((x, i) => { - const sd = details[x]; - return ( - <li key={i}> - {x} (Policy version {sd.policy_version}) - </li> - ); - })} - </ul>} - <button onClick={() => reducer.reset()}>Back to start</button> - </AnastasisClientFrame>); + {details && ( + <div class="block"> + <p>The backup is stored by the following providers:</p> + {Object.keys(details).map((url, i) => { + const sd = details[url]; + const p = providers[url] as AuthenticationProviderStatusOk; + return ( + <div key={i} class="box"> + <a href={url} target="_blank" rel="noreferrer"> + {p.business_name} + </a> + <p> + version {sd.policy_version} + {sd.policy_expiration.t_s !== "never" + ? ` expires at: ${format( + new Date(sd.policy_expiration.t_s * 1000), + "dd-MM-yyyy", + )}` + : " without expiration date"} + </p> + </div> + ); + })} + <div + style={{ + display: "flex", + justifyContent: "center", + }} + > + <p> + <div class="buttons ml-4"> + <button + class="button is-primary is-right" + onClick={() => reducer.reset()} + > + Start again + </button> + </div> + </p> + </div> + </div> + )} + </AnastasisClientFrame> + ); } diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx index 4f186c031..84df615f3 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx @@ -1,83 +1,272 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { ChallengeOverviewScreen as TestedComponent } from './ChallengeOverviewScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { + ChallengeFeedbackStatus, + RecoveryStates, + ReducerState, +} from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { ChallengeOverviewScreen as TestedComponent } from "./ChallengeOverviewScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; +import { AmountString } from "@gnu-taler/taler-util"; export default { - title: 'Pages/ChallengeOverviewScreen', + title: "Challenge overview", component: TestedComponent, + args: { + order: 5, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const OneChallenge = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting, +export const OneUnsolvedPolicy = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.challengeSelecting, recovery_information: { - policies: [[{uuid:'1'}]], - challenges: [{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '1', - }] + policies: [[{ uuid: "1" }]], + challenges: [ + { + instructions: "just go for it", + type: "question", + uuid: "1", + }, + ], }, } as ReducerState); -export const MoreChallenges = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting, +export const SomePoliciesOneSolved = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.challengeSelecting, recovery_information: { - policies: [[{uuid:'1'}, {uuid:'2'}],[{uuid:'3'}]], - challenges: [{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '1', - },{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '2', - },{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '3', - }] + policies: [[{ uuid: "1" }, { uuid: "2" }], [{ uuid: "uuid-3" }]], + challenges: [ + { + instructions: "this question cost 1 USD", + type: "question", + uuid: "1", + }, + { + instructions: "answering this question is free", + type: "question", + uuid: "2", + }, + { + instructions: "this question is already answered", + type: "question", + uuid: "uuid-3", + }, + ], + }, + challenge_feedback: { + "uuid-3": { + state: "solved", + }, }, } as ReducerState); -export const OneBadConfiguredPolicy = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting, +export const OneBadConfiguredPolicy = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.challengeSelecting, recovery_information: { - policies: [[{uuid:'2'}]], - challenges: [{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'sasd', - uuid: '1', - }] + policies: [[{ uuid: "1" }, { uuid: "2" }]], + challenges: [ + { + instructions: "this policy has a missing uuid (the other auth method)", + type: "totp", + uuid: "1", + }, + ], }, } as ReducerState); -export const NoPolicies = createExample(TestedComponent, reducerStatesExample.challengeSelecting); +export const OnePolicyWithAllTheChallenges = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.challengeSelecting, + recovery_information: { + policies: [ + [ + { uuid: "1" }, + { uuid: "2" }, + { uuid: "3" }, + { uuid: "4" }, + { uuid: "5" }, + { uuid: "6" }, + { uuid: "7" }, + { uuid: "8" }, + ], + ], + challenges: [ + { + instructions: "Does P equals NP?", + type: "question", + uuid: "1", + }, + { + instructions: "SMS to 555-555", + type: "sms", + uuid: "2", + }, + { + instructions: "Email to qwe@asd.com", + type: "email", + uuid: "3", + }, + { + instructions: 'Enter 8 digits code for "Anastasis"', + type: "totp", + uuid: "4", + }, + { + // + instructions: + "Wire transfer from ASDXCVQWE123123 with holder Florian", + type: "iban", + uuid: "5", + }, + { + instructions: "Join a video call", + type: "video", //Enter 8 digits code for "Anastasis" + uuid: "7", + }, + {}, + { + instructions: "Letter to address in postal code DE123123", + type: "post", //Enter 8 digits code for "Anastasis" + uuid: "8", + }, + { + instructions: "instruction for an unknown type of challenge", + type: "new-type-of-challenge", + uuid: "6", + }, + ], + }, + } as ReducerState, +); + +export const OnePolicyWithAllTheChallengesInDifferentState = + tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.challengeSelecting, + recovery_state: RecoveryStates.ChallengeSelecting, + recovery_information: { + policies: [ + [ + { uuid: "uuid-1" }, + { uuid: "uuid-2" }, + { uuid: "uuid-3" }, + { uuid: "uuid-4" }, + { uuid: "uuid-5" }, + { uuid: "uuid-6" }, + { uuid: "uuid-7" }, + { uuid: "uuid-8" }, + { uuid: "uuid-9" }, + { uuid: "uuid-10" }, + ], + ], + challenges: [ + { + instructions: 'this challenge is in state "solved"', + type: "question", + uuid: "uuid-1", + }, + { + instructions: 'this challenge is in state "code-in-file"', + type: "question", + uuid: "uuid-2", + }, + { + instructions: 'this challenge is in state "code-sent"', + type: "question", + uuid: "uuid-3", + }, + { + instructions: 'this challenge is in state "server-failure "', + type: "question", + uuid: "uuid-4", + }, + { + instructions: 'this challenge is in state "truth-unknown"', + type: "question", + uuid: "uuid-5", + }, + { + instructions: 'this challenge is in state "taler-payment"', + type: "question", + uuid: "uuid-6", + }, + { + instructions: 'this challenge is in state "unsupported"', + type: "question", + uuid: "uuid-7", + }, + { + instructions: 'this challenge is in state "rate-limit-exceeded"', + type: "question", + uuid: "uuid-8", + }, + { + instructions: 'this challenge is in state "iban-instructions"', + type: "question", + uuid: "uuid-9", + }, + { + instructions: 'this challenge is in state "incorrect-answer"', + type: "question", + uuid: "uuid-10", + }, + ], + }, + challenge_feedback: { + "uuid-1": { state: ChallengeFeedbackStatus.Solved.toString() }, + "uuid-2": { state: ChallengeFeedbackStatus.CodeInFile.toString() }, + "uuid-3": { state: ChallengeFeedbackStatus.CodeSent.toString() }, + "uuid-4": { + state: ChallengeFeedbackStatus.ServerFailure.toString(), + http_status: 500, + error_response: "some error message or error object", + }, + "uuid-5": { state: ChallengeFeedbackStatus.TruthUnknown.toString() }, + "uuid-6": { + state: ChallengeFeedbackStatus.TalerPayment.toString(), + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + "uuid-7": { state: ChallengeFeedbackStatus.Unsupported.toString() }, + "uuid-8": { state: ChallengeFeedbackStatus.RateLimitExceeded.toString() }, + "uuid-9": { + state: ChallengeFeedbackStatus.IbanInstructions.toString(), + challenge_amount: "EUR:1" as AmountString, + target_iban: "DE12345789000", + target_business_name: "Data Loss Incorporated", + wire_transfer_subject: "Anastasis 987654321", + }, + "uuid-10": { state: ChallengeFeedbackStatus.IncorrectAnswer.toString() }, + }, + } as ReducerState); +export const NoPolicies = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.challengeSelecting, +); diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx index c9b52e91b..5b9c11bab 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx @@ -1,77 +1,293 @@ -import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + ChallengeFeedback, + ChallengeFeedbackStatus, +} from "@gnu-taler/anastasis-core"; +import { Fragment, h, VNode } from "preact"; +import { AsyncButton } from "../../components/AsyncButton.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { authMethods, KnownAuthMethods } from "./authMethod/index.js"; +import { AnastasisClientFrame } from "./index.js"; + +function OverviewFeedbackDisplay(props: { + feedback?: ChallengeFeedback; +}): VNode { + const { feedback } = props; + if (!feedback) { + return <Fragment />; + } + + switch (feedback.state) { + case ChallengeFeedbackStatus.Solved: + return <div />; + case ChallengeFeedbackStatus.IbanInstructions: + return <div class="block has-text-info">Payment required.</div>; + case ChallengeFeedbackStatus.ServerFailure: + return <div class="block has-text-danger">Server error.</div>; + case ChallengeFeedbackStatus.RateLimitExceeded: + return ( + <div class="block has-text-danger"> + There were to many failed attempts. + </div> + ); + case ChallengeFeedbackStatus.Unsupported: + return ( + <div class="block has-text-danger"> + This client doesn't support solving this type of challenge. Use + another version or contact the provider. + </div> + ); + case ChallengeFeedbackStatus.TruthUnknown: + return ( + <div class="block has-text-danger"> + Provider doesn't recognize the type of challenge. Use another + version or contact the provider. + </div> + ); + case ChallengeFeedbackStatus.IncorrectAnswer: + return ( + <div class="block has-text-danger">The answer was not correct.</div> + ); + case ChallengeFeedbackStatus.CodeInFile: + return <div class="block has-text-info">code in file</div>; + case ChallengeFeedbackStatus.CodeSent: + return <div class="block has-text-info">Code sent</div>; + case ChallengeFeedbackStatus.TalerPayment: + return <div class="block has-text-info">Payment required</div>; + } +} export function ChallengeOverviewScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return <div>invalid state</div>; } - const policies = reducer.currentReducerState.recovery_information?.policies ?? []; - const chArr = reducer.currentReducerState.recovery_information?.challenges ?? []; - const challengeFeedback = reducer.currentReducerState?.challenge_feedback; + const policies = + reducer.currentReducerState.recovery_information?.policies ?? []; + const knownChallengesArray = + reducer.currentReducerState.recovery_information?.challenges ?? []; + const challengeFeedback = + reducer.currentReducerState?.challenge_feedback ?? {}; - const challenges: { + const knownChallengesMap: { [uuid: string]: { type: string; instructions: string; - cost: string; + feedback: ChallengeFeedback | undefined; }; } = {}; - for (const ch of chArr) { - challenges[ch.uuid] = { + for (const ch of knownChallengesArray) { + knownChallengesMap[ch.uuid] = { type: ch.type, - cost: ch.cost, instructions: ch.instructions, + feedback: challengeFeedback[ch.uuid], }; } + const policiesWithInfo = policies + .map((row) => { + let isPolicySolved = true; + const challenges = row + .map(({ uuid }) => { + const info = knownChallengesMap[uuid]; + const isChallengeSolved = info?.feedback?.state === "solved"; + isPolicySolved = isPolicySolved && isChallengeSolved; + return { info, uuid, isChallengeSolved }; + }) + .filter((ch) => ch.info !== undefined); + + return { + isPolicySolved, + challenges, + corrupted: row.length > challenges.length, + }; + }) + .filter((p) => !p.corrupted); + + const atLeastThereIsOnePolicySolved = + policiesWithInfo.find((p) => p.isPolicySolved) !== undefined; + + const errors = !atLeastThereIsOnePolicySolved + ? "Solve one policy before proceeding" + : undefined; return ( - <AnastasisClientFrame title="Recovery: Solve challenges"> - <h2>Policies</h2> - {!policies.length && <p> - No policies found - </p>} - {policies.map((row, i) => { - return ( - <div key={i}> - <h3>Policy #{i + 1}</h3> - {row.map(column => { - const ch = challenges[column.uuid]; - if (!ch) return <div> - There is no challenge for this policy + <AnastasisClientFrame hideNext={errors} title="Recovery: Solve challenges"> + {!policiesWithInfo.length ? ( + <p class="block"> + No policies found, try with another version of the secret + </p> + ) : policiesWithInfo.length === 1 ? ( + <p class="block"> + One policy found for this secret. You need to solve all the challenges + in order to recover your secret. + </p> + ) : ( + <p class="block"> + We have found {policiesWithInfo.length} polices. You need to solve all + the challenges from one policy in order to recover your secret. + </p> + )} + {policiesWithInfo.map((policy, policy_index) => { + const tableBody = policy.challenges.map(({ info, uuid }) => { + const method = authMethods[info.type as KnownAuthMethods]; + + if (!method) { + return ( + <div + key={uuid} + class="block" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div style={{ display: "flex", alignItems: "center" }}> + <span>unknown challenge</span> + </div> </div> - const feedback = challengeFeedback?.[column.uuid]; + ); + } + + function ChallengeButton({ + id, + feedback, + }: { + id: string; + feedback?: ChallengeFeedback; + }): VNode { + async function selectChallenge(): Promise<void> { + if (reducer) { + return reducer.transition("select_challenge", { uuid: id }); + } + } + if (!feedback) { return ( - <div key={column.uuid} - 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: column.uuid, - })} + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Solve + </AsyncButton> + </div> + ); + } + switch (feedback.state) { + case ChallengeFeedbackStatus.ServerFailure: + case ChallengeFeedbackStatus.Unsupported: + case ChallengeFeedbackStatus.TruthUnknown: + case ChallengeFeedbackStatus.RateLimitExceeded: + return <div />; + case ChallengeFeedbackStatus.IbanInstructions: + case ChallengeFeedbackStatus.TalerPayment: + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Pay + </AsyncButton> + </div> + ); + case ChallengeFeedbackStatus.Solved: + return ( + <div> + <div class="tag is-success is-large">Solved</div> + </div> + ); + default: + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} > Solve - </button> - ) : null} + </AsyncButton> + </div> + ); + } + } + return ( + <div + key={uuid} + class="block" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div + style={{ + display: "flex", + flexDirection: "column", + }} + > + <div style={{ display: "flex", alignItems: "center" }}> + <span class="icon">{method?.icon}</span> + <span>{info.instructions}</span> </div> - ); - })} + <OverviewFeedbackDisplay feedback={info.feedback} /> + </div> + + <ChallengeButton id={uuid} feedback={info.feedback} /> + </div> + ); + }); + + const policyName = policy.challenges + .map((x) => x.info.type) + .join(" + "); + + const opa = !atLeastThereIsOnePolicySolved + ? undefined + : policy.isPolicySolved + ? undefined + : "0.6"; + + return ( + <div + key={policy_index} + class="box" + style={{ + opacity: opa, + }} + > + <h3 class="subtitle"> + Policy #{policy_index + 1}: {policyName} + </h3> + {policy.challenges.length === 0 && ( + <p>This policy doesn't have any challenges.</p> + )} + {policy.challenges.length === 1 && ( + <p>This policy has one challenge.</p> + )} + {policy.challenges.length > 1 && ( + <p>This policy has {policy.challenges.length} challenges.</p> + )} + {tableBody} </div> ); })} diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx new file mode 100644 index 000000000..0489e5a11 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx @@ -0,0 +1,42 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../utils/index.js"; +import { ChallengePayingScreen as TestedComponent } from "./ChallengePayingScreen.js"; + +export default { + title: "Challenge paying", + component: TestedComponent, + args: { + order: 10, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +export const Example = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.challengePaying, +); diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx new file mode 100644 index 000000000..9f1201797 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx @@ -0,0 +1,45 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { h, VNode } from "preact"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame } from "./index.js"; + +export function ChallengePayingScreen(): VNode { + const reducer = useAnastasisContext(); + if (!reducer) { + return <div>no reducer in context</div>; + } + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return <div>invalid state</div>; + } + const payments = [""]; //reducer.currentReducerState.payments ?? + return ( + <AnastasisClientFrame hideNav title="Recovery: Challenge Paying"> + <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={() => reducer.transition("pay", {})}> + Check payment status now + </button> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx new file mode 100644 index 000000000..0111815f5 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx @@ -0,0 +1,84 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { ComponentChildren, h, VNode } from "preact"; +import { AsyncButton } from "../../components/AsyncButton.js"; + +export interface ConfirmModelProps { + active?: boolean; + description?: string; + onCancel?: () => void; + onConfirm?: () => Promise<void>; + label?: string; + cancelLabel?: string; + children?: ComponentChildren; + danger?: boolean; + disabled?: boolean; +} + +export function ConfirmModal({ + active, + description, + onCancel, + onConfirm, + children, + danger, + disabled, + label = "Confirm", + cancelLabel = "Dismiss", +}: ConfirmModelProps): VNode { + return ( + <div class={active ? "modal is-active" : "modal"}> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card" style={{ maxWidth: 700 }}> + <header class="modal-card-head"> + {!description ? null : ( + <p class="modal-card-title"> + <b>{description}</b> + </p> + )} + <button class="delete " aria-label="close" onClick={onCancel} /> + </header> + <section class="modal-card-body">{children}</section> + <footer class="modal-card-foot"> + <button class="button" onClick={onCancel}> + {cancelLabel} + </button> + <div + class="buttons is-right" + style={{ width: "100%" }} + onKeyDown={(e) => { + if (e.key === "Escape" && onCancel) onCancel(); + }} + > + <AsyncButton + grabFocus + class={danger ? "button is-danger " : "button is-info "} + disabled={disabled} + onClick={onConfirm} + > + {label} + </AsyncButton> + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + </div> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx index aad37cd7f..646165341 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx @@ -1,36 +1,59 @@ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { ContinentSelectionScreen as TestedComponent } from './ContinentSelectionScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { ContinentSelectionScreen as TestedComponent } from "./ContinentSelectionScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/ContinentSelectionScreen', + title: "Continent selection", component: TestedComponent, + args: { + order: 2, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Backup = createExample(TestedComponent, reducerStatesExample.backupSelectCountry); -export const Recovery = createExample(TestedComponent, reducerStatesExample.recoverySelectCountry); +export const BackupSelectContinent = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.backupSelectContinent, +); + +export const BackupSelectCountry = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.backupSelectContinent, + selected_continent: "Testcontinent", +} as ReducerState); + +export const RecoverySelectContinent = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.recoverySelectContinent, +); + +export const RecoverySelectCountry = tests.createExample(TestedComponent, {}, { + ...reducerStatesExample.recoverySelectContinent, + selected_continent: "Testcontinent", +} as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx index ad529a4a7..3231e61e4 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx @@ -1,20 +1,159 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, withProcessLabel } from "./index"; +import { useState } from "preact/hooks"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame, withProcessLabel } from "./index.js"; export function ContinentSelectionScreen(): VNode { - const reducer = useAnastasisContext() - if (!reducer || !reducer.currentReducerState || !("continents" in reducer.currentReducerState)) { - return <div /> + const reducer = useAnastasisContext(); + + // FIXME: remove this when #7056 is fixed + const countryFromReducer = + (reducer?.currentReducerState as any).selected_country || ""; + const [countryCode, setCountryCode] = useState(countryFromReducer); + + if ( + !reducer || + !reducer.currentReducerState || + !("continents" in reducer.currentReducerState) + ) { + return <div />; } - const sel = (x: string): void => reducer.transition("select_continent", { continent: x }); + const selectContinent = (continent: string): void => { + reducer.transition("select_continent", { continent }); + }; + const selectCountry = (country: string): void => { + setCountryCode(country); + }; + + const continentList = reducer.currentReducerState.continents || []; + const countryList = reducer.currentReducerState.countries || []; + const theContinent = reducer.currentReducerState.selected_continent || ""; + // const cc = reducer.currentReducerState.selected_country || ""; + const theCountry = countryList.find((c) => c.code === countryCode); + const selectCountryAction = async () => { + // selection should be when the select box changes it value + if (!theCountry) return; + // FIXME: Why is there no await? + reducer.transition("select_country", { + country_code: countryCode, + }); + }; + + // const step1 = reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting || + // reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting; + + // FIXME: i18n + const errors = !theCountry ? "Select a country" : undefined; + + const handleBack = async () => { + // We want to go to the start, even if we already selected + // a country. + // FIXME: What if we don't want to lose all information here? + // Can we do some kind of soft reset? + reducer.reset(); + }; + return ( - <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Continent")}> - {reducer.currentReducerState.continents.map((x: any) => ( - <button onClick={() => sel(x.name)} key={x.name}> - {x.name} - </button> - ))} + <AnastasisClientFrame + hideNext={errors} + title={withProcessLabel(reducer, "Where do you live?")} + onNext={selectCountryAction} + onBack={handleBack} + > + <div class="columns"> + <div class="column is-one-third"> + <div class="field"> + <label class="label">Continent</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth"> + <select + onChange={(e) => selectContinent(e.currentTarget.value)} + value={theContinent} + > + <option key="none" disabled selected value=""> + {" "} + Choose a continent{" "} + </option> + {continentList.map((prov) => ( + <option key={prov.name} value={prov.name}> + {prov.name} + </option> + ))} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> + </div> + </div> + + <div class="field"> + <label class="label">Country</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth"> + <select + onChange={(e) => selectCountry((e.target as any).value)} + disabled={!theContinent} + value={theCountry?.code || ""} + > + <option key="none" disabled selected value=""> + {" "} + Choose a country{" "} + </option> + {countryList.map((prov) => ( + <option key={prov.name} value={prov.code}> + {prov.name} + </option> + ))} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> + </div> + </div> + </div> + <div class="column is-two-third"> + <p> + Your selection will help us ask right information to uniquely + identify you when you want to recover your secret again. + </p> + <p> + Choose the country that issued most of your long-term legal + documents or personal identifiers. + </p> + {/* <div + style={{ + border: "1px solid gray", + borderRadius: "0.5em", + backgroundColor: "#fbfcbd", + padding: "0.5em", + }} + > + <p> + If you just want to try out Anastasis, we recommend that you + choose <b>Testcontinent</b> with <b>Demoland</b>. For this special + country, you will be asked for a simple number and not real, + personal identifiable information. + </p> + </div> */} + </div> + </div> </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx deleted file mode 100644 index adf36980f..000000000 --- a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { CountrySelectionScreen as TestedComponent } from './CountrySelectionScreen'; - - -export default { - title: 'Pages/CountrySelectionScreen', - component: TestedComponent, - argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, - }, -}; - -export const Backup = createExample(TestedComponent, reducerStatesExample.backupSelectCountry); -export const Recovery = createExample(TestedComponent, reducerStatesExample.recoverySelectCountry); diff --git a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx deleted file mode 100644 index 555622c1d..000000000 --- a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, withProcessLabel } from "./index"; - -export function CountrySelectionScreen(): VNode { - const reducer = useAnastasisContext() - if (!reducer) { - return <div>no reducer in context</div> - } - if (!reducer.currentReducerState || !("countries" in reducer.currentReducerState)) { - return <div>invalid state</div> - } - const sel = (x: any): void => reducer.transition("select_country", { - country_code: x.code, - currencies: [x.currency], - }); - return ( - <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Country")} > - {reducer.currentReducerState.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/EditPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx new file mode 100644 index 000000000..3c9fd7f50 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx @@ -0,0 +1,141 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ReducerState } from "@gnu-taler/anastasis-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../utils/index.js"; +import { EditPoliciesScreen as TestedComponent } from "./EditPoliciesScreen.js"; + +export default { + title: "Edit policies", + args: { + order: 6, + }, + component: TestedComponent, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +export const EditingAPolicy = tests.createExample( + TestedComponent, + { index: 0 }, + { + ...reducerStatesExample.policyReview, + policies: [ + { + methods: [ + { + authentication_method: 1, + provider: "https://anastasis.demo.taler.net/", + }, + { + authentication_method: 2, + provider: "http://localhost:8086/", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "http://localhost:8086/", + }, + ], + }, + ], + authentication_methods: [ + { + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA", + }, + { + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA", + }, + { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "", + }, + { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8", + }, + ], + } as ReducerState, +); + +export const CreatingAPolicy = tests.createExample( + TestedComponent, + { index: 3 }, + { + ...reducerStatesExample.policyReview, + policies: [ + { + methods: [ + { + authentication_method: 1, + provider: "https://anastasis.demo.taler.net/", + }, + { + authentication_method: 2, + provider: "http://localhost:8086/", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "http://localhost:8086/", + }, + ], + }, + ], + authentication_methods: [ + { + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA", + }, + { + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA", + }, + { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "", + }, + { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8", + }, + ], + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx new file mode 100644 index 000000000..24550f89e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx @@ -0,0 +1,187 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { authMethods, KnownAuthMethods } from "./authMethod/index.js"; +import { AnastasisClientFrame } from "./index.js"; + +export interface ProviderInfo { + url: string; + cost: string; + isFree: boolean; +} + +export type ProviderInfoByType = { + [type in KnownAuthMethods]?: ProviderInfo[]; +}; + +interface Props { + index: number; + cancel: () => void; + confirm: (changes: MethodProvider[]) => void; +} + +export interface MethodProvider { + authentication_method: number; + provider: string; +} + +export function EditPoliciesScreen({ + index: policy_index, + cancel, + confirm, +}: Props): VNode { + const [changedProvider, setChangedProvider] = useState<Array<string>>([]); + + const reducer = useAnastasisContext(); + if (!reducer) { + return <div>no reducer in context</div>; + } + if (reducer.currentReducerState?.reducer_type !== "backup") { + return <div>invalid state</div>; + } + + const selectableProviders: ProviderInfoByType = {}; + const allProviders = Object.entries( + reducer.currentReducerState.authentication_providers || {}, + ); + for (let index = 0; index < allProviders.length; index++) { + const [url, status] = allProviders[index]; + if ("methods" in status) { + status.methods.map((m) => { + const type: KnownAuthMethods = m.type as KnownAuthMethods; + const values = selectableProviders[type] || []; + const isFree = !m.usage_fee || m.usage_fee.endsWith(":0"); + values.push({ url, cost: m.usage_fee, isFree }); + selectableProviders[type] = values; + }); + } + } + + const allAuthMethods = + reducer.currentReducerState.authentication_methods ?? []; + const policies = reducer.currentReducerState.policies ?? []; + const policy = policies[policy_index]; + + for ( + let method_index = 0; + method_index < allAuthMethods.length; + method_index++ + ) { + policy?.methods.find((m) => m.authentication_method === method_index) + ?.provider; + } + + function sendChanges(): void { + const newMethods: MethodProvider[] = []; + allAuthMethods.forEach((method, index) => { + const oldValue = policy?.methods.find( + (m) => m.authentication_method === index, + ); + if (changedProvider[index] === undefined && oldValue !== undefined) { + newMethods.push(oldValue); + } + if ( + changedProvider[index] !== undefined && + changedProvider[index] !== "" + ) { + newMethods.push({ + authentication_method: index, + provider: changedProvider[index], + }); + } + }); + confirm(newMethods); + } + + return ( + <AnastasisClientFrame + hideNav + title={!policy ? "Backup: New Policy" : "Backup: Edit Policy"} + > + <section class="section"> + {!policy ? ( + <p>Creating a new policy #{policy_index}</p> + ) : ( + <p>Editing policy #{policy_index}</p> + )} + {allAuthMethods.map((method, index) => { + //take the url from the updated change or from the policy + const providerURL = + changedProvider[index] === undefined + ? policy?.methods.find((m) => m.authentication_method === index) + ?.provider + : changedProvider[index]; + + const type: KnownAuthMethods = method.type as KnownAuthMethods; + function changeProviderTo(url: string): void { + const copy = [...changedProvider]; + copy[index] = url; + setChangedProvider(copy); + } + return ( + <div + key={index} + class="block" + style={{ display: "flex", alignItems: "center" }} + > + <span class="icon">{authMethods[type]?.icon}</span> + <span>{method.instructions}</span> + <span> + <span class="select "> + <select + onChange={(e) => changeProviderTo(e.currentTarget.value)} + value={providerURL ?? ""} + > + <option key="none" value=""> + {" "} + << off >>{" "} + </option> + {selectableProviders[type]?.map((prov) => ( + <option key={prov.url} value={prov.url}> + {prov.url} + </option> + ))} + </select> + </span> + </span> + </div> + ); + })} + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span class="buttons"> + <button class="button" onClick={() => setChangedProvider([])}> + Reset + </button> + <button class="button is-info" onClick={sendChanges}> + Confirm + </button> + </span> + </div> + </section> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx index 1a9462b88..ea88b74a0 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx @@ -1,47 +1,56 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { PoliciesPayingScreen as TestedComponent } from './PoliciesPayingScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { PoliciesPayingScreen as TestedComponent } from "./PoliciesPayingScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/PoliciesPayingScreen', + title: "Policies paying", component: TestedComponent, + args: { + order: 9, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.policyPay); -export const WithSomePaymentRequest = createExample(TestedComponent, { +export const Example = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.policyPay, +); +export const WithSomePaymentRequest = tests.createExample(TestedComponent, {}, { ...reducerStatesExample.policyPay, - policy_payment_requests: [{ - payto: 'payto://x-taler-bank/bank.taler/account-a', - provider: 'provider1' - }, { - payto: 'payto://x-taler-bank/bank.taler/account-b', - provider: 'provider2' - }] + policy_payment_requests: [ + { + payto: "payto://x-taler-bank/bank.taler/account-a", + provider: "provider1", + }, + { + payto: "payto://x-taler-bank/bank.taler/account-b", + provider: "provider2", + }, + ], } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx index 8a39cf0e4..c48236b9d 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx @@ -1,22 +1,37 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame } from "./index.js"; export function PoliciesPayingScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "backup") { + return <div>invalid state</div>; } const payments = reducer.currentReducerState.policy_payment_requests ?? []; - + return ( - <AnastasisClientFrame hideNext title="Backup: Recovery Document Payments"> + <AnastasisClientFrame hideNav title="Backup: Recovery Document Payments"> <p> - Some of the providers require a payment to store the encrypted - recovery document. + Some of the providers require a payment to store the encrypted recovery + document. </p> <ul> {payments.map((x, i) => { diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx index 0c1842420..97e0821fd 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx @@ -1,42 +1,57 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { RecoveryFinishedScreen as TestedComponent } from './RecoveryFinishedScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { reducerStatesExample } from "../../utils/index.js"; +import { RecoveryFinishedScreen as TestedComponent } from "./RecoveryFinishedScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/RecoveryFinishedScreen', + title: "Recovery Finished", + args: { + order: 7, + }, component: TestedComponent, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const NormalEnding = createExample(TestedComponent, { +export const GoodEnding = tests.createExample(TestedComponent, {}, { ...reducerStatesExample.recoveryFinished, - core_secret: { mime: 'text/plain', value: 'hello' } + recovery_document: { + secret_name: "the_name_of_the_secret", + }, + core_secret: { + mime: "text/plain", + value: encodeCrock( + stringToBytes("hello this is my secret, don't tell anybody"), + ), + }, } as ReducerState); -export const BadEnding = createExample(TestedComponent, reducerStatesExample.recoveryFinished); +export const BadEnding = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.recoveryFinished, +); diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx index 8c8a2c7c8..f528bc207 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx @@ -1,34 +1,129 @@ -import { - bytesToString, - decodeCrock -} from "@gnu-taler/taler-util"; +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { bytesToString, decodeCrock } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +import { useEffect, useState } from "preact/hooks"; +import { QR } from "../../components/QR.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame } from "./index.js"; export function RecoveryFinishedScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); + const [copied, setCopied] = useState(false); + useEffect(() => { + setTimeout(() => { + setCopied(false); + }, 1000); + }, [copied]); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return <div>invalid state</div>; } - const encodedSecret = reducer.currentReducerState.core_secret?.value + const secretName = reducer.currentReducerState.recovery_document?.secret_name; + const encodedSecret = reducer.currentReducerState.core_secret; if (!encodedSecret) { - return <AnastasisClientFrame title="Recovery Problem" hideNext> - <p> - Secret not found - </p> - </AnastasisClientFrame> + return ( + <AnastasisClientFrame title="Recovery Problem" hideNav> + <p>Secret not found</p> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); } - const secret = bytesToString(decodeCrock(encodedSecret)) + const secret = bytesToString(decodeCrock(encodedSecret.value)); + const plainText = + encodedSecret.value.length < 1000 && encodedSecret.mime === "text/plain"; + + let [uri, setUri] = useState(`data:${encodedSecret.mime},${secret}`); + fetch(`data:${encodedSecret.mime},${secret}`) // TODO: look into using new Blob + .then((v) => v.blob()) + .then((blob) => URL.createObjectURL(blob)) + .then((newUri) => { + setUri(newUri); + }); return ( - <AnastasisClientFrame title="Recovery Finished" hideNext> - <p> - Secret: {secret} - </p> + <AnastasisClientFrame title="Recovery Success" hideNav> + <h2 class="subtitle">Your secret was recovered</h2> + {secretName && ( + <p class="block"> + <b>Secret name:</b> {secretName} + </p> + )} + <div class="block buttons" disabled={copied}> + {plainText ? ( + <button + class="button" + onClick={() => { + navigator.clipboard.writeText(secret); + setCopied(true); + }} + > + {!copied ? "Copy" : "Copied"} + </button> + ) : undefined} + + <a + class="button is-info" + download={ + encodedSecret.filename ? encodedSecret.filename : "secret.file" + } + href={uri} + > + <div class="icon is-small "> + <i class="mdi mdi-download" /> + </div> + <span>Download content</span> + </a> + </div> + + {plainText ? ( + <div class="block"> + <QR text={secret} /> + </div> + ) : undefined} + + <div + style={{ + display: "flex", + justifyContent: "center", + }} + > + <p> + <div class="buttons ml-4"> + <button + class="button is-primary is-right" + onClick={() => reducer.reset()} + > + Start again + </button> + </div> + </p> + </div> </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx index b52699e7b..71144917a 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx @@ -1,81 +1,270 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { ReviewPoliciesScreen as TestedComponent } from './ReviewPoliciesScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { ReviewPoliciesScreen as TestedComponent } from "./ReviewPoliciesScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/ReviewPoliciesScreen', + title: "Reviewing Policies", + args: { + order: 6, + }, component: TestedComponent, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const HasPoliciesButMethodListIsEmpty = createExample(TestedComponent, { - ...reducerStatesExample.policyReview, - policies: [{ - methods: [{ - authentication_method: 0, - provider: 'asd' - },{ - authentication_method: 1, - provider: 'asd' - }] - },{ - methods: [{ - authentication_method: 1, - provider: 'asd' - }] - }], - authentication_methods: [] -} as ReducerState); +export const HasPoliciesButMethodListIsEmpty = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.policyReview, + policies: [ + { + methods: [ + { + authentication_method: 0, + provider: "asd", + }, + { + authentication_method: 1, + provider: "asd", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "asd", + }, + ], + }, + ], + authentication_methods: [], + } as ReducerState, +); -export const SomePoliciesWithMethods = createExample(TestedComponent, { - ...reducerStatesExample.policyReview, - policies: [{ - methods: [{ - authentication_method: 0, - provider: 'asd' - },{ - authentication_method: 1, - provider: 'asd' - }] - },{ - methods: [{ - authentication_method: 1, - provider: 'asd' - }] - }], - authentication_methods: [{ - challenge: 'asd', - instructions: 'ins', - type: 'type', - },{ - challenge: 'asd2', - instructions: 'ins2', - type: 'type2', - }] -} as ReducerState); +export const SomePoliciesWithMethods = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.policyReview, + policies: [ + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/", + }, + ], + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/", + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/", + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + { + methods: [ + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/", + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/", + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + ], + authentication_methods: [ + { + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA", + }, + { + type: "sms", + instructions: "SMS to 555-555", + challenge: "", + }, + { + type: "question", + instructions: "Does P equal NP?", + challenge: "C5SP8", + }, + { + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA", + }, + { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "", + }, + { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8", + }, + ], + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx index b360ccaf0..3755dac9c 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx @@ -1,51 +1,160 @@ -/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core"; import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +import { useState } from "preact/hooks"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { authMethods, KnownAuthMethods } from "./authMethod/index.js"; +import { EditPoliciesScreen } from "./EditPoliciesScreen.js"; +import { AnastasisClientFrame } from "./index.js"; export function ReviewPoliciesScreen(): VNode { - const reducer = useAnastasisContext() + const [editingPolicy, setEditingPolicy] = useState<number | undefined>(); + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "backup") { + return <div>invalid state</div>; } - const authMethods = reducer.currentReducerState.authentication_methods ?? []; + + const configuredAuthMethods = + reducer.currentReducerState.authentication_methods ?? []; const policies = reducer.currentReducerState.policies ?? []; + const providers = reducer.currentReducerState.authentication_providers ?? {}; + + if (editingPolicy !== undefined) { + return ( + <EditPoliciesScreen + index={editingPolicy} + cancel={() => setEditingPolicy(undefined)} + confirm={async (newMethods) => { + await reducer.transition("update_policy", { + policy_index: editingPolicy, + policy: newMethods, + }); + setEditingPolicy(undefined); + }} + /> + ); + } + + const errors = policies.length < 1 ? "Need more policies" : undefined; return ( - <AnastasisClientFrame title="Backup: Review Recovery Policies"> + <AnastasisClientFrame + hideNext={errors} + title="Backup: Review Recovery Policies" + > + {policies.length > 0 && ( + <p class="block"> + Based on your configured authentication method you have created, some + policies have been configured. In order to recover your secret you + have to solve all the challenges of at least one policy. + </p> + )} + {policies.length < 1 && ( + <p class="block"> + No policies had been created. Go back and add more authentication + methods. + </p> + )} + <div class="block"> + <button + class="button is-success" + style={{ marginLeft: 10 }} + onClick={() => setEditingPolicy(policies.length)} + > + Add new policy + </button> + </div> {policies.map((p, policy_index) => { const methods = p.methods - .map(x => authMethods[x.authentication_method] && ({ ...authMethods[x.authentication_method], provider: x.provider })) - .filter(x => !!x) + .map( + (x) => + configuredAuthMethods[x.authentication_method] && { + ...configuredAuthMethods[x.authentication_method], + provider: x.provider, + }, + ) + .filter((x) => !!x); - const policyName = methods.map(x => x.type).join(" + "); + const policyName = methods.map((x) => x.type).join(" + "); + + if (p.methods.length > methods.length) { + //there is at least one authentication method that is corrupted + return null; + } return ( - <div key={policy_index} class="policy"> - <h3> - Policy #{policy_index + 1}: {policyName} - </h3> - Required Authentications: - {!methods.length && <p> - No auth method found - </p>} - <ul> + <div + key={policy_index} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div> + <h3 class="subtitle"> + Policy #{policy_index + 1}: {policyName} + </h3> + {!methods.length && <p>No auth method found</p>} {methods.map((m, i) => { + const p = providers[ + m.provider + ] as AuthenticationProviderStatusOk; return ( - <li key={i}> - {m.type} ({m.instructions}) at provider {m.provider} - </li> + <p + key={i} + class="block" + style={{ display: "flex", alignItems: "center" }} + > + <span class="icon"> + {authMethods[m.type as KnownAuthMethods]?.icon} + </span> + <span> + {m.instructions} recovery provided by{" "} + <a href={m.provider} target="_blank" rel="noreferrer"> + {p.business_name} + </a> + </span> + </p> ); })} - </ul> - <div> + </div> + <div + style={{ + marginTop: "auto", + marginBottom: "auto", + display: "flex", + justifyContent: "space-between", + flexDirection: "column", + }} + > + <button + class="button is-info block" + onClick={() => setEditingPolicy(policy_index)} + > + Edit + </button> <button - onClick={() => reducer.transition("delete_policy", { policy_index })} + class="button is-danger block" + onClick={() => + reducer.transition("delete_policy", { policy_index }) + } > - Delete Policy + Delete </button> </div> </div> diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx index 18560356a..24bbb2927 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx @@ -1,44 +1,50 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { SecretEditorScreen as TestedComponent } from './SecretEditorScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { SecretEditorScreen as TestedComponent } from "./SecretEditorScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/SecretEditorScreen', + title: "Secret editor", component: TestedComponent, + args: { + order: 7, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const WithSecretNamePreselected = createExample(TestedComponent, { - ...reducerStatesExample.secretEdition, - secret_name: 'someSecretName', -} as ReducerState); +export const WithSecretNamePreselected = tests.createExample( + TestedComponent, + {}, + { + ...reducerStatesExample.secretEdition, + secret_name: "someSecretName", + } as ReducerState, +); -export const WithoutName = createExample(TestedComponent, { +export const WithoutName = tests.createExample(TestedComponent, {}, { ...reducerStatesExample.secretEdition, } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx index a5235d66c..93a27837c 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx @@ -1,65 +1,124 @@ -/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; import { - AnastasisClientFrame, - LabeledInput -} from "./index"; + FileInput, + FileTypeContent, +} from "../../components/fields/FileInput.js"; +import { TextInput } from "../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame } from "./index.js"; export function SecretEditorScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); const [secretValue, setSecretValue] = useState(""); + const [secretFile, _setSecretFile] = useState<FileTypeContent | undefined>( + undefined, + ); + function setSecretFile(v: FileTypeContent | undefined): void { + setSecretValue(""); // reset secret value when uploading a file + _setSecretFile(v); + } - const currentSecretName = reducer?.currentReducerState - && ("secret_name" in reducer.currentReducerState) - && reducer.currentReducerState.secret_name; + const currentSecretName = + reducer?.currentReducerState && + "secret_name" in reducer.currentReducerState && + reducer.currentReducerState.secret_name; const [secretName, setSecretName] = useState(currentSecretName || ""); - + if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "backup") { + return <div>invalid state</div>; } - const secretNext = (): void => { - reducer.runTransaction(async (tx) => { + const secretNext = async (): Promise<void> => { + const secret = secretFile + ? { + value: encodeCrock(stringToBytes(secretFile.content)), + filename: secretFile.name, + mime: secretFile.type, + } + : { + value: encodeCrock(stringToBytes(secretValue)), + mime: "text/plain", + }; + return 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", - }, + secret, expiration: { - t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5, + t_s: new Date().getTime() + 60 * 60 * 24 * 365 * 5, }, }); await tx.transition("next", {}); }); }; + const errors = !secretName + ? "Add a secret name" + : !secretValue && !secretFile + ? "Add a secret value or a choose a file to upload" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) secretNext(); + } return ( <AnastasisClientFrame - title="Backup: Provide secret" + hideNext={errors} + title="Backup: Provide secret to backup" onNext={() => secretNext()} > - <div> - <LabeledInput - label="Secret Name:" + <div class="block"> + <TextInput + label="Secret name:" + tooltip="This allows you to uniquely identify a secret if you have made multiple back ups. The value entered here will NOT be protected by the authentication checks!" grabFocus + onConfirm={goNextIfNoErrors} bind={[secretName, setSecretName]} /> + <div> + Names should be unique, so that you can easily identify your secret + later. + </div> </div> - <div> - <LabeledInput - label="Secret Value:" + <div class="block"> + <TextInput + inputType="multiline" + disabled={!!secretFile} + onConfirm={goNextIfNoErrors} + label="Enter the secret as text:" bind={[secretValue, setSecretValue]} /> </div> + <div class="block"> + Or upload a secret file + <FileInput label="Choose file" onChange={setSecretFile} /> + {secretFile && ( + <div> + Uploading secret file <b>{secretFile.name}</b>{" "} + <a onClick={() => setSecretFile(undefined)}>cancel</a> + </div> + )} + </div> </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx index e9c597023..fb3b26e15 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx @@ -1,50 +1,83 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { SecretSelectionScreen as TestedComponent } from './SecretSelectionScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../utils/index.js"; +import { + SecretSelectionScreen, + SecretSelectionScreenFound, +} from "./SecretSelectionScreen.js"; export default { - title: 'Pages/SecretSelectionScreen', - component: TestedComponent, - argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + title: "Secret selection", + component: SecretSelectionScreen, + args: { + order: 4, }, }; -export const Example = createExample(TestedComponent, { - ...reducerStatesExample.secretSelection, - recovery_document: { - provider_url: 'http://anastasis.url/', - secret_name: 'secretName', - version: 1, +export const Example = tests.createExample( + SecretSelectionScreenFound, + { + policies: [ + { + secret_name: "The secret name 1", + attribute_mask: 1, + policy_hash: "abcdefghijklmnopqrstuvwxyz", + providers: [ + { + url: "http://someurl", + version: 1, + }, + ], + }, + { + secret_name: "The secret name 2", + attribute_mask: 1, + policy_hash: "abcdefghijklmnopqrstuvwxyz", + providers: [ + { + url: "http://someurl", + version: 1, + }, + ], + }, + ], }, -} as ReducerState); - + { + ...reducerStatesExample.secretSelection, + recovery_document: { + provider_url: "https://kudos.demo.anastasis.lu/", + secret_name: "secretName", + version: 1, + }, + } as ReducerState, +); -export const NoRecoveryDocumentFound = createExample(TestedComponent, { - ...reducerStatesExample.secretSelection, - recovery_document: undefined, -} as ReducerState); +export const NoRecoveryDocumentFound = tests.createExample( + SecretSelectionScreen, + {}, + { + ...reducerStatesExample.secretSelection, + recovery_document: undefined, + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx index 903f57868..ce44b0884 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx @@ -1,87 +1,452 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL -export function SecretSelectionScreen(): VNode { - const [selectingVersion, setSelectingVersion] = useState<boolean>(false); - const [otherProvider, setOtherProvider] = useState<string>(""); - const reducer = useAnastasisContext() + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. - const currentVersion = reducer?.currentReducerState - && ("recovery_document" in reducer.currentReducerState) - && reducer.currentReducerState.recovery_document?.version; + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - const [otherVersion, setOtherVersion] = useState<number>(currentVersion || 0); + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AggregatedPolicyMetaInfo, + AuthenticationProviderStatus, + AuthenticationProviderStatusOk, +} from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton.js"; +import { PhoneNumberInput } from "../../components/fields/NumberInput.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import AddingProviderScreen from "./AddingProviderScreen/index.js"; +import { AnastasisClientFrame } from "./index.js"; +export function SecretSelectionScreenFound({ + policies, + onManageProvider, + onNext, +}: { + policies: AggregatedPolicyMetaInfo[]; + onManageProvider: () => void; + onNext: (version: AggregatedPolicyMetaInfo) => void; +}): VNode { + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { - return <div>invalid state</div> + + if ( + !reducer.currentReducerState || + reducer.currentReducerState.reducer_type !== "recovery" + ) { + return <div>invalid state</div>; } + return ( + <AnastasisClientFrame + title="Recovery: Select secret" + hideNext="Please select version to recover" + > + <div class="columns"> + <div class="column"> + <p class="block">Found versions:</p> + {policies.map((version, i) => ( + <div key={i} class="box"> + <div + class="block" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div + style={{ + display: "flex", + flexDirection: "column", + }} + > + <div style={{ display: "flex", alignItems: "center" }}> + <b>Name:</b> <span>{version.secret_name}</span> + </div> + <div style={{ display: "flex", alignItems: "center" }}> + <b>Id:</b> + <span + class="icon has-tooltip-top" + data-tooltip={version.policy_hash} + > + <i class="mdi mdi-information" /> + </span> + <span>{version.policy_hash.substring(0, 22)}...</span> + </div> + </div> + + <div> + <AsyncButton + class="button" + onClick={async () => onNext(version)} + > + Recover + </AsyncButton> + </div> + </div> + </div> + ))} + </div> + <div class="column"> + <p> + Secret found, you can select another version or continue to the + challenges solving + </p> + <p class="block"> + <a onClick={onManageProvider}>Manage recovery providers</a> + </p> + </div> + </div> + </AnastasisClientFrame> + ); +} + +export function SecretSelectionScreen(): VNode { + const reducer = useAnastasisContext(); + const [manageProvider, setManageProvider] = useState(false); + + useEffect(() => { + async function f() { + if (reducer) { + await reducer.discoverStart(); + } + } + f().catch((e) => console.log(e)); + }, []); - function selectVersion(p: string, n: number): void { - if (!reducer) return; - reducer.runTransaction(async (tx) => { - await tx.transition("change_version", { - version: n, - provider_url: p, - }); - setSelectingVersion(false); - }); + if (!reducer) { + return <div>no reducer in context</div>; } - const recoveryDocument = reducer.currentReducerState.recovery_document - if (!recoveryDocument) { + if ( + !reducer.currentReducerState || + reducer.currentReducerState.reducer_type !== "recovery" + ) { + return <div>invalid state</div>; + } + + if (manageProvider) { return ( - <AnastasisClientFrame hideNav title="Recovery: Problem"> - <p>No recovery document found</p> - </AnastasisClientFrame> - ) + <AddingProviderScreen onCancel={async () => setManageProvider(false)} /> + ); + } + + if ( + reducer.discoveryState.state === "none" || + reducer.discoveryState.state === "active" + ) { + // Can this even happen? + return <SecretSelectionScreenWaiting />; } - if (selectingVersion) { + + const policies = reducer.discoveryState.aggregatedPolicies ?? []; + + if (policies.length === 0) { 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(reducer.currentReducerState.authentication_providers ?? {}).map( - (x, i) => ( - <option key={i} selected={x === recoveryDocument.provider_url} value={x}> - {x} + <AddingProviderScreen + onCancel={async () => setManageProvider(false)} + notifications={[ + { + message: "Secret not found", + type: "ERROR", + description: + "With the information you provided we could not found secret in any of the providers. You can try adding more providers if you think the data is correct.", + }, + ]} + /> + ); + } + + return ( + <SecretSelectionScreenFound + policies={policies} + onNext={(version) => reducer.transition("select_version", version)} + onManageProvider={async () => setManageProvider(false)} + /> + ); +} + +// export function OldSecretSelectionScreen(): VNode { +// const [selectingVersion, setSelectingVersion] = useState<boolean>(false); +// const reducer = useAnastasisContext(); +// const [manageProvider, setManageProvider] = useState(false); + +// useEffect(() => { +// async function f() { +// if (reducer) { +// await reducer.discoverStart(); +// } +// } +// f().catch((e) => console.log(e)); +// }, []); + +// const currentVersion = +// (reducer?.currentReducerState && +// "recovery_document" in reducer.currentReducerState && +// reducer.currentReducerState.recovery_document?.version) || +// 0; + +// if (!reducer) { +// return <div>no reducer in context</div>; +// } +// if ( +// !reducer.currentReducerState || +// reducer.currentReducerState.reducer_type !== "recovery" +// ) { +// return <div>invalid state</div>; +// } + +// async function doSelectVersion(p: string, n: number): Promise<void> { +// if (!reducer) return Promise.resolve(); +// return reducer.runTransaction(async (tx) => { +// await tx.transition("select_version", { +// version: n, +// provider_url: p, +// }); +// setSelectingVersion(false); +// }); +// } + +// const provs = reducer.currentReducerState.authentication_providers ?? {}; +// const recoveryDocument = reducer.currentReducerState.recovery_document; + +// if (!recoveryDocument) { +// return ( +// <ChooseAnotherProviderScreen +// providers={provs} +// selected="" +// onChange={(newProv) => doSelectVersion(newProv, 0)} +// /> +// ); +// } + +// if (selectingVersion) { +// return ( +// <SelectOtherVersionProviderScreen +// providers={provs} +// provider={recoveryDocument.provider_url} +// version={recoveryDocument.version} +// onCancel={() => setSelectingVersion(false)} +// onConfirm={doSelectVersion} +// /> +// ); +// } + +// if (manageProvider) { +// return ( +// <AddingProviderScreen onCancel={async () => setManageProvider(false)} /> +// ); +// } + +// const providerInfo = provs[ +// recoveryDocument.provider_url +// ] as AuthenticationProviderStatusOk; + +// return ( +// <AnastasisClientFrame title="Recovery: Select secret"> +// <div class="columns"> +// <div class="column"> +// <div class="box" style={{ border: "2px solid green" }}> +// <h1 class="subtitle">{providerInfo.business_name}</h1> +// <div class="block"> +// {currentVersion === 0 ? ( +// <p>Set to recover the latest version</p> +// ) : ( +// <p>Set to recover the version number {currentVersion}</p> +// )} +// </div> +// <div class="buttons is-right"> +// <button class="button" onClick={(e) => setSelectingVersion(true)}> +// Change secret's version +// </button> +// </div> +// </div> +// </div> +// <div class="column"> +// <p> +// Secret found, you can select another version or continue to the +// challenges solving +// </p> +// <p class="block"> +// <a onClick={() => setManageProvider(true)}> +// Manage recovery providers +// </a> +// </p> +// </div> +// </div> +// </AnastasisClientFrame> +// ); +// } + +function ChooseAnotherProviderScreen({ + onChange, +}: { + onChange: (prov: string) => void; +}): VNode { + const reducer = useAnastasisContext(); + + if (!reducer) { + return <div>no reducer in context</div>; + } + + if ( + !reducer.currentReducerState || + reducer.currentReducerState.reducer_type !== "recovery" + ) { + return <div>invalid state</div>; + } + const providers = reducer.currentReducerState.authentication_providers ?? {}; + + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery: Problem" + > + <p>No recovery document found, try with another provider</p> + <div class="field"> + <label class="label">Provider</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth"> + <select onChange={(e) => onChange(e.currentTarget.value)} value=""> + <option key="none" disabled selected value=""> + Choose a provider </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> + {Object.keys(providers).map((url) => { + const p = providers[url]; + if (!("methods" in p)) return null; + return ( + <option key={url} value={url}> + {p.business_name} + </option> + ); + })} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> </div> - <div> - <button onClick={() => selectVersion(otherProvider, 0)}> - Use latest version - </button> - </div> - <div> - <button onClick={() => setSelectingVersion(false)}>Cancel</button> + </div> + </AnastasisClientFrame> + ); +} + +function SelectOtherVersionProviderScreen({ + providers, + provider, + version, + onConfirm, + onCancel, +}: { + onCancel: () => void; + provider: string; + version: number; + providers: { [url: string]: AuthenticationProviderStatus }; + onConfirm: (prov: string, v: number) => Promise<void>; +}): VNode { + const [otherProvider, setOtherProvider] = useState<string>(provider); + const [otherVersion, setOtherVersion] = useState( + version > 0 ? String(version) : "", + ); + const otherProviderInfo = providers[ + otherProvider + ] as AuthenticationProviderStatusOk; + + return ( + <AnastasisClientFrame hideNav title="Recovery: Select secret"> + <div class="columns"> + <div class="column"> + <div class="box"> + <h1 class="subtitle">Provider {otherProviderInfo.business_name}</h1> + <div class="block"> + {version === 0 ? ( + <p>Set to recover the latest version</p> + ) : ( + <p>Set to recover the version number {version}</p> + )} + <p>Specify other version below or use the latest</p> + </div> + + <div class="field"> + <label class="label">Provider</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth"> + <select + onChange={(e) => setOtherProvider(e.currentTarget.value)} + value={otherProvider} + > + <option key="none" disabled selected value=""> + {" "} + Choose a provider{" "} + </option> + {Object.keys(providers).map((url) => { + const p = providers[url]; + if (!("methods" in p)) return null; + return ( + <option key={url} value={url}> + {p.business_name} + </option> + ); + })} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> + </div> + </div> + <div class="container"> + <PhoneNumberInput + label="Version" + placeholder="version number to recover" + grabFocus + bind={[otherVersion, setOtherVersion]} + /> + </div> + </div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + <div class="buttons"> + <AsyncButton + class="button" + onClick={() => onConfirm(otherProvider, 0)} + > + Use latest + </AsyncButton> + <AsyncButton + class="button is-info" + onClick={() => + onConfirm(otherProvider, parseInt(otherVersion, 10)) + } + > + Confirm + </AsyncButton> + </div> + </div> </div> - </AnastasisClientFrame> - ); - } + </div> + </AnastasisClientFrame> + ); +} + +function SecretSelectionScreenWaiting(): VNode { return ( <AnastasisClientFrame title="Recovery: Select secret"> - <p>Provider: {recoveryDocument.provider_url}</p> - <p>Secret version: {recoveryDocument.version}</p> - <p>Secret name: {recoveryDocument.secret_name}</p> - <button onClick={() => setSelectingVersion(true)}> - Select different secret - </button> + <div>loading secret versions</div> </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx deleted file mode 100644 index 2c27895c2..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolveEmailEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) 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 deleted file mode 100644 index 1a824acb8..000000000 --- a/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolvePostEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) 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 deleted file mode 100644 index 72dadbe89..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolveQuestionEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) 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.stories.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx index 69af9be42..dc707a052 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx @@ -1,121 +1,72 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { SolveScreen as TestedComponent } from './SolveScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { SolveScreen as TestedComponent } from "./SolveScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/SolveScreen', + title: "Solve Screen", component: TestedComponent, + args: { + order: 6, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const NoInformation = createExample(TestedComponent, reducerStatesExample.challengeSolving); - -export const NotSupportedChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'follow htis instructions', - type: 'chall-type', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); - -export const MismatchedChallengeId = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'follow htis instructions', - type: 'chall-type', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'no-no-no' -} as ReducerState); - -export const SmsChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'follow htis instructions', - type: 'sms', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); - -export const QuestionChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'follow htis instructions', - type: 'question', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); +export const NoInformation = tests.createExample( + TestedComponent, + reducerStatesExample.challengeSolving, +); -export const EmailChallenge = createExample(TestedComponent, { +export const NotSupportedChallenge = tests.createExample(TestedComponent, {}, { ...reducerStatesExample.challengeSolving, recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'follow htis instructions', - type: 'email', - uuid: 'ASDASDSAD!1' - }], + challenges: [ + { + instructions: "does P equals NP?", + type: "chall-type", + uuid: "ASDASDSAD!1", + }, + ], policies: [], }, - selected_challenge_uuid: 'ASDASDSAD!1' + selected_challenge_uuid: "ASDASDSAD!1", } as ReducerState); -export const PostChallenge = createExample(TestedComponent, { +export const MismatchedChallengeId = tests.createExample(TestedComponent, {}, { ...reducerStatesExample.challengeSolving, recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'follow htis instructions', - type: 'post', - uuid: 'ASDASDSAD!1' - }], + challenges: [ + { + instructions: "does P equals NP?", + type: "chall-type", + uuid: "ASDASDSAD!1", + }, + ], policies: [], }, - selected_challenge_uuid: 'ASDASDSAD!1' + selected_challenge_uuid: "no-no-no", } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx index 05ae50b48..7f4d5aa18 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx @@ -1,54 +1,255 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + ChallengeFeedback, + ChallengeFeedbackStatus, +} from "@gnu-taler/anastasis-core"; import { h, VNode } from "preact"; -import { ChallengeFeedback, ChallengeInfo } from "../../../../anastasis-core/lib"; -import { useAnastasisContext } from "../../context/anastasis"; -import { SolveEmailEntry } from "./SolveEmailEntry"; -import { SolvePostEntry } from "./SolvePostEntry"; -import { SolveQuestionEntry } from "./SolveQuestionEntry"; -import { SolveSmsEntry } from "./SolveSmsEntry"; -import { SolveUnsupportedEntry } from "./SolveUnsupportedEntry"; +import { Notifications } from "../../components/Notifications.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { authMethods, KnownAuthMethods } from "./authMethod/index.js"; +import { AnastasisClientFrame } from "./index.js"; + +export function SolveOverviewFeedbackDisplay(props: { + feedback?: ChallengeFeedback; +}): VNode { + const { feedback } = props; + if (!feedback) { + return <div />; + } + switch (feedback.state) { + case ChallengeFeedbackStatus.TalerPayment: + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: ( + <span> + To pay you can{" "} + <a + href={feedback.taler_pay_uri} + target="_blank" + rel="noreferrer" + > + click here + </a> + </span> + ), + }, + ]} + /> + ); + case ChallengeFeedbackStatus.IbanInstructions: + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: `Need to send a wire transfer to "${feedback.target_business_name}"`, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.ServerFailure: + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `Server error: response code ${feedback.http_status}`, + description: !feedback.error_response + ? undefined + : `More information: ${JSON.stringify( + feedback.error_response, + )}`, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.RateLimitExceeded: + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: "There were to many failed attempts.", + }, + ]} + /> + ); + case ChallengeFeedbackStatus.Unsupported: + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `This client doesn't support solving this type of challenge`, + description: `Use another version or contact the provider. Type of challenge "${feedback.unsupported_method}"`, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.TruthUnknown: + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `Provider doesn't recognize the type of challenge`, + description: "Contact the provider for further information", + }, + ]} + /> + ); + case ChallengeFeedbackStatus.CodeInFile: + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Required TAN can be found in file "${feedback.filename}"`, + description: feedback.display_hint + ? `HINT: ${feedback.display_hint}` + : undefined, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.CodeSent: + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Code sent to address "${feedback.address_hint}"`, + description: feedback.display_hint + ? `HINT: ${feedback.display_hint}` + : undefined, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.IncorrectAnswer: + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `The answer is wrong.`, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.Solved: + return ( + <Notifications + notifications={[ + { + type: "SUCCESS", + message: `This challenge is solved`, + }, + ]} + /> + ); + } +} export function SolveScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); } - if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + </AnastasisClientFrame> + ); } if (!reducer.currentReducerState.recovery_information) { - return <div>no recovery information found</div> + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); } if (!reducer.currentReducerState.selected_challenge_uuid) { - return <div>no selected uuid</div> + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); } + function SolveNotImplemented(): VNode { + return ( + <AnastasisClientFrame hideNav title="Not implemented"> + <p> + The challenge selected is not supported for this UI. Please update + this version or try using another policy. + </p> + {reducer && ( + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + )} + </AnastasisClientFrame> + ); + } + const chArr = reducer.currentReducerState.recovery_information.challenges; - const challengeFeedback = reducer.currentReducerState.challenge_feedback ?? {}; const selectedUuid = reducer.currentReducerState.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} - feedback={challengeFeedback[selectedUuid]} /> - ); -} + const selectedChallenge = chArr.find((ch) => ch.uuid === selectedUuid); -export interface SolveEntryProps { - challenge: ChallengeInfo; - feedback?: ChallengeFeedback; -} + const SolveDialog = + !selectedChallenge || + !authMethods[selectedChallenge.type as KnownAuthMethods] + ? SolveNotImplemented + : authMethods[selectedChallenge.type as KnownAuthMethods].solve ?? + SolveNotImplemented; + return <SolveDialog id={selectedUuid} />; +} diff --git a/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx deleted file mode 100644 index 163e0d1f3..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolveSmsEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) 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 deleted file mode 100644 index 7f538d249..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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.stories.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx index ad84cd8f2..1f6145345 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx @@ -1,35 +1,42 @@ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { StartScreen as TestedComponent } from './StartScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../utils/index.js"; +import { StartScreen as TestedComponent } from "./StartScreen.js"; export default { - title: 'Pages/StartScreen', + title: "Start screen", component: TestedComponent, + args: { + order: 1, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const InitialState = createExample(TestedComponent, reducerStatesExample.initial);
\ No newline at end of file +export const InitialState = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.initial, +); diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.tsx index 6625ec5b8..03399cfba 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.tsx @@ -1,33 +1,71 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +import { FileButton } from "../../components/FlieButton.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame } from "./index.js"; export function StartScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } return ( <AnastasisClientFrame hideNav title="Home"> - <div> - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - - <div class="buttons is-right"> - <button class="button is-success" autoFocus onClick={() => reducer.startBackup()}> - Backup - </button> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <div class="buttons"> + <button + class="button is-success" + autoFocus + onClick={() => reducer.startBackup()} + > + <div class="icon"> + <i class="mdi mdi-arrow-up" /> + </div> + <span>Backup a secret</span> + </button> - <button class="button is-info" onClick={() => reducer.startRecover()}>Recover</button> + <button + class="button is-info" + onClick={() => reducer.startRecover()} + > + <div class="icon"> + <i class="mdi mdi-arrow-down" /> </div> + <span>Recover a secret</span> + </button> + + <FileButton + label="Restore a session" + onChange={(content) => { + if (content?.type === "application/json") { + reducer.importState(content.content); + } + }} + /> - </div> - <div class="column" /> + {/* <button class="button"> + <div class="icon"><i class="mdi mdi-file" /></div> + <span>Restore a session</span> + </button> */} </div> - </section> + </div> + <div class="column" /> </div> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx index e2f3d521e..424c4884a 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx @@ -1,40 +1,47 @@ /* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { TruthsPayingScreen as TestedComponent } from './TruthsPayingScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../utils/index.js"; +import { TruthsPayingScreen as TestedComponent } from "./TruthsPayingScreen.js"; +import * as tests from "@gnu-taler/web-util/testing"; export default { - title: 'Pages/TruthsPayingScreen', + title: "Truths Paying", component: TestedComponent, + args: { + order: 10, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.truthsPaying); -export const WithPaytoList = createExample(TestedComponent, { +export const Example = tests.createExample( + TestedComponent, + {}, + reducerStatesExample.truthsPaying, +); +export const WithPaytoList = tests.createExample(TestedComponent, {}, { ...reducerStatesExample.truthsPaying, - payments: ['payto://x-taler-bank/bank/account'] + payments: ["payto://x-taler-bank/bank/account"], } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx index 319f590a0..c9a555c35 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx @@ -1,21 +1,33 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame } from "./index"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { AnastasisClientFrame } from "./index.js"; export function TruthsPayingScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if (reducer.currentReducerState?.reducer_type !== "backup") { + return <div>invalid state</div>; } const payments = reducer.currentReducerState.payments ?? []; return ( - <AnastasisClientFrame - hideNext - title="Backup: Authentication Storage Payments" - > + <AnastasisClientFrame hideNext={"FIXME"} title="Backup: Truths Paying"> <p> Some of the providers require a payment to store the encrypted authentication information. diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx new file mode 100644 index 000000000..aee7829ff --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx @@ -0,0 +1,82 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; + +export default { + title: "Auth method: Email setup", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "email"; + +export const Empty = tests.createExample( + TestedComponent[type].setup, + { + configured: [], + }, + reducerStatesExample.authEditing, +); + +export const WithOneExample = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Email to sebasjm@email.com ", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); + +export const WithMoreExamples = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Email to sebasjm@email.com", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "Email to someone@sebasjm.com", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx new file mode 100644 index 000000000..b3af0f080 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx @@ -0,0 +1,113 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { EmailInput } from "../../../components/fields/EmailInput.js"; +import { AnastasisClientFrame } from "../index.js"; +import { AuthMethodSetupProps } from "./index.js"; + +const EMAIL_PATTERN = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +export function AuthMethodEmailSetup({ + cancel, + addAuthMethod, + configured, +}: AuthMethodSetupProps): VNode { + const [email, setEmail] = useState(""); + const addEmailAuth = (): void => + addAuthMethod({ + authentication_method: { + type: "email", + instructions: `Email to ${email}`, + challenge: encodeCrock(stringToBytes(email)), + }, + }); + const emailError = !EMAIL_PATTERN.test(email) + ? "Email address is not valid" + : undefined; + const errors = !email ? "Add your email" : emailError; + + function goNextIfNoErrors(): void { + if (!errors) addEmailAuth(); + } + 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. Add the uuid from the challenge + </p> + <div> + <EmailInput + label="Email address" + error={emailError} + onConfirm={goNextIfNoErrors} + placeholder="email@domain.com" + bind={[email, setEmail]} + /> + </div> + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your emails:</div> + <div class="block"> + {configured.map((c, i) => { + return ( + <div + key={i} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <p style={{ marginBottom: "auto", marginTop: "auto" }}> + {c.instructions} + </p> + <div> + <button class="button is-danger" onClick={c.remove}> + Delete + </button> + </div> + </div> + ); + })} + </div> + </section> + )} + <div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span data-tooltip={errors}> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addEmailAuth} + > + Add + </button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx new file mode 100644 index 000000000..075bab2a7 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx @@ -0,0 +1,92 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + ChallengeFeedbackStatus, + ReducerState, +} from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; +import * as tests from "@gnu-taler/web-util/testing"; + +export default { + title: "Auth method: Email solve", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "email"; + +export const WithoutFeedback = tests.createExample( + TestedComponent[type].solve, + { + id: "uuid-1", + }, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + instructions: "Email to me@domain.com", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, +); + +export const PaymentFeedback = tests.createExample( + TestedComponent[type].solve, + { + id: "uuid-1", + }, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + instructions: "Email to me@domain.com", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + challenge_feedback: { + "uuid-1": { + state: ChallengeFeedbackStatus.TalerPayment, + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + }, + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx new file mode 100644 index 000000000..6a9595a83 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx @@ -0,0 +1,195 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { ChallengeInfo } from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton.js"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../../context/anastasis.js"; +import { useTranslationContext } from "../../../context/translation.js"; +import { AnastasisClientFrame } from "../index.js"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js"; +import { shouldHideConfirm } from "./helpers.js"; +import { AuthMethodSolveProps } from "./index.js"; + +export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, _setAnswer] = useState("A-"); + + function setAnswer(str: string): void { + //A-12345-678-1234-5678 + const unformatted = str + .replace(/^A-/, "") + .replace(/-/g, "") + .toLocaleUpperCase(); + + let result = `A-${unformatted.substring(0, 5)}`; + if (unformatted.length > 5) { + result += `-${unformatted.substring(5, 8)}`; + } + if (unformatted.length > 8) { + result += `-${unformatted.substring(8, 12)}`; + } + if (unformatted.length > 12) { + result += `-${unformatted.substring(12)}`; + } + + _setAnswer(result); + } + const [expanded, setExpanded] = useState(false); + const { i18n } = useTranslationContext(); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state, no recovery state</div> + </AnastasisClientFrame> + ); + } + + if (!reducer.currentReducerState.recovery_information) { + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state, no challenge id</div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); + } + + const chArr = reducer.currentReducerState.recovery_information.challenges; + const challengeFeedback = + reducer.currentReducerState.challenge_feedback ?? {}; + const selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { + answer: `A-${answer.replace(/^A-/, "").replace(/-/g, "").trim()}`, + }); + } + function onCancel(): void { + reducer?.back(); + } + + const error = + answer.length > 21 + ? i18n.str`The answer should not be greater than 21 characters.` + : undefined; + + return ( + <AnastasisClientFrame hideNav title="Email challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p> + An email has been sent to "<b>{selectedChallenge.instructions}</b> + ". The message has and identification code and recovery code that + starts with " + <b>A-</b>". Wait the message to arrive and the enter the recovery + code below. + </p> + {!expanded ? ( + <p> + The identification code in the email should start with " + {selectedUuid.substring(0, 10)}" + <span + class="icon has-tooltip-top" + data-tooltip="click to expand" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + ) : ( + <p> + The identification code in the email is "{selectedUuid}" + <span + class="icon has-tooltip-top" + data-tooltip="click to show less code" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + )} + <TextInput + label="Answer" + grabFocus + onConfirm={onNext} + bind={[answer, setAnswer]} + error={error} + placeholder="A-12345-678-1234-5678" + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm(feedback) && ( + <AsyncButton + class="button is-info" + onClick={onNext} + disabled={!!error} + > + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx new file mode 100644 index 000000000..d571093f7 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx @@ -0,0 +1,81 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; + +export default { + title: "Auth method: IBAN setup", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "iban"; + +export const Empty = tests.createExample( + TestedComponent[type].setup, + { + configured: [], + }, + reducerStatesExample.authEditing, +); + +export const WithOneExample = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Wire transfer from QWEASD123123 with holder Sebastian", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); +export const WithMoreExamples = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Wire transfer from QWEASD123123 with holder Javier", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "Wire transfer from QWEASD123123 with holder Sebastian", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx new file mode 100644 index 000000000..663ccb644 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx @@ -0,0 +1,129 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + canonicalJson, + encodeCrock, + stringToBytes, +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { AnastasisClientFrame } from "../index.js"; +import { AuthMethodSetupProps } from "./index.js"; + +export function AuthMethodIbanSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { + const [name, setName] = useState(""); + const [account, setAccount] = useState(""); + const addIbanAuth = (): void => + addAuthMethod({ + authentication_method: { + type: "iban", + instructions: `Wire transfer from ${account} with holder ${name}`, + challenge: encodeCrock( + stringToBytes( + canonicalJson({ + name, + account, + }), + ), + ), + }, + }); + const errors = !name + ? "Add an account name" + : !account + ? "Add an account IBAN number" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) addIbanAuth(); + } + return ( + <AnastasisClientFrame hideNav title="Add bank transfer authentication"> + <p> + For bank transfer authentication, you need to provide a bank account + (account holder name and IBAN). When recovering your secret, you will be + asked to pay the recovery fee via bank transfer from the account you + provided here. + </p> + <div> + <TextInput + label="Bank account holder name" + grabFocus + placeholder="John Smith" + onConfirm={goNextIfNoErrors} + bind={[name, setName]} + /> + <TextInput + label="IBAN" + placeholder="DE91100000000123456789" + onConfirm={goNextIfNoErrors} + bind={[account, setAccount]} + /> + </div> + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your bank accounts:</div> + <div class="block"> + {configured.map((c, i) => { + return ( + <div + key={i} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <p style={{ marginBottom: "auto", marginTop: "auto" }}> + {c.instructions} + </p> + <div> + <button class="button is-danger" onClick={c.remove}> + Delete + </button> + </div> + </div> + ); + })} + </div> + </section> + )} + <div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span data-tooltip={errors}> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addIbanAuth} + > + Add + </button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx new file mode 100644 index 000000000..2a16c8456 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ReducerState } from "@gnu-taler/anastasis-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { KnownAuthMethods, authMethods as TestedComponent } from "./index.js"; + +export default { + title: "Auth method: IBAN Solve", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "iban"; + +export const WithoutFeedback = tests.createExample( + TestedComponent[type].solve, + { + id: "uuid-1", + }, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx new file mode 100644 index 000000000..dec65812e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx @@ -0,0 +1,118 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { ChallengeInfo } from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton.js"; +import { useAnastasisContext } from "../../../context/anastasis.js"; +import { AnastasisClientFrame } from "../index.js"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js"; +import { shouldHideConfirm } from "./helpers.js"; +import { AuthMethodSolveProps } from "./index.js"; + +export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState(""); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + </AnastasisClientFrame> + ); + } + + if (!reducer.currentReducerState.recovery_information) { + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); + } + + const chArr = reducer.currentReducerState.recovery_information.challenges; + const challengeFeedback = + reducer.currentReducerState.challenge_feedback ?? {}; + const selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + return ( + <AnastasisClientFrame hideNav title="IBAN Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>Send a wire transfer to the address,</p> + <button class="button">Check</button> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm(feedback) && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx new file mode 100644 index 000000000..a893c923e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx @@ -0,0 +1,82 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; + +export default { + title: "Auth method: Post setup", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "post"; + +export const Empty = tests.createExample( + TestedComponent[type].setup, + { + configured: [], + }, + reducerStatesExample.authEditing, +); + +export const WithOneExample = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Letter to address in postal code QWE456", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); + +export const WithMoreExamples = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Letter to address in postal code QWE456", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "Letter to address in postal code ABC123", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx new file mode 100644 index 000000000..2a8199783 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx @@ -0,0 +1,161 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + canonicalJson, + encodeCrock, + stringToBytes, +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { AnastasisClientFrame } from "../index.js"; +import { AuthMethodSetupProps } from "./index.js"; + +export function AuthMethodPostSetup({ + addAuthMethod, + cancel, + configured, +}: 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, + }; + addAuthMethod({ + authentication_method: { + type: "post", + instructions: `Letter to address in postal code ${postcode}`, + challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))), + }, + }); + }; + + const errors = !fullName + ? "The full name is missing" + : !street + ? "The street is missing" + : !city + ? "The city is missing" + : !postcode + ? "The postcode is missing" + : !country + ? "The country is missing" + : undefined; + + function goNextIfNoErrors(): void { + if (!errors) addPostAuth(); + } + return ( + <AnastasisClientFrame hideNav title="Add postal authentication"> + <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> + <TextInput + grabFocus + label="Full Name" + bind={[fullName, setFullName]} + onConfirm={goNextIfNoErrors} + /> + </div> + <div> + <TextInput + onConfirm={goNextIfNoErrors} + label="Street" + bind={[street, setStreet]} + /> + </div> + <div> + <TextInput + onConfirm={goNextIfNoErrors} + label="City" + bind={[city, setCity]} + /> + </div> + <div> + <TextInput + onConfirm={goNextIfNoErrors} + label="Postal Code" + bind={[postcode, setPostcode]} + /> + </div> + <div> + <TextInput + onConfirm={goNextIfNoErrors} + label="Country" + bind={[country, setCountry]} + /> + </div> + + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your postal code:</div> + <div class="block"> + {configured.map((c, i) => { + return ( + <div + key={i} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <p style={{ marginBottom: "auto", marginTop: "auto" }}> + {c.instructions} + </p> + <div> + <button class="button is-danger" onClick={c.remove}> + Delete + </button> + </div> + </div> + ); + })} + </div> + </section> + )} + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span data-tooltip={errors}> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addPostAuth} + > + Add + </button> + </span> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx new file mode 100644 index 000000000..3495f7f63 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; +import * as tests from "@gnu-taler/web-util/testing"; + +export default { + title: "Auth method: Post solve", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "post"; + +export const WithoutFeedback = tests.createExample( + TestedComponent[type].solve, + { + id: "uuid-1", + }, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx new file mode 100644 index 000000000..8204ab1cf --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx @@ -0,0 +1,160 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { ChallengeInfo } from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton.js"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../../context/anastasis.js"; +import { useTranslationContext } from "../../../context/translation.js"; +import { AnastasisClientFrame } from "../index.js"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js"; +import { shouldHideConfirm } from "./helpers.js"; +import { AuthMethodSolveProps } from "./index.js"; + +export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, _setAnswer] = useState("A-"); + + function setAnswer(str: string): void { + //A-12345-678-1234-5678 + const unformatted = str + .replace(/^A-/, "") + .replace(/-/g, "") + .toLocaleUpperCase(); + + let result = `A-${unformatted.substring(0, 5)}`; + if (unformatted.length > 5) { + result += `-${unformatted.substring(5, 8)}`; + } + if (unformatted.length > 8) { + result += `-${unformatted.substring(8, 12)}`; + } + if (unformatted.length > 12) { + result += `-${unformatted.substring(12)}`; + } + + _setAnswer(result); + } + const { i18n } = useTranslationContext(); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + </AnastasisClientFrame> + ); + } + + if (!reducer.currentReducerState.recovery_information) { + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); + } + + const chArr = reducer.currentReducerState.recovery_information.challenges; + const challengeFeedback = + reducer.currentReducerState.challenge_feedback ?? {}; + const selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { + answer: `A-${answer.replace(/^A-/, "").replace(/-/g, "").trim()}`, + }); + } + function onCancel(): void { + reducer?.back(); + } + + const error = + answer.length > 21 + ? i18n.str`The answer should not be greater than 21 characters.` + : undefined; + + return ( + <AnastasisClientFrame hideNav title="Postal Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>Wait for the answer</p> + <TextInput + onConfirm={onNext} + label="Answer" + grabFocus + placeholder="A-12345-678-1234-5678" + error={error} + bind={[answer, setAnswer]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm(feedback) && ( + <AsyncButton + class="button is-info" + onClick={onNext} + disabled={!!error} + > + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx new file mode 100644 index 000000000..c9bc127f7 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx @@ -0,0 +1,84 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; + +export default { + title: "Auth method: Question setup", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "question"; + +export const Empty = tests.createExample( + TestedComponent[type].setup, + { + configured: [], + }, + reducerStatesExample.authEditing, +); + +export const WithOneExample = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: + "Is integer factorization polynomial? (non-quantum computer)", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); + +export const WithMoreExamples = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Does P equal NP?", + remove: () => null, + }, + { + challenge: "asd", + type, + instructions: + "Are continuous groups automatically differential groups?", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx new file mode 100644 index 000000000..7dc6fcc0c --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx @@ -0,0 +1,127 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { AnastasisClientFrame } from "../index.js"; +import { AuthMethodSetupProps } from "./index.js"; + +export function AuthMethodQuestionSetup({ + cancel, + addAuthMethod, + configured, +}: AuthMethodSetupProps): VNode { + const [questionText, setQuestionText] = useState(""); + const [answerText, setAnswerText] = useState(""); + const addQuestionAuth = (): void => + addAuthMethod({ + authentication_method: { + type: "question", + instructions: questionText, + challenge: encodeCrock(stringToBytes(answerText)), + }, + }); + + const errors = !questionText + ? "Add your security question" + : !answerText + ? "Add the answer to your question" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) addQuestionAuth(); + } + 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> + <p class="notification is-warning"> + Note that the answer is case-sensitive and must be entered in exactly + the same way (punctuation, spaces) during recovery. + </p> + <div> + <TextInput + label="Security question" + grabFocus + onConfirm={goNextIfNoErrors} + placeholder="Your question" + bind={[questionText, setQuestionText]} + /> + </div> + <div> + <TextInput + label="Answer" + onConfirm={goNextIfNoErrors} + placeholder="Your answer" + bind={[answerText, setAnswerText]} + /> + </div> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span data-tooltip={errors}> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addQuestionAuth} + > + Add + </button> + </span> + </div> + + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your security questions:</div> + <div class="block"> + {configured.map((c, i) => { + return ( + <div + key={i} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <p style={{ marginBottom: "auto", marginTop: "auto" }}> + {c.instructions} + </p> + <div> + <button class="button is-danger" onClick={c.remove}> + Delete + </button> + </div> + </div> + ); + })} + </div> + </section> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx new file mode 100644 index 000000000..dbb17ddab --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx @@ -0,0 +1,239 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + ChallengeFeedbackStatus, + ReducerState, +} from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; +import * as tests from "@gnu-taler/web-util/testing"; +import { AmountString } from "@gnu-taler/taler-util"; + +export default { + title: "Auth method: Question solve", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "question"; + +export const WithoutFeedback = tests.createExample( + TestedComponent[type].solve, + { + id: "uuid-1", + }, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, +); + +const recovery_information = { + challenges: [ + { + instructions: "does P equal NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], +}; + +export const CodeInFileFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.CodeInFile, + filename: "asd", + display_hint: "hint", + }, + }, + } as ReducerState, +); + +export const CodeSentFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.CodeSent, + address_hint: "asdasd", + display_hint: "qweqweqw", + }, + }, + } as ReducerState, +); + +export const SolvedFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Solved, + }, + }, + } as ReducerState, +); + +export const ServerFailureFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.ServerFailure, + http_status: 500, + }, + }, + } as ReducerState, +); + +export const TruthUnknownFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.TruthUnknown, + }, + }, + } as ReducerState, +); + +export const TalerPaymentFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.TalerPayment, + payment_secret: "secret", + provider: "asdasdas", + taler_pay_uri: "taler://pay/...", + }, + }, + } as ReducerState, +); + +export const UnsupportedFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Unsupported, + unsupported_method: "method", + }, + }, + } as ReducerState, +); + +export const RateLimitExceededFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.RateLimitExceeded, + }, + }, + } as ReducerState, +); + +export const IbanInstructionsFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.IbanInstructions, + challenge_amount: "EUR:1" as AmountString, + target_iban: "DE12345789000", + target_business_name: "Data Loss Incorporated", + wire_transfer_subject: "Anastasis 987654321", + answer_code: 987654321, + }, + }, + } as ReducerState, +); + +export const IncorrectAnswerFeedback = tests.createExample( + TestedComponent[type].solve, + {}, + { + ...reducerStatesExample.challengeSolving, + recovery_information, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.IncorrectAnswer, + }, + }, + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx new file mode 100644 index 000000000..abb200eb1 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx @@ -0,0 +1,128 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { ChallengeInfo } from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton.js"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../../context/anastasis.js"; +import { AnastasisClientFrame } from "../index.js"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js"; +import { shouldHideConfirm } from "./helpers.js"; +import { AuthMethodSolveProps } from "./index.js"; + +export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState(""); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + </AnastasisClientFrame> + ); + } + + if (!reducer.currentReducerState.recovery_information) { + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); + } + + const chArr = reducer.currentReducerState.recovery_information.challenges; + const challengeFeedback = + reducer.currentReducerState.challenge_feedback ?? {}; + const selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + return ( + <AnastasisClientFrame hideNav title="Question challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p> + In this challenge you need to provide the answer for the next question: + </p> + <pre>{selectedChallenge.instructions}</pre> + <p>Type the answer below</p> + <TextInput + label="Answer" + onConfirm={onNext} + grabFocus + bind={[answer, setAnswer]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm(feedback) && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx new file mode 100644 index 000000000..fbf345779 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx @@ -0,0 +1,82 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; + +export default { + title: "Auth method: SMS setup", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "sms"; + +export const Empty = tests.createExample( + TestedComponent[type].setup, + { + configured: [], + }, + reducerStatesExample.authEditing, +); + +export const WithOneExample = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "SMS to +11-1234-2345", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); + +export const WithMoreExamples = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "SMS to +11-1234-2345", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "SMS to +11-5555-2345", + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx new file mode 100644 index 000000000..87064237c --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx @@ -0,0 +1,127 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { PhoneNumberInput } from "../../../components/fields/NumberInput.js"; +import { AnastasisClientFrame } from "../index.js"; +import { AuthMethodSetupProps } from "./index.js"; + +const REGEX_JUST_NUMBERS = /^\+[0-9 ]*$/; + +function isJustNumbers(str: string): boolean { + return REGEX_JUST_NUMBERS.test(str); +} + +export function AuthMethodSmsSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { + const [mobileNumber, setMobileNumber] = useState("+"); + const addSmsAuth = (): void => { + addAuthMethod({ + authentication_method: { + type: "sms", + instructions: `SMS to ${mobileNumber}`, + challenge: encodeCrock(stringToBytes(mobileNumber)), + }, + }); + }; + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + const errors = !mobileNumber + ? "Add a mobile number" + : !mobileNumber.startsWith("+") + ? "Mobile number should start with '+'" + : !isJustNumbers(mobileNumber) + ? "Mobile number can't have other than numbers" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) addSmsAuth(); + } + 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> + <div class="container"> + <PhoneNumberInput + label="Mobile number" + placeholder="Your mobile number" + onConfirm={goNextIfNoErrors} + error={errors} + grabFocus + bind={[mobileNumber, setMobileNumber]} + /> + <div> + Enter mobile number including +CC international dialing prefix. + </div> + </div> + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your mobile numbers:</div> + <div class="block"> + {configured.map((c, i) => { + return ( + <div + key={i} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <p style={{ marginTop: "auto", marginBottom: "auto" }}> + {c.instructions} + </p> + <div> + <button class="button is-danger" onClick={c.remove}> + Delete + </button> + </div> + </div> + ); + })} + </div> + </section> + )} + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span data-tooltip={errors}> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addSmsAuth} + > + Add + </button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx new file mode 100644 index 000000000..8e3fb1a16 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx @@ -0,0 +1,61 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ReducerState } from "@gnu-taler/anastasis-core"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; +import * as tests from "@gnu-taler/web-util/testing"; + +export default { + title: "Auth method: SMS solve", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "sms"; + +export const WithoutFeedback = tests.createExample( + TestedComponent[type].solve, + { + id: "AHCC4ZJ3Z1AF8TWBKGVGEKCQ3R7HXHJ51MJ45NHNZMHYZTKJ9NW0", + }, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + instructions: "SMS to +54 11 2233 4455", + type: "question", + uuid: "AHCC4ZJ3Z1AF8TWBKGVGEKCQ3R7HXHJ51MJ45NHNZMHYZTKJ9NW0", + }, + ], + policies: [], + }, + selected_challenge_uuid: + "AHCC4ZJ3Z1AF8TWBKGVGEKCQ3R7HXHJ51MJ45NHNZMHYZTKJ9NW0", + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx new file mode 100644 index 000000000..58bb53c4f --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx @@ -0,0 +1,191 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { ChallengeInfo } from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton.js"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../../context/anastasis.js"; +import { useTranslationContext } from "../../../context/translation.js"; +import { AnastasisClientFrame } from "../index.js"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js"; +import { shouldHideConfirm } from "./helpers.js"; +import { AuthMethodSolveProps } from "./index.js"; + +export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, _setAnswer] = useState("A-"); + + function setAnswer(str: string): void { + //A-12345-678-1234-5678 + const unformatted = str + .replace(/^A-/, "") + .replace(/-/g, "") + .toLocaleUpperCase(); + + let result = `A-${unformatted.substring(0, 5)}`; + if (unformatted.length > 5) { + result += `-${unformatted.substring(5, 8)}`; + } + if (unformatted.length > 8) { + result += `-${unformatted.substring(8, 12)}`; + } + if (unformatted.length > 12) { + result += `-${unformatted.substring(12)}`; + } + + _setAnswer(result); + } + const { i18n } = useTranslationContext(); + + const [expanded, setExpanded] = useState(false); + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + </AnastasisClientFrame> + ); + } + + if (!reducer.currentReducerState.recovery_information) { + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); + } + + const chArr = reducer.currentReducerState.recovery_information.challenges; + const challengeFeedback = + reducer.currentReducerState.challenge_feedback ?? {}; + const selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { + answer: `A-${answer.replace(/^A-/, "").replace(/-/g, "").trim()}`, + }); + } + function onCancel(): void { + reducer?.back(); + } + + const error = + answer.length > 21 + ? i18n.str`The answer should not be greater than 21 characters.` + : undefined; + + return ( + <AnastasisClientFrame hideNav title="SMS Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p> + An sms has been sent to "<b>{selectedChallenge.instructions}</b> + ". The message has and identification code and recovery code that + starts with " + <b>A-</b>". Wait the message to arrive and the enter the recovery + code below. + </p> + {!expanded ? ( + <p> + The identification code in the SMS should start with " + {selectedUuid.substring(0, 10)}" + <span + class="icon has-tooltip-top" + data-tooltip="click to expand" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + ) : ( + <p> + The identification code in the SMS is "{selectedUuid}" + <span + class="icon has-tooltip-top" + data-tooltip="click to show less code" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + )} + <TextInput + label="Answer" + grabFocus + onConfirm={onNext} + bind={[answer, setAnswer]} + error={error} + placeholder="A-12345-678-1234-5678" + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm(feedback) && ( + <AsyncButton + class="button is-info" + onClick={onNext} + disabled={!!error} + > + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx new file mode 100644 index 000000000..ee66fcee1 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx @@ -0,0 +1,80 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js"; + +export default { + title: "Auth method: Totp setup", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "totp"; + +export const Empty = tests.createExample( + TestedComponent[type].setup, + { + configured: [], + }, + reducerStatesExample.authEditing, +); +export const WithOneExample = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: 'Enter 8 digits code for "Anastasis"', + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); +export const WithMoreExample = tests.createExample( + TestedComponent[type].setup, + { + configured: [ + { + challenge: "qwe", + type, + instructions: 'Enter 8 digits code for "Anastasis1"', + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: 'Enter 8 digits code for "Anastasis2"', + remove: () => null, + }, + ], + }, + reducerStatesExample.authEditing, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx new file mode 100644 index 000000000..acdcef3ac --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx @@ -0,0 +1,141 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { encodeCrock } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useMemo, useState } from "preact/hooks"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { QR } from "../../../components/QR.js"; +import { AnastasisClientFrame } from "../index.js"; +import { AuthMethodSetupProps } from "./index.js"; +import { base32enc, computeTOTPandCheck } from "./totp.js"; + +/** + * This is hard-coded in the protocol for TOTP auth. + */ +const ANASTASIS_TOTP_DIGITS = 8; + +export function AuthMethodTotpSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { + const [name, setName] = useState("anastasis"); + const [test, setTest] = useState(""); + const secretKey = useMemo(() => { + const array = new Uint8Array(32); + if (typeof window === "undefined") return array; + return window.crypto.getRandomValues(array); + }, []); + + const secret32 = base32enc(secretKey); + const totpURL = `otpauth://totp/${name}?digits=${ANASTASIS_TOTP_DIGITS}&secret=${secret32}`; + + const addTotpAuth = (): void => + addAuthMethod({ + authentication_method: { + type: "totp", + instructions: `Enter ${ANASTASIS_TOTP_DIGITS} digits code for "${name}"`, + challenge: encodeCrock(secretKey), + }, + }); + + const testCodeMatches = computeTOTPandCheck(secretKey, 8, parseInt(test, 10)); + + const errors = !name + ? "The TOTP name is missing" + : !testCodeMatches + ? "The test code doesn't match" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) addTotpAuth(); + } + return ( + <AnastasisClientFrame hideNav title="Add TOTP authentication"> + <p> + For Time-based One-Time Password (TOTP) authentication, you need to set + a name for the TOTP secret. Then, you must scan the generated QR code + with your TOTP App to import the TOTP secret into your TOTP App. + </p> + <div class="block"> + <TextInput label="TOTP Name" grabFocus bind={[name, setName]} /> + </div> + <div style={{ height: 300 }}> + <QR text={totpURL} /> + </div> + <p> + Confirm that your TOTP App works by entering the current 8-digit TOTP + code here: + </p> + <TextInput + label="Test code" + onConfirm={goNextIfNoErrors} + bind={[test, setTest]} + /> + <div> + We note that Google's implementation of TOTP is incomplete and will + not work. We recommend using FreeOTP+. + </div> + + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your TOTP numbers:</div> + <div class="block"> + {configured.map((c, i) => { + return ( + <div + key={i} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <p style={{ marginTop: "auto", marginBottom: "auto" }}> + {c.instructions} + </p> + <div> + <button class="button is-danger" onClick={c.remove}> + Delete + </button> + </div> + </div> + ); + })} + </div> + </section> + )} + <div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span data-tooltip={errors}> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addTotpAuth} + > + Add + </button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx new file mode 100644 index 000000000..c120aaadc --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ReducerState } from "@gnu-taler/anastasis-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { reducerStatesExample } from "../../../utils/index.js"; +import { KnownAuthMethods, authMethods as TestedComponent } from "./index.js"; + +export default { + title: "Auth method: Totp solve", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "totp"; + +export const WithoutFeedback = tests.createExample( + TestedComponent[type].solve, + { + id: "uuid-1", + }, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx new file mode 100644 index 000000000..0ce0e1016 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx @@ -0,0 +1,125 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { ChallengeInfo } from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton.js"; +import { TextInput } from "../../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../../context/anastasis.js"; +import { AnastasisClientFrame } from "../index.js"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js"; +import { shouldHideConfirm } from "./helpers.js"; +import { AuthMethodSolveProps } from "./index.js"; + +export function AuthMethodTotpSolve(props: AuthMethodSolveProps): VNode { + const [answerCode, setAnswerCode] = useState(""); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + if (reducer.currentReducerState?.reducer_type !== "recovery") { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + </AnastasisClientFrame> + ); + } + + if (!reducer.currentReducerState.recovery_information) { + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); + } + + const chArr = reducer.currentReducerState.recovery_information.challenges; + const challengeFeedback = + reducer.currentReducerState.challenge_feedback ?? {}; + const selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { + answer: answerCode, + }); + } + function onCancel(): void { + reducer?.back(); + } + + return ( + <AnastasisClientFrame hideNav title="TOTP Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>enter the totp solution</p> + <TextInput + label="Answer" + onConfirm={onNext} + grabFocus + bind={[answerCode, setAnswerCode]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm(feedback) && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/helpers.ts b/packages/anastasis-webui/src/pages/home/authMethod/helpers.ts new file mode 100644 index 000000000..b6d9f5bbd --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/helpers.ts @@ -0,0 +1,27 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + ChallengeFeedback, + ChallengeFeedbackStatus, +} from "@gnu-taler/anastasis-core"; + +export function shouldHideConfirm(feedback: ChallengeFeedback): boolean { + return ( + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx new file mode 100644 index 000000000..9f7f4a197 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx @@ -0,0 +1,109 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { AuthMethod } from "@gnu-taler/anastasis-core"; +import { h, VNode } from "preact"; +import postalIcon from "../../../assets/icons/auth_method/postal.svg"; +import questionIcon from "../../../assets/icons/auth_method/question.svg"; +import smsIcon from "../../../assets/icons/auth_method/sms.svg"; +import { AuthMethodEmailSetup as EmailSetup } from "./AuthMethodEmailSetup.js"; +import { AuthMethodEmailSolve as EmailSolve } from "./AuthMethodEmailSolve.js"; +import { AuthMethodIbanSetup as IbanSetup } from "./AuthMethodIbanSetup.js"; +import { AuthMethodIbanSolve as IbanSolve } from "./AuthMethodIbanSolve.js"; +import { AuthMethodPostSetup as PostalSetup } from "./AuthMethodPostSetup.js"; +import { AuthMethodPostSolve as PostalSolve } from "./AuthMethodPostSolve.js"; +import { AuthMethodQuestionSetup as QuestionSetup } from "./AuthMethodQuestionSetup.js"; +import { AuthMethodQuestionSolve as QuestionSolve } from "./AuthMethodQuestionSolve.js"; +import { AuthMethodSmsSetup as SmsSetup } from "./AuthMethodSmsSetup.js"; +import { AuthMethodSmsSolve as SmsSolve } from "./AuthMethodSmsSolve.js"; +import { AuthMethodTotpSetup as TotpSetup } from "./AuthMethodTotpSetup.js"; +import { AuthMethodTotpSolve as TotpSolve } from "./AuthMethodTotpSolve.js"; + +export type AuthMethodWithRemove = AuthMethod & { remove: () => void }; + +export interface AuthMethodSetupProps { + method: string; + addAuthMethod: (x: any) => void; + configured: AuthMethodWithRemove[]; + cancel: () => void; +} + +export interface AuthMethodSolveProps { + id: string; +} + +interface AuthMethodConfiguration { + icon: VNode; + label: string; + setup: (props: AuthMethodSetupProps) => VNode; + solve: (props: AuthMethodSolveProps) => VNode; + skip?: boolean; +} + +const ALL_METHODS = [ + "sms", + "email", + "post", + "question", + "totp", + "iban", +] as const; +export type KnownAuthMethods = typeof ALL_METHODS[number]; +export function isKnownAuthMethods(value: string): value is KnownAuthMethods { + return ALL_METHODS.includes(value as KnownAuthMethods); +} + +type KnowMethodConfig = { + [name in KnownAuthMethods]: AuthMethodConfiguration; +}; + +export const authMethods: KnowMethodConfig = { + question: { + icon: <img src={questionIcon} />, + label: "Question", + setup: QuestionSetup, + solve: QuestionSolve, + }, + sms: { + icon: <img src={smsIcon} />, + label: "SMS", + setup: SmsSetup, + solve: SmsSolve, + }, + email: { + icon: <i class="mdi mdi-email" />, + label: "Email", + setup: EmailSetup, + solve: EmailSolve, + }, + iban: { + icon: <i class="mdi mdi-bank" />, + label: "IBAN", + setup: IbanSetup, + solve: IbanSolve, + }, + post: { + icon: <img src={postalIcon} />, + label: "Physical mail", + setup: PostalSetup, + solve: PostalSolve, + }, + totp: { + icon: <i class="mdi mdi-devices" />, + label: "TOTP", + setup: TotpSetup, + solve: TotpSolve, + }, +}; diff --git a/packages/anastasis-webui/src/pages/home/authMethod/totp.ts b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts new file mode 100644 index 000000000..ff8027ced --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts @@ -0,0 +1,79 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +//@ts-ignore +import jssha from "jssha"; + +const SEARCH_RANGE = 16; +const timeStep = 30; + +export function computeTOTPandCheck( + secretKey: Uint8Array, + digits: number, + code: number, +): boolean { + const now = new Date().getTime(); + const epoch = Math.floor(Math.round(now / 1000.0) / timeStep); + + for (let ms = -SEARCH_RANGE; ms < SEARCH_RANGE; ms++) { + const movingFactor = (epoch + ms).toString(16).padStart(16, "0"); + + const hmacSha = new jssha("SHA-1", "HEX", { + hmacKey: { value: secretKey, format: "UINT8ARRAY" }, + }); + hmacSha.update(movingFactor); + const hmac_text = hmacSha.getHMAC("UINT8ARRAY"); + + const offset = hmac_text[hmac_text.length - 1] & 0xf; + + const otp = + (((hmac_text[offset + 0] << 24) + + (hmac_text[offset + 1] << 16) + + (hmac_text[offset + 2] << 8) + + hmac_text[offset + 3]) & + 0x7fffffff) % + Math.pow(10, digits); + + if (otp == code) return true; + } + return false; +} + +const encTable__ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split(""); +export function base32enc(buffer: Uint8Array): string { + let rpos = 0; + let bits = 0; + let vbit = 0; + + let result = ""; + while (rpos < buffer.length || vbit > 0) { + if (rpos < buffer.length && vbit < 5) { + bits = (bits << 8) | buffer[rpos++]; + vbit += 8; + } + if (vbit < 5) { + bits <<= 5 - vbit; + vbit = 5; + } + result += encTable__[(bits >> (vbit - 5)) & 31]; + vbit -= 5; + } + return result; +} + +// const array = new Uint8Array(256) +// const secretKey = window.crypto.getRandomValues(array) +// console.log(base32enc(secretKey)) diff --git a/packages/anastasis-webui/src/pages/home/index.stories.tsx b/packages/anastasis-webui/src/pages/home/index.stories.tsx new file mode 100644 index 000000000..b4525b423 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/index.stories.tsx @@ -0,0 +1,52 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export * as AddingProviderScreen from "./AddingProviderScreen/stories.js"; +export * as AttributeEntryScreen from "./AttributeEntryScreen.stories.js"; + +export * as AuthenticationEditorScreen from "./AuthenticationEditorScreen.stories.js"; +export * as authMethod_AuthMethodEmailSetup from "./authMethod/AuthMethodEmailSetup.stories.js"; +export * as authMethod_AuthMethodEmailSolve from "./authMethod/AuthMethodEmailSolve.stories.js"; +export * as authMethod_AuthMethodIbanSetup from "./authMethod/AuthMethodIbanSetup.stories.js"; +export * as authMethod_AuthMethodIbanSolve from "./authMethod/AuthMethodIbanSolve.stories.js"; +export * as authMethod_AuthMethodPostSetup from "./authMethod/AuthMethodPostSetup.stories.js"; +export * as authMethod_AuthMethodPostSolve from "./authMethod/AuthMethodPostSolve.stories.js"; +export * as authMethod_AuthMethodQuestionSetup from "./authMethod/AuthMethodQuestionSetup.stories.js"; +export * as authMethod_AuthMethodQuestionSolve from "./authMethod/AuthMethodQuestionSolve.stories.js"; +export * as authMethod_AuthMethodSmsSetup from "./authMethod/AuthMethodSmsSetup.stories.js"; +export * as authMethod_AuthMethodSmsSolve from "./authMethod/AuthMethodSmsSolve.stories.js"; +export * as authMethod_AuthMethodTotpSetup from "./authMethod/AuthMethodTotpSetup.stories.js"; +export * as authMethod_AuthMethodTotpSolve from "./authMethod/AuthMethodTotpSolve.stories.js"; + +export * as BackupFinishedScreen from "./BackupFinishedScreen.stories.js"; +export * as ChallengeOverviewScreen from "./ChallengeOverviewScreen.stories.js"; +export * as ChallengePayingScreen from "./ChallengePayingScreen.stories.js"; +export * as ContinentSelectionScreen from "./ContinentSelectionScreen.stories.js"; +export * as EditPoliciesScreen from "./EditPoliciesScreen.stories.js"; +export * as PoliciesPayingScreen from "./PoliciesPayingScreen.stories.js"; +export * as RecoveryFinishedScreen from "./RecoveryFinishedScreen.stories.js"; + +export * as ReviewPoliciesScreen from "./ReviewPoliciesScreen.stories.js"; +export * as SecretEditorScreen from "./SecretEditorScreen.stories.js"; +export * as SecretSelectionScreen from "./SecretSelectionScreen.stories.js"; +export * as SolveScreen from "./SolveScreen.stories.js"; +export * as StartScreen from "./StartScreen.stories.js"; +export * as TruthsPayingScreen from "./TruthsPayingScreen.stories.js"; diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx index 4cec47ec8..c665144a4 100644 --- a/packages/anastasis-webui/src/pages/home/index.tsx +++ b/packages/anastasis-webui/src/pages/home/index.tsx @@ -1,43 +1,55 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { BackupStates, RecoveryStates } from "@gnu-taler/anastasis-core"; import { - BackupStates, - RecoveryStates, - ReducerStateBackup, - ReducerStateRecovery -} from "anastasis-core"; -import { - ComponentChildren, Fragment, + ComponentChildren, + Fragment, FunctionalComponent, h, - VNode + VNode, } from "preact"; +import { useCallback, useEffect, useErrorBoundary } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton.js"; +import { Menu } from "../../components/menu/index.js"; +import { Notifications } from "../../components/Notifications.js"; import { - useErrorBoundary, - useLayoutEffect, - useRef -} from "preact/hooks"; -import { Menu } from "../../components/menu"; -import { AnastasisProvider, useAnastasisContext } from "../../context/anastasis"; + AnastasisProvider, + useAnastasisContext, +} from "../../context/anastasis.js"; 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"; + useAnastasisReducer, +} from "../../hooks/use-anastasis-reducer.js"; +import { AttributeEntryScreen } from "./AttributeEntryScreen.js"; +import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen.js"; +import { BackupFinishedScreen } from "./BackupFinishedScreen.js"; +import { ChallengeOverviewScreen } from "./ChallengeOverviewScreen.js"; +import { ChallengePayingScreen } from "./ChallengePayingScreen.js"; +import { ContinentSelectionScreen } from "./ContinentSelectionScreen.js"; +import { PoliciesPayingScreen } from "./PoliciesPayingScreen.js"; +import { RecoveryFinishedScreen } from "./RecoveryFinishedScreen.js"; +import { ReviewPoliciesScreen } from "./ReviewPoliciesScreen.js"; +import { SecretEditorScreen } from "./SecretEditorScreen.js"; +import { SecretSelectionScreen } from "./SecretSelectionScreen.js"; +import { SolveScreen } from "./SolveScreen.js"; +import { StartScreen } from "./StartScreen.js"; +import { TruthsPayingScreen } from "./TruthsPayingScreen.js"; function isBackup(reducer: AnastasisReducerApi): boolean { - return !!reducer.currentReducerState?.backup_state; + return reducer.currentReducerState?.reducer_type === "backup"; } export function withProcessLabel( @@ -51,7 +63,11 @@ export function withProcessLabel( } interface AnastasisClientFrameProps { - onNext?(): void; + onNext?(): Promise<void>; + /** + * Override for the "back" functionality. + */ + onBack?(): Promise<void>; title: string; children: ComponentChildren; /** @@ -61,7 +77,7 @@ interface AnastasisClientFrameProps { /** * Hide only the "next" button. */ - hideNext?: boolean; + hideNext?: string; } function ErrorBoundary(props: { @@ -69,7 +85,7 @@ function ErrorBoundary(props: { children: ComponentChildren; }): VNode { const [error, resetError] = useErrorBoundary((error) => - console.log("got error", error), + console.log("ErrorBoundary got error", error), ); if (error) { return ( @@ -91,39 +107,100 @@ function ErrorBoundary(props: { return <div>{props.children}</div>; } +let currentHistoryId = 0; + export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { const reducer = useAnastasisContext(); - if (!reducer) { - return <p>Fatal: Reducer must be in context.</p>; - } - const next = (): void => { + + const doBack = async (): Promise<void> => { + if (props.onBack) { + await props.onBack(); + } else { + if (!reducer) return; + await reducer.back(); + } + }; + const doNext = async (fromPopstate?: boolean): Promise<void> => { + if (!fromPopstate) { + try { + const nextId: number = + (history.state && typeof history.state.id === "number" + ? history.state.id + : 0) + 1; + + currentHistoryId = nextId; + + history.pushState({ id: nextId }, "unused", `#${nextId}`); + } catch (e) { + console.log("ERROR doNext ", e); + } + } + if (props.onNext) { - props.onNext(); + await props.onNext(); } else { - reducer.transition("next", {}); + if (!reducer) return; + await reducer.transition("next", {}); } }; const handleKeyPress = ( e: h.JSX.TargetedKeyboardEvent<HTMLDivElement>, ): void => { - console.log("Got key press", e.key); + // console.log("Got key press", e.key); // FIXME: By default, "next" action should be executed here }; + + const browserOnBackButton = useCallback(async (ev: PopStateEvent) => { + //check if we are going back or forward + if (!ev.state || ev.state.id === 0 || ev.state.id < currentHistoryId) { + await doBack(); + } else { + await doNext(true); + } + + // reducer + return false; + }, []); + useEffect(() => { + window.addEventListener("popstate", browserOnBackButton); + + return () => { + window.removeEventListener("popstate", browserOnBackButton); + }; + }, []); + // if (!reducer) { + // return <p>Fatal: Reducer must be in context.</p>; + // } + return ( <Fragment> - <Menu title="Anastasis" /> - <div> - <div class="home" onKeyPress={(e) => handleKeyPress(e)}> - <h1>{props.title}</h1> - <ErrorBanner /> + <div class="home" onKeyPress={(e) => handleKeyPress(e)}> + <h1 class="title">{props.title}</h1> + <ErrorBanner /> + <section class="section is-main-section"> {props.children} {!props.hideNav ? ( - <div> - <button onClick={() => reducer.back()}>Back</button> - {!props.hideNext ? <button onClick={next}>Next</button> : null} + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => doBack()}> + Back + </button> + <AsyncButton + class="button is-info" + data-tooltip={props.hideNext} + onClick={() => doNext()} + disabled={props.hideNext !== undefined} + > + Next + </AsyncButton> </div> ) : null} - </div> + </section> </div> </Fragment> ); @@ -134,14 +211,15 @@ const AnastasisClient: FunctionalComponent = () => { return ( <AnastasisProvider value={reducer}> <ErrorBoundary reducer={reducer}> + <Menu title="Anastasis" /> <AnastasisClientImpl /> </ErrorBoundary> </AnastasisProvider> ); }; -const AnastasisClientImpl: FunctionalComponent = () => { - const reducer = useAnastasisContext() +function AnastasisClientImpl(): VNode { + const reducer = useAnastasisContext(); if (!reducer) { return <p>Fatal: Reducer must be in context.</p>; } @@ -149,115 +227,113 @@ const AnastasisClientImpl: FunctionalComponent = () => { if (!state) { return <StartScreen />; } - console.log("state", reducer.currentReducerState); + + // FIXME: Use switch statements here! if ( - state.backup_state === BackupStates.ContinentSelecting || - state.recovery_state === RecoveryStates.ContinentSelecting + (state.reducer_type === "backup" && + state.backup_state === BackupStates.ContinentSelecting) || + (state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.ContinentSelecting) || + (state.reducer_type === "backup" && + state.backup_state === BackupStates.CountrySelecting) || + (state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.CountrySelecting) ) { - return ( - <ContinentSelectionScreen /> - ); + return <ContinentSelectionScreen />; } if ( - state.backup_state === BackupStates.CountrySelecting || - state.recovery_state === RecoveryStates.CountrySelecting + (state.reducer_type === "backup" && + state.backup_state === BackupStates.UserAttributesCollecting) || + (state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.UserAttributesCollecting) ) { - return ( - <CountrySelectionScreen /> - ); + return <AttributeEntryScreen />; } if ( - state.backup_state === BackupStates.UserAttributesCollecting || - state.recovery_state === RecoveryStates.UserAttributesCollecting + state.reducer_type === "backup" && + state.backup_state === BackupStates.AuthenticationsEditing ) { - return ( - <AttributeEntryScreen /> - ); + return <AuthenticationEditorScreen />; } - if (state.backup_state === BackupStates.AuthenticationsEditing) { - return ( - <AuthenticationEditorScreen /> - ); - } - if (state.backup_state === BackupStates.PoliciesReviewing) { - return ( - <ReviewPoliciesScreen /> - ); + if ( + state.reducer_type === "backup" && + state.backup_state === BackupStates.PoliciesReviewing + ) { + return <ReviewPoliciesScreen />; } - if (state.backup_state === BackupStates.SecretEditing) { + if ( + state.reducer_type === "backup" && + state.backup_state === BackupStates.SecretEditing + ) { return <SecretEditorScreen />; } - if (state.backup_state === BackupStates.BackupFinished) { + if ( + state.reducer_type === "backup" && + state.backup_state === BackupStates.BackupFinished + ) { return <BackupFinishedScreen />; } - if (state.backup_state === BackupStates.TruthsPaying) { + if ( + state.reducer_type === "backup" && + state.backup_state === BackupStates.TruthsPaying + ) { return <TruthsPayingScreen />; } - if (state.backup_state === BackupStates.PoliciesPaying) { + if ( + state.reducer_type === "backup" && + state.backup_state === BackupStates.PoliciesPaying + ) { return <PoliciesPayingScreen />; } - if (state.recovery_state === RecoveryStates.SecretSelecting) { - return ( - <SecretSelectionScreen /> - ); + if ( + state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.SecretSelecting + ) { + return <SecretSelectionScreen />; } - if (state.recovery_state === RecoveryStates.ChallengeSelecting) { - return ( - <ChallengeOverviewScreen /> - ); + if ( + state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.ChallengeSelecting + ) { + return <ChallengeOverviewScreen />; } - if (state.recovery_state === RecoveryStates.ChallengeSolving) { + if ( + state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.ChallengeSolving + ) { return <SolveScreen />; } - if (state.recovery_state === RecoveryStates.RecoveryFinished) { - return ( - <RecoveryFinishedScreen /> - ); + if ( + state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.RecoveryFinished + ) { + return <RecoveryFinishedScreen />; + } + if ( + state.reducer_type === "recovery" && + state.recovery_state === RecoveryStates.ChallengePaying + ) { + return <ChallengePayingScreen />; } - console.log("unknown state", reducer.currentReducerState); return ( <AnastasisClientFrame hideNav title="Bug"> <p>Bug: Unknown state.</p> <div class="buttons is-right"> - <button class="button" onClick={() => reducer.reset()}>Reset</button> + <button class="button" onClick={() => reducer.reset()}> + Reset + </button> </div> </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> - ); } /** @@ -267,12 +343,16 @@ function ErrorBanner(): VNode | null { const reducer = useAnastasisContext(); if (!reducer || !reducer.currentError) return null; return ( - <div id="error"> - <p>Error: {JSON.stringify(reducer.currentError)}</p> - <button onClick={() => reducer.dismissError()}> - Dismiss Error - </button> - </div> + <Notifications + removeNotification={reducer.dismissError} + notifications={[ + { + type: "ERROR", + message: `Error code: ${reducer.currentError.code}`, + description: reducer.currentError.hint, + }, + ]} + /> ); } diff --git a/packages/anastasis-webui/src/pages/home/style.css b/packages/anastasis-webui/src/pages/home/style.css deleted file mode 100644 index e69de29bb..000000000 --- a/packages/anastasis-webui/src/pages/home/style.css +++ /dev/null diff --git a/packages/anastasis-webui/src/pages/notfound/index.tsx b/packages/anastasis-webui/src/pages/notfound/index.tsx deleted file mode 100644 index 4e74d1d9f..000000000 --- a/packages/anastasis-webui/src/pages/notfound/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { FunctionalComponent, h } from 'preact'; -import { Link } from 'preact-router/match'; - -const Notfound: FunctionalComponent = () => { - return ( - <div> - <h1>Error 404</h1> - <p>That page doesn't exist.</p> - <Link href="/"> - <h4>Back to Home</h4> - </Link> - </div> - ); -}; - -export default Notfound; diff --git a/packages/anastasis-webui/src/pages/notfound/style.css b/packages/anastasis-webui/src/pages/notfound/style.css deleted file mode 100644 index e69de29bb..000000000 --- a/packages/anastasis-webui/src/pages/notfound/style.css +++ /dev/null diff --git a/packages/anastasis-webui/src/pages/profile/index.tsx b/packages/anastasis-webui/src/pages/profile/index.tsx deleted file mode 100644 index 859a83ed4..000000000 --- a/packages/anastasis-webui/src/pages/profile/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { FunctionalComponent, h } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; - -interface Props { - user: string; -} - -const Profile: FunctionalComponent<Props> = (props: Props) => { - const { user } = props; - const [time, setTime] = useState<number>(Date.now()); - const [count, setCount] = useState<number>(0); - - // gets called when this route is navigated to - useEffect(() => { - const timer = window.setInterval(() => setTime(Date.now()), 1000); - - // gets called just before navigating away from the route - return (): void => { - clearInterval(timer); - }; - }, []); - - // update the current time - const increment = (): void => { - setCount(count + 1); - }; - - return ( - <div> - <h1>Profile: {user}</h1> - <p>This is the user profile for a user named {user}.</p> - - <div>Current time: {new Date(time).toLocaleString()}</div> - - <p> - <button onClick={increment}>Click Me</button> Clicked {count}{' '} - times. - </p> - </div> - ); -}; - -export default Profile; diff --git a/packages/anastasis-webui/src/pages/profile/style.css b/packages/anastasis-webui/src/pages/profile/style.css deleted file mode 100644 index e69de29bb..000000000 --- a/packages/anastasis-webui/src/pages/profile/style.css +++ /dev/null |